Compare commits
898
Commits
main
..
a59bbc213a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a59bbc213a | ||
|
|
3689775efc | ||
|
|
6250645fa4 | ||
|
|
6099fc651c | ||
|
|
74074093dc | ||
|
|
56d0ef01fa | ||
|
|
163828e213 | ||
|
|
401e558011 | ||
|
|
572d9610f2 | ||
|
|
1d3444d665 | ||
|
|
e0f54fb661 | ||
|
|
8bacd6aa3f | ||
|
|
b710008dbe | ||
|
|
70a405b231 | ||
|
|
bdc8f149dc | ||
|
|
2e886c7bc1 | ||
|
|
1eed22de5c | ||
|
|
c23ce6085a | ||
|
|
e798fd50a8 | ||
|
|
48b360bce1 | ||
|
|
0e083268c6 | ||
|
|
0ad8f6cb31 | ||
|
|
f8d650567a | ||
|
|
8db6f39046 | ||
|
|
e4ebd8b4fd | ||
|
|
efcd10c3c0 | ||
|
|
2a9b883e1b | ||
|
|
f770dcf2db | ||
|
|
2deb8a2069 | ||
|
|
87e19e3d02 | ||
|
|
31a754d264 | ||
|
|
767048d79b | ||
|
|
13181fa275 | ||
|
|
37e872809b | ||
|
|
b99339ea93 | ||
|
|
f0e68cb31e | ||
|
|
8fe372532b | ||
|
|
214ac33049 | ||
|
|
7b245869d5 | ||
|
|
f5a2928f1b | ||
|
|
f74146bb11 | ||
|
|
f6157bf879 | ||
|
|
704adf6fe8 | ||
|
|
f2575e04fb | ||
|
|
ba7bbbfc62 | ||
|
|
1db9947267 | ||
|
|
38d05da272 | ||
|
|
c740de95f9 | ||
|
|
483a63ede9 | ||
|
|
a817b9b61d | ||
|
|
0c6da9845c | ||
|
|
586c653e4f | ||
|
|
c8cc411aa5 | ||
|
|
71d967114d | ||
|
|
659c30f376 | ||
|
|
70d3b3b019 | ||
|
|
0b8c324944 | ||
|
|
cbbaa22f56 | ||
|
|
fb754cad92 | ||
|
|
0518cdce0b | ||
|
|
1af739cac2 | ||
|
|
b28ba3c47d | ||
|
|
fadc57a7c7 | ||
|
|
cca3946a35 | ||
|
|
18be3fdc19 | ||
|
|
939e63f244 | ||
|
|
b4c267d584 | ||
|
|
b9b31a494a | ||
|
|
b961954aa1 | ||
|
|
84287808c8 | ||
|
|
692272e3ca | ||
|
|
89d639e44e | ||
|
|
e62da0aef1 | ||
|
|
3759306e38 | ||
|
|
fcd91b4321 | ||
|
|
cf32a78ef5 | ||
|
|
7c43d957bc | ||
|
|
cc6a9ef9d3 | ||
|
|
1babfa0d64 | ||
|
|
ce940b39b8 | ||
|
|
6151fc1dac | ||
|
|
338bdb5932 | ||
|
|
bbcc845c26 | ||
|
|
1c080bc4be | ||
|
|
59b0494328 | ||
|
|
6337350b60 | ||
|
|
986acfac38 | ||
|
|
b27149b5ba | ||
|
|
eb1284060a | ||
|
|
4266ac1f12 | ||
|
|
6b4709d011 | ||
|
|
ef8d3068a8 | ||
|
|
a23c14389b | ||
|
|
6dfd8db0a6 | ||
|
|
abce2e03d1 | ||
|
|
54d7d5b74e | ||
|
|
17bb88050a | ||
|
|
8c2e20dfe8 | ||
|
|
3e2dd7ea04 | ||
|
|
70eebefac7 | ||
|
|
ac47d201d8 | ||
|
|
11e19f48e8 | ||
|
|
5ac49522d9 | ||
|
|
f9f1179db5 | ||
|
|
04527c286f | ||
|
|
e23d6571c8 | ||
|
|
0c331bbb93 | ||
|
|
a2d506cce9 | ||
|
|
3a4cf93bcc | ||
|
|
29b9fce07d | ||
|
|
9a0ba22fe8 | ||
|
|
8321761358 | ||
|
|
bdee3e844f | ||
|
|
081d66695c | ||
|
|
94c5ce4989 | ||
|
|
6a05b56481 | ||
|
|
6f34e96111 | ||
|
|
30e8c17000 | ||
|
|
62619f26b8 | ||
|
|
3e006e0bec | ||
|
|
3a4c46cc0d | ||
|
|
9ba6a28c5c | ||
|
|
5c6d837f10 | ||
|
|
634aa535de | ||
|
|
8ce607d787 | ||
|
|
85edc95652 | ||
|
|
a7d7cb5627 | ||
|
|
f3ef9d4052 | ||
|
|
f77ccd47aa | ||
|
|
006bb4e282 | ||
|
|
2af8ce9bb3 | ||
|
|
d67f9f3f3e | ||
|
|
fa89cde581 | ||
|
|
c05bd57fe1 | ||
|
|
88bb78fb23 | ||
|
|
e96cb5bdaa | ||
|
|
86844e330e | ||
|
|
cf95fea6c3 | ||
|
|
c366f8f0d3 | ||
|
|
1e6f8b9cd8 | ||
|
|
006882cd6f | ||
|
|
ceabf7d73b | ||
|
|
874dbe1e43 | ||
|
|
6e0aa3c46c | ||
|
|
ea047411ef | ||
|
|
d21201cdce | ||
|
|
c087832b74 | ||
|
|
ee11cde835 | ||
|
|
cc9fc0eeaf | ||
|
|
27de7a8b4e | ||
|
|
200b0178e7 | ||
|
|
9e237a1d98 | ||
|
|
db3b5a77bc | ||
|
|
8a6aa33974 | ||
|
|
3fc33eaa12 | ||
|
|
f2b35c108c | ||
|
|
52ddce5625 | ||
|
|
934f334b91 | ||
|
|
3130933ea4 | ||
|
|
481d7e3ab6 | ||
|
|
e76bf15135 | ||
|
|
05192a00cd | ||
|
|
0de95e3824 | ||
|
|
23055919cd | ||
|
|
6350a7851c | ||
|
|
5b3e12ec4b | ||
|
|
82dc018676 | ||
|
|
40d11dc66a | ||
|
|
b9a142ba48 | ||
|
|
afeb59d0c1 | ||
|
|
56ef299c5d | ||
|
|
fd5d1c3401 | ||
|
|
4a6bba7734 | ||
|
|
c32d3509b3 | ||
|
|
213136b92e | ||
|
|
0dc75452d6 | ||
|
|
9d1f6831df | ||
|
|
0341e2f2d8 | ||
|
|
c4739ff7d3 | ||
|
|
cac691e9ff | ||
|
|
0e12f7a75c | ||
|
|
3872dafaf1 | ||
|
|
6a905c1948 | ||
|
|
bf576bc0e3 | ||
|
|
2c182ad48d | ||
|
|
a4d29b9d6f | ||
|
|
52538d0181 | ||
|
|
75f12c1971 | ||
|
|
a41da84e3f | ||
|
|
c53b930554 | ||
|
|
1705575985 | ||
|
|
0c0742fce7 | ||
|
|
803ad3dfc6 | ||
|
|
f965cc4ecd | ||
|
|
ad3f46a9ae | ||
|
|
3ea4918ca2 | ||
|
|
26a54848f1 | ||
|
|
015337d75c | ||
|
|
5f06d7e04c | ||
|
|
1b2ad3965b | ||
|
|
7adb71efe5 | ||
|
|
f35fb12856 | ||
|
|
0295108e6b | ||
|
|
a2fa976065 | ||
|
|
b82db127ae | ||
|
|
f5e807cdbf | ||
|
|
06f28fd426 | ||
|
|
60afefac96 | ||
|
|
351fef504c | ||
|
|
3a8703d3c6 | ||
|
|
1c58165425 | ||
|
|
38640d8f75 | ||
|
|
bb1f7cdb88 | ||
|
|
4bb1b0997a | ||
|
|
f8f1235e60 | ||
|
|
fbd72f727a | ||
|
|
62eb1060d9 | ||
|
|
2fc18801a7 | ||
|
|
f0d2a7cc05 | ||
|
|
2808952153 | ||
|
|
149659064d | ||
|
|
cb310967cd | ||
|
|
3e7cb9387c | ||
|
|
cef451cb17 | ||
|
|
da9566c11f | ||
|
|
d3c0e138d8 | ||
|
|
7afc24ce51 | ||
|
|
92573ebeb4 | ||
|
|
987bd3a1c7 | ||
|
|
a95a09a062 | ||
|
|
7b2c93bcef | ||
|
|
f02058b0c0 | ||
|
|
f35742fec3 | ||
|
|
7b08e6a45e | ||
|
|
e99cceaa52 | ||
|
|
df8c3f1e09 | ||
|
|
b65ec449a0 | ||
|
|
bb9d763416 | ||
|
|
bdddbf678c | ||
|
|
389d417c1b | ||
|
|
f182c2209e | ||
|
|
8e9ee4bff0 | ||
|
|
7f47a21d40 | ||
|
|
7914511de5 | ||
|
|
efbc6a9b4c | ||
|
|
57ac8e772b | ||
|
|
3589f42651 | ||
|
|
1052010dd5 | ||
|
|
ae26181cf8 | ||
|
|
66b36509d2 | ||
|
|
e49062a9db | ||
|
|
9e56ff8494 | ||
|
|
831b5c17cd | ||
|
|
d04ea8549d | ||
|
|
35786703d5 | ||
|
|
10eec5fd6d | ||
|
|
d4debeab74 | ||
|
|
5c0ea360cd | ||
|
|
3b38e30df1 | ||
|
|
9a39bedd3a | ||
|
|
9e1eb9f1e5 | ||
|
|
82f8a1ef4a | ||
|
|
7c815898ea | ||
|
|
58a951eec9 | ||
|
|
ef75cb3341 | ||
|
|
1c71c0016b | ||
|
|
1a034041ab | ||
|
|
c60f9ff4d3 | ||
|
|
3efe388d3f | ||
|
|
7dc12c9e7f | ||
|
|
19c88d786e | ||
|
|
9937f532ec | ||
|
|
e380a1b9e7 | ||
|
|
13476bddf6 | ||
|
|
31d08ec973 | ||
|
|
deaa7716a2 | ||
|
|
157493b0f4 | ||
|
|
f7e1708039 | ||
|
|
20f817506f | ||
|
|
94b9d2c63b | ||
|
|
fcc2d7b748 | ||
|
|
24ddd4b294 | ||
|
|
557b34b71a | ||
|
|
e0255b28ec | ||
|
|
69855fa118 | ||
|
|
a07f2680d6 | ||
|
|
0738b1ff91 | ||
|
|
388f8bc508 | ||
|
|
a17bdc7e13 | ||
|
|
5711618e6e | ||
|
|
d022cff499 | ||
|
|
d4a480b5ea | ||
|
|
161ff392d7 | ||
|
|
4e72fbf84b | ||
|
|
bde3819510 | ||
|
|
097889ba3f | ||
|
|
20dd16734f | ||
|
|
b01665f02c | ||
|
|
aec2da11f2 | ||
|
|
272998f757 | ||
|
|
744d0ace84 | ||
|
|
326c549670 | ||
|
|
d966c9529b | ||
|
|
4350714534 | ||
|
|
1a26cacb66 | ||
|
|
0057792802 | ||
|
|
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 | ||
|
|
2ba55f692d | ||
|
|
be9489f939 | ||
|
|
aecd9f844e | ||
|
|
8486c22c32 | ||
|
|
21ab5628b0 | ||
|
|
c1eeacc0b4 | ||
|
|
6ba6121988 | ||
|
|
757b67b78c | ||
|
|
bda93e354f | ||
|
|
0271e3f971 | ||
|
|
4b48485bcc | ||
|
|
d20079f3ed | ||
|
|
ba83922660 | ||
|
|
2e3b85d7f7 | ||
|
|
a7446d1314 | ||
|
|
46a27405ae | ||
|
|
66f8942936 |
-176
@@ -15,182 +15,6 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## Version 1.36.0 - 2026-02-14
|
||||
|
||||
Increases the websocket heartbeat interval to reduce network overhead and improve connection efficiency. Loads subscriptions asynchronously to enhance responsiveness during data retrieval.
|
||||
|
||||
**Changes:** 1 files, 29 lines
|
||||
**Languages:** Python (29 lines)
|
||||
|
||||
## Version 1.35.0 - 2026-01-31
|
||||
|
||||
update py files
|
||||
|
||||
**Changes:** 1 files, 2 lines
|
||||
**Languages:** Python (2 lines)
|
||||
|
||||
## Version 1.34.0 - 2026-01-31
|
||||
|
||||
update css, html, py files
|
||||
|
||||
**Changes:** 44 files, 3802 lines
|
||||
**Languages:** CSS (712 lines), HTML (1300 lines), Python (1790 lines)
|
||||
|
||||
## Version 1.33.0 - 2026-01-24
|
||||
|
||||
Modularize RPC action handlers by splitting the monolithic rpc.py file into separate components for authentication, channels, containers, databases, mentions, messages, and miscellaneous actions. This improves code organization and facilitates easier maintenance and extension of RPC functionalities.
|
||||
|
||||
**Changes:** 13 files, 2753 lines
|
||||
**Languages:** Python (2753 lines)
|
||||
|
||||
## Version 1.32.0 - 2026-01-24
|
||||
|
||||
Adds mention functionality to the chat system, enabling users to mention other users with backend models and services handling the logic. Updates the frontend with navigation for mentions, including modified chat input and new mention navigation components.
|
||||
|
||||
**Changes:** 13 files, 438 lines
|
||||
**Languages:** CSS (75 lines), HTML (1 lines), JavaScript (125 lines), Python (237 lines)
|
||||
|
||||
## Version 1.31.0 - 2026-01-24
|
||||
|
||||
Reduces the maximum number of workers for channel message processing to 1, limiting concurrency to improve performance in resource-constrained environments.
|
||||
|
||||
**Changes:** 1 files, 2 lines
|
||||
**Languages:** Python (2 lines)
|
||||
|
||||
## Version 1.30.0 - 2026-01-17
|
||||
|
||||
Increases the maximum number of workers for processing channel messages to 30, enabling better handling of concurrent message loads.
|
||||
|
||||
**Changes:** 1 files, 2 lines
|
||||
**Languages:** Python (2 lines)
|
||||
|
||||
## Version 1.29.0 - 2026-01-17
|
||||
|
||||
Updates the site's CSS and HTML to refine the visual layout and styling of the index page. These changes enhance readability and user interface consistency without altering core functionality.
|
||||
|
||||
**Changes:** 2 files, 297 lines
|
||||
**Languages:** CSS (293 lines), HTML (4 lines)
|
||||
|
||||
## Version 1.28.0 - 2026-01-17
|
||||
|
||||
Adds a documentation hub with pages on architecture, design, API, bots, and contributions. Increases the maximum workers in the channel message service to 10 for improved performance and updates navigation links in the about and index pages.
|
||||
|
||||
**Changes:** 11 files, 2755 lines
|
||||
**Languages:** HTML (2719 lines), Python (36 lines)
|
||||
|
||||
## Version 1.27.0 - 2026-01-16
|
||||
|
||||
update html files
|
||||
|
||||
**Changes:** 1 files, 2 lines
|
||||
**Languages:** HTML (2 lines)
|
||||
|
||||
## Version 1.26.0 - 2026-01-16
|
||||
|
||||
update html files
|
||||
|
||||
**Changes:** 1 files, 2 lines
|
||||
**Languages:** HTML (2 lines)
|
||||
|
||||
## Version 1.25.0 - 2026-01-16
|
||||
|
||||
update html files
|
||||
|
||||
**Changes:** 1 files, 2 lines
|
||||
**Languages:** HTML (2 lines)
|
||||
|
||||
## Version 1.24.0 - 2026-01-03
|
||||
|
||||
update css, js files
|
||||
|
||||
**Changes:** 3 files, 60 lines
|
||||
**Languages:** CSS (44 lines), JavaScript (16 lines)
|
||||
|
||||
## Version 1.23.0 - 2026-01-03
|
||||
|
||||
update py files
|
||||
|
||||
**Changes:** 1 files, 30 lines
|
||||
**Languages:** Python (30 lines)
|
||||
|
||||
## Version 1.22.0 - 2026-01-03
|
||||
|
||||
update py files
|
||||
|
||||
**Changes:** 1 files, 8 lines
|
||||
**Languages:** Python (8 lines)
|
||||
|
||||
## Version 1.21.0 - 2026-01-03
|
||||
|
||||
update py files
|
||||
|
||||
**Changes:** 1 files, 2 lines
|
||||
**Languages:** Python (2 lines)
|
||||
|
||||
## Version 1.20.0 - 2026-01-03
|
||||
|
||||
update css files
|
||||
|
||||
**Changes:** 2 files, 25 lines
|
||||
**Languages:** CSS (25 lines)
|
||||
|
||||
## Version 1.19.0 - 2026-01-03
|
||||
|
||||
update css, html, js files
|
||||
|
||||
**Changes:** 4 files, 311 lines
|
||||
**Languages:** CSS (137 lines), HTML (3 lines), JavaScript (171 lines)
|
||||
|
||||
## Version 1.18.0 - 2026-01-03
|
||||
|
||||
update html, js files
|
||||
|
||||
**Changes:** 2 files, 271 lines
|
||||
**Languages:** HTML (39 lines), JavaScript (232 lines)
|
||||
|
||||
## Version 1.17.0 - 2026-01-03
|
||||
|
||||
update css, js, py files
|
||||
|
||||
**Changes:** 4 files, 17 lines
|
||||
**Languages:** CSS (11 lines), JavaScript (2 lines), Python (4 lines)
|
||||
|
||||
## Version 1.16.0 - 2026-01-03
|
||||
|
||||
update css, js files
|
||||
|
||||
**Changes:** 3 files, 15 lines
|
||||
**Languages:** CSS (11 lines), JavaScript (4 lines)
|
||||
|
||||
## Version 1.15.0 - 2026-01-03
|
||||
|
||||
update css files
|
||||
|
||||
**Changes:** 1 files, 19 lines
|
||||
**Languages:** CSS (19 lines)
|
||||
|
||||
## Version 1.14.0 - 2025-12-26
|
||||
|
||||
Users can now create, configure settings for, and delete channels through dedicated dialog interfaces. Developers access new RPC methods to support these channel management operations.
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "Snek"
|
||||
version = "1.36.0"
|
||||
version = "1.14.0"
|
||||
readme = "README.md"
|
||||
#license = { file = "LICENSE", content-type="text/markdown" }
|
||||
description = "Snek Chat Application by Molodetz"
|
||||
|
||||
@@ -54,8 +54,7 @@ def load_env(file_path: str = '.env') -> None:
|
||||
# Skip lines without '='
|
||||
if '=' not in line:
|
||||
continue
|
||||
if line.startswith('export '):
|
||||
line = line[7:]
|
||||
# Split into key and value at the first '='
|
||||
key, value = line.split('=', 1)
|
||||
# Set environment variable
|
||||
os.environ[key.strip()] = value.strip()
|
||||
|
||||
@@ -44,7 +44,6 @@ from snek.view.about import AboutHTMLView, AboutMDView
|
||||
from snek.view.avatar import AvatarView
|
||||
from snek.view.channel import ChannelAttachmentView,ChannelAttachmentUploadView, ChannelView
|
||||
from snek.view.docs import DocsHTMLView, DocsMDView
|
||||
from snek.view.site import ArchitectureView, DesignView, ApiView, BotsView, ContributeView
|
||||
from snek.view.drive import DriveApiView, DriveView
|
||||
from snek.view.channel import ChannelDriveApiView
|
||||
from snek.view.container import ContainerView
|
||||
@@ -79,29 +78,6 @@ from snek.view.settings.profile_pages import (
|
||||
)
|
||||
from snek.view.profile_page import ProfilePageView
|
||||
from snek.view.stats import StatsView
|
||||
from snek.view.admin import (
|
||||
AdminIndexView,
|
||||
AdminUsersIndexView,
|
||||
AdminUserEditView,
|
||||
AdminUserBanView,
|
||||
AdminChannelsIndexView,
|
||||
AdminChannelMembersView,
|
||||
AdminChannelEditView,
|
||||
AdminChannelClearView,
|
||||
AdminChannelDeleteView,
|
||||
AdminMessagesIndexView,
|
||||
AdminForumsIndexView,
|
||||
AdminThreadsView,
|
||||
AdminPostsView,
|
||||
AdminFilesIndexView,
|
||||
AdminDriveItemsView,
|
||||
AdminRepositoriesView,
|
||||
AdminNotificationsIndexView,
|
||||
AdminNotificationCreateView,
|
||||
AdminSystemIndexView,
|
||||
AdminKVView,
|
||||
AdminPushView,
|
||||
)
|
||||
from snek.view.status import StatusView
|
||||
from snek.view.terminal import TerminalSocketView, TerminalView
|
||||
from snek.view.upload import UploadView
|
||||
@@ -323,11 +299,6 @@ class Application(BaseApplication):
|
||||
self.router.add_view("/logout.html", LogoutView)
|
||||
self.router.add_view("/docs.html", DocsHTMLView)
|
||||
self.router.add_view("/docs.md", DocsMDView)
|
||||
self.router.add_view("/architecture.html", ArchitectureView)
|
||||
self.router.add_view("/design.html", DesignView)
|
||||
self.router.add_view("/api.html", ApiView)
|
||||
self.router.add_view("/bots.html", BotsView)
|
||||
self.router.add_view("/contribute.html", ContributeView)
|
||||
self.router.add_view("/status.json", StatusView)
|
||||
self.router.add_view("/settings/index.html", SettingsIndexView)
|
||||
self.router.add_view("/settings/profile.html", SettingsProfileView)
|
||||
@@ -393,27 +364,6 @@ class Application(BaseApplication):
|
||||
self.router.add_view(
|
||||
"/settings/containers/container/{uid}/delete.html", ContainersDeleteView
|
||||
)
|
||||
self.router.add_view("/admin/index.html", AdminIndexView)
|
||||
self.router.add_view("/admin/users/index.html", AdminUsersIndexView)
|
||||
self.router.add_view("/admin/users/{user_uid}/edit.html", AdminUserEditView)
|
||||
self.router.add_view("/admin/users/{user_uid}/ban.html", AdminUserBanView)
|
||||
self.router.add_view("/admin/channels/index.html", AdminChannelsIndexView)
|
||||
self.router.add_view("/admin/channels/{channel_uid}/members.html", AdminChannelMembersView)
|
||||
self.router.add_view("/admin/channels/{channel_uid}/edit.html", AdminChannelEditView)
|
||||
self.router.add_view("/admin/channels/{channel_uid}/clear.html", AdminChannelClearView)
|
||||
self.router.add_view("/admin/channels/{channel_uid}/delete.html", AdminChannelDeleteView)
|
||||
self.router.add_view("/admin/messages/index.html", AdminMessagesIndexView)
|
||||
self.router.add_view("/admin/forums/index.html", AdminForumsIndexView)
|
||||
self.router.add_view("/admin/forums/{forum_uid}/threads.html", AdminThreadsView)
|
||||
self.router.add_view("/admin/forums/threads/{thread_uid}/posts.html", AdminPostsView)
|
||||
self.router.add_view("/admin/files/index.html", AdminFilesIndexView)
|
||||
self.router.add_view("/admin/files/{drive_uid}/items.html", AdminDriveItemsView)
|
||||
self.router.add_view("/admin/files/repositories.html", AdminRepositoriesView)
|
||||
self.router.add_view("/admin/notifications/index.html", AdminNotificationsIndexView)
|
||||
self.router.add_view("/admin/notifications/create.html", AdminNotificationCreateView)
|
||||
self.router.add_view("/admin/system/index.html", AdminSystemIndexView)
|
||||
self.router.add_view("/admin/system/kv.html", AdminKVView)
|
||||
self.router.add_view("/admin/system/push.html", AdminPushView)
|
||||
self.webdav = WebdavApplication(self)
|
||||
self.git = GitApplication(self)
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ from snek.mapper.user import UserMapper
|
||||
from snek.mapper.user_property import UserPropertyMapper
|
||||
from snek.mapper.profile_page import ProfilePageMapper
|
||||
from snek.mapper.forum import ForumMapper, ThreadMapper, PostMapper, PostLikeMapper
|
||||
from snek.mapper.mention import MentionMapper
|
||||
from snek.system.object import Object
|
||||
|
||||
|
||||
@@ -41,7 +40,6 @@ def get_mappers(app=None):
|
||||
"post": PostMapper(app=app),
|
||||
"post_like": PostLikeMapper(app=app),
|
||||
"profile_page": ProfilePageMapper(app=app),
|
||||
"mention": MentionMapper(app=app),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.model.mention import MentionModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
class MentionMapper(BaseMapper):
|
||||
table_name = "mention"
|
||||
model_class = MentionModel
|
||||
@@ -1,11 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
class MentionModel(BaseModel):
|
||||
message_uid = ModelField(name="message_uid", required=True, kind=str)
|
||||
channel_uid = ModelField(name="channel_uid", required=True, kind=str)
|
||||
mentioned_user_uid = ModelField(name="mentioned_user_uid", required=True, kind=str)
|
||||
author_user_uid = ModelField(name="author_user_uid", required=True, kind=str)
|
||||
read_at = ModelField(name="read_at", required=False, kind=str)
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import functools
|
||||
|
||||
from snek.service.admin import AdminService
|
||||
from snek.service.channel import ChannelService
|
||||
from snek.service.channel_attachment import ChannelAttachmentService
|
||||
from snek.service.channel_member import ChannelMemberService
|
||||
@@ -23,7 +22,6 @@ from snek.system.object import Object
|
||||
from snek.service.statistics import StatisticsService
|
||||
from snek.service.forum import ForumService, ThreadService, PostService, PostLikeService
|
||||
from snek.service.profile_page import ProfilePageService
|
||||
from snek.service.mention import MentionService
|
||||
_service_registry = {}
|
||||
|
||||
def register_service(name, service_cls):
|
||||
@@ -68,6 +66,4 @@ register_service("thread", ThreadService)
|
||||
register_service("post", PostService)
|
||||
register_service("post_like", PostLikeService)
|
||||
register_service("profile_page", ProfilePageService)
|
||||
register_service("mention", MentionService)
|
||||
register_service("admin", AdminService)
|
||||
|
||||
|
||||
@@ -1,577 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from snek.system.service import BaseService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminService(BaseService):
|
||||
|
||||
mapper_name = None
|
||||
|
||||
async def _require_admin(self, user_uid: str) -> bool:
|
||||
user = await self.services.user.get(uid=user_uid)
|
||||
if not user or not user["is_admin"]:
|
||||
raise PermissionError("Admin access required")
|
||||
return True
|
||||
|
||||
async def get_dashboard_stats(self) -> dict[str, Any]:
|
||||
logger.info("Fetching dashboard statistics")
|
||||
user_count = await self.services.user.count(deleted_at=None)
|
||||
channel_count = await self.services.channel.count(deleted_at=None)
|
||||
message_count = await self.services.channel_message.count(deleted_at=None)
|
||||
forum_count = await self.services.forum.count(deleted_at=None)
|
||||
thread_count = await self.services.thread.count(deleted_at=None)
|
||||
post_count = await self.services.post.count(deleted_at=None)
|
||||
connected_users = await self.services.socket.get_connected_users()
|
||||
online_count = len(connected_users)
|
||||
drive_count = await self.services.drive.count(deleted_at=None)
|
||||
repository_count = await self.services.repository.count(deleted_at=None)
|
||||
notification_count = await self.services.notification.count(deleted_at=None)
|
||||
logger.debug(f"Dashboard stats: users={user_count}, channels={channel_count}, messages={message_count}")
|
||||
return {
|
||||
"users": user_count,
|
||||
"channels": channel_count,
|
||||
"messages": message_count,
|
||||
"forums": forum_count,
|
||||
"threads": thread_count,
|
||||
"posts": post_count,
|
||||
"online": online_count,
|
||||
"drives": drive_count,
|
||||
"repositories": repository_count,
|
||||
"notifications": notification_count,
|
||||
}
|
||||
|
||||
async def get_recent_activity(self, limit: int = 20) -> list[dict]:
|
||||
logger.info(f"Fetching recent activity, limit={limit}")
|
||||
messages = []
|
||||
async for message in self.services.channel_message.find(_limit=limit, _order_by="-created_at", deleted_at=None):
|
||||
user = await self.services.user.get(uid=message["user_uid"])
|
||||
channel = await self.services.channel.get(uid=message["channel_uid"])
|
||||
messages.append({
|
||||
"uid": message["uid"],
|
||||
"content": message["message"][:100] if message["message"] else "",
|
||||
"created_at": str(message["created_at"]) if message["created_at"] else None,
|
||||
"user_nick": user["nick"] if user else "Unknown",
|
||||
"user_uid": message["user_uid"],
|
||||
"channel_name": channel["name"] if channel else "Unknown",
|
||||
"channel_uid": message["channel_uid"],
|
||||
})
|
||||
logger.debug(f"Fetched {len(messages)} recent messages")
|
||||
return messages
|
||||
|
||||
async def list_users(self, page: int = 1, per_page: int = 20, search: str = None) -> dict:
|
||||
logger.info(f"Listing users: page={page}, per_page={per_page}, search={search}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
users = []
|
||||
async for user in self.services.user.find(**filters):
|
||||
if search and search.lower() not in (user["username"] or "").lower() and search.lower() not in (user["nick"] or "").lower():
|
||||
continue
|
||||
users.append({
|
||||
"uid": user["uid"],
|
||||
"username": user["username"],
|
||||
"nick": user["nick"],
|
||||
"is_admin": user["is_admin"] or False,
|
||||
"is_banned": user["is_banned"] or False,
|
||||
"created_at": str(user["created_at"]) if user["created_at"] else None,
|
||||
"last_online": str(user["last_online"]) if user["last_online"] else None,
|
||||
"city": user["city"],
|
||||
"country_short": user["country_short"],
|
||||
})
|
||||
total = await self.services.user.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(users)} users, total={total}")
|
||||
return {"users": users, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def update_user(self, admin_uid: str, target_uid: str, updates: dict) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} updating user {target_uid}")
|
||||
user = await self.services.user.get(uid=target_uid)
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
allowed_fields = ["nick", "is_admin", "is_banned", "color"]
|
||||
for field in allowed_fields:
|
||||
if field in updates:
|
||||
if field == "is_admin" and target_uid == admin_uid:
|
||||
raise ValueError("Cannot modify own admin status")
|
||||
user[field] = updates[field]
|
||||
await self.services.user.save(user)
|
||||
logger.info(f"User {target_uid} updated by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def ban_user(self, admin_uid: str, target_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} banning user {target_uid}")
|
||||
if admin_uid == target_uid:
|
||||
raise ValueError("Cannot ban yourself")
|
||||
user = await self.services.user.get(uid=target_uid)
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
user["is_banned"] = True
|
||||
await self.services.user.save(user)
|
||||
logger.info(f"User {target_uid} banned by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def unban_user(self, admin_uid: str, target_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} unbanning user {target_uid}")
|
||||
user = await self.services.user.get(uid=target_uid)
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
user["is_banned"] = False
|
||||
await self.services.user.save(user)
|
||||
logger.info(f"User {target_uid} unbanned by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def reset_password(self, admin_uid: str, target_uid: str, new_password: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} resetting password for user {target_uid}")
|
||||
if len(new_password) != 6:
|
||||
raise ValueError("Password must be exactly 6 characters")
|
||||
user = await self.services.user.get(uid=target_uid)
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
user["password"] = new_password
|
||||
await self.services.user.save(user)
|
||||
logger.info(f"Password reset for user {target_uid} by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_channels(self, page: int = 1, per_page: int = 20, search: str = None) -> dict:
|
||||
logger.info(f"Listing channels: page={page}, per_page={per_page}, search={search}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
channels = []
|
||||
async for channel in self.services.channel.find(**filters):
|
||||
if search and search.lower() not in (channel["name"] or "").lower():
|
||||
continue
|
||||
member_count = await self.services.channel_member.count(channel_uid=channel["uid"], deleted_at=None)
|
||||
message_count = await self.services.channel_message.count(channel_uid=channel["uid"], deleted_at=None)
|
||||
channels.append({
|
||||
"uid": channel["uid"],
|
||||
"name": channel["name"],
|
||||
"description": channel["description"] or "",
|
||||
"tag": channel["tag"],
|
||||
"is_private": channel["is_private"] or False,
|
||||
"created_at": str(channel["created_at"]) if channel["created_at"] else None,
|
||||
"member_count": member_count,
|
||||
"message_count": message_count,
|
||||
})
|
||||
total = await self.services.channel.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(channels)} channels, total={total}")
|
||||
return {"channels": channels, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def get_channel_members(self, channel_uid: str) -> list[dict]:
|
||||
logger.info(f"Getting members for channel {channel_uid}")
|
||||
members = []
|
||||
async for member in self.services.channel_member.find(channel_uid=channel_uid, deleted_at=None):
|
||||
user = await self.services.user.get(uid=member["user_uid"])
|
||||
members.append({
|
||||
"uid": member["uid"],
|
||||
"user_uid": member["user_uid"],
|
||||
"username": user["username"] if user else "Unknown",
|
||||
"nick": user["nick"] if user else "Unknown",
|
||||
"is_owner": member["is_owner"] or False,
|
||||
"is_banned": member["is_banned"] or False,
|
||||
"joined_at": str(member["created_at"]) if member["created_at"] else None,
|
||||
})
|
||||
logger.debug(f"Found {len(members)} members for channel {channel_uid}")
|
||||
return members
|
||||
|
||||
async def update_channel(self, admin_uid: str, channel_uid: str, updates: dict) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} updating channel {channel_uid}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
raise ValueError("Channel not found")
|
||||
allowed_fields = ["name", "description", "is_private"]
|
||||
for field in allowed_fields:
|
||||
if field in updates:
|
||||
channel[field] = updates[field]
|
||||
await self.services.channel.save(channel)
|
||||
logger.info(f"Channel {channel_uid} updated by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def delete_channel(self, admin_uid: str, channel_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting channel {channel_uid}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
raise ValueError("Channel not found")
|
||||
if channel["name"] == "public":
|
||||
raise ValueError("Cannot delete the public channel")
|
||||
channel["deleted_at"] = datetime.now()
|
||||
await self.services.channel.save(channel)
|
||||
logger.info(f"Channel {channel_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def clear_channel_history(self, admin_uid: str, channel_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} clearing history for channel {channel_uid}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
raise ValueError("Channel not found")
|
||||
deleted_count = 0
|
||||
async for message in self.services.channel_message.find(channel_uid=channel_uid, deleted_at=None):
|
||||
message["deleted_at"] = datetime.now()
|
||||
await self.services.channel_message.save(message)
|
||||
deleted_count += 1
|
||||
logger.info(f"Cleared {deleted_count} messages from channel {channel_uid}")
|
||||
return {"success": True, "deleted_count": deleted_count}
|
||||
|
||||
async def search_messages(self, query: str = None, channel_uid: str = None, user_uid: str = None, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Searching messages: query={query}, channel={channel_uid}, user={user_uid}, page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset, "_order_by": "-created_at"}
|
||||
if channel_uid:
|
||||
filters["channel_uid"] = channel_uid
|
||||
if user_uid:
|
||||
filters["user_uid"] = user_uid
|
||||
messages = []
|
||||
async for message in self.services.channel_message.find(**filters):
|
||||
if query and query.lower() not in (message["message"] or "").lower():
|
||||
continue
|
||||
user = await self.services.user.get(uid=message["user_uid"])
|
||||
channel = await self.services.channel.get(uid=message["channel_uid"])
|
||||
messages.append({
|
||||
"uid": message["uid"],
|
||||
"content": message["message"][:200] if message["message"] else "",
|
||||
"created_at": str(message["created_at"]) if message["created_at"] else None,
|
||||
"user_nick": user["nick"] if user else "Unknown",
|
||||
"user_uid": message["user_uid"],
|
||||
"channel_name": channel["name"] if channel else "Unknown",
|
||||
"channel_uid": message["channel_uid"],
|
||||
})
|
||||
total = await self.services.channel_message.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(messages)} messages")
|
||||
return {"messages": messages, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def delete_message(self, admin_uid: str, message_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting message {message_uid}")
|
||||
message = await self.services.channel_message.get(uid=message_uid)
|
||||
if not message:
|
||||
raise ValueError("Message not found")
|
||||
message["deleted_at"] = datetime.now()
|
||||
await self.services.channel_message.save(message)
|
||||
logger.info(f"Message {message_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_forums(self, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing forums: page={page}, per_page={per_page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
forums = []
|
||||
async for forum in self.services.forum.find(**filters):
|
||||
thread_count = await self.services.thread.count(forum_uid=forum["uid"], deleted_at=None)
|
||||
forums.append({
|
||||
"uid": forum["uid"],
|
||||
"name": forum["name"],
|
||||
"description": forum["description"] or "",
|
||||
"is_active": forum["is_active"] if forum["is_active"] is not None else True,
|
||||
"created_at": str(forum["created_at"]) if forum["created_at"] else None,
|
||||
"thread_count": thread_count,
|
||||
})
|
||||
total = await self.services.forum.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(forums)} forums, total={total}")
|
||||
return {"forums": forums, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def update_forum(self, admin_uid: str, forum_uid: str, updates: dict) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} updating forum {forum_uid}")
|
||||
forum = await self.services.forum.get(uid=forum_uid)
|
||||
if not forum:
|
||||
raise ValueError("Forum not found")
|
||||
allowed_fields = ["name", "description", "is_active"]
|
||||
for field in allowed_fields:
|
||||
if field in updates:
|
||||
forum[field] = updates[field]
|
||||
await self.services.forum.save(forum)
|
||||
logger.info(f"Forum {forum_uid} updated by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def delete_forum(self, admin_uid: str, forum_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting forum {forum_uid}")
|
||||
forum = await self.services.forum.get(uid=forum_uid)
|
||||
if not forum:
|
||||
raise ValueError("Forum not found")
|
||||
forum["deleted_at"] = datetime.now()
|
||||
await self.services.forum.save(forum)
|
||||
logger.info(f"Forum {forum_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_threads(self, forum_uid: str, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing threads for forum {forum_uid}: page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"forum_uid": forum_uid, "deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
threads = []
|
||||
async for thread in self.services.thread.find(**filters):
|
||||
user = await self.services.user.get(uid=thread["created_by_uid"])
|
||||
post_count = await self.services.post.count(thread_uid=thread["uid"], deleted_at=None)
|
||||
threads.append({
|
||||
"uid": thread["uid"],
|
||||
"title": thread["title"],
|
||||
"is_pinned": thread["is_pinned"] or False,
|
||||
"is_locked": thread["is_locked"] or False,
|
||||
"created_at": str(thread["created_at"]) if thread["created_at"] else None,
|
||||
"created_by_nick": user["nick"] if user else "Unknown",
|
||||
"created_by_uid": thread["created_by_uid"],
|
||||
"post_count": post_count,
|
||||
})
|
||||
total = await self.services.thread.count(forum_uid=forum_uid, deleted_at=None)
|
||||
logger.debug(f"Found {len(threads)} threads, total={total}")
|
||||
return {"threads": threads, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def toggle_thread_pin(self, admin_uid: str, thread_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} toggling pin for thread {thread_uid}")
|
||||
thread = await self.services.thread.get(uid=thread_uid)
|
||||
if not thread:
|
||||
raise ValueError("Thread not found")
|
||||
thread["is_pinned"] = not (thread["is_pinned"] or False)
|
||||
await self.services.thread.save(thread)
|
||||
logger.info(f"Thread {thread_uid} pin toggled to {thread['is_pinned']}")
|
||||
return {"success": True, "is_pinned": thread["is_pinned"]}
|
||||
|
||||
async def toggle_thread_lock(self, admin_uid: str, thread_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} toggling lock for thread {thread_uid}")
|
||||
thread = await self.services.thread.get(uid=thread_uid)
|
||||
if not thread:
|
||||
raise ValueError("Thread not found")
|
||||
thread["is_locked"] = not (thread["is_locked"] or False)
|
||||
await self.services.thread.save(thread)
|
||||
logger.info(f"Thread {thread_uid} lock toggled to {thread['is_locked']}")
|
||||
return {"success": True, "is_locked": thread["is_locked"]}
|
||||
|
||||
async def delete_thread(self, admin_uid: str, thread_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting thread {thread_uid}")
|
||||
thread = await self.services.thread.get(uid=thread_uid)
|
||||
if not thread:
|
||||
raise ValueError("Thread not found")
|
||||
thread["deleted_at"] = datetime.now()
|
||||
await self.services.thread.save(thread)
|
||||
logger.info(f"Thread {thread_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_posts(self, thread_uid: str, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing posts for thread {thread_uid}: page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"thread_uid": thread_uid, "deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
posts = []
|
||||
async for post in self.services.post.find(**filters):
|
||||
user = await self.services.user.get(uid=post["created_by_uid"])
|
||||
posts.append({
|
||||
"uid": post["uid"],
|
||||
"content": post["content"][:200] if post["content"] else "",
|
||||
"created_at": str(post["created_at"]) if post["created_at"] else None,
|
||||
"created_by_nick": user["nick"] if user else "Unknown",
|
||||
"created_by_uid": post["created_by_uid"],
|
||||
})
|
||||
total = await self.services.post.count(thread_uid=thread_uid, deleted_at=None)
|
||||
logger.debug(f"Found {len(posts)} posts, total={total}")
|
||||
return {"posts": posts, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def delete_post(self, admin_uid: str, post_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting post {post_uid}")
|
||||
post = await self.services.post.get(uid=post_uid)
|
||||
if not post:
|
||||
raise ValueError("Post not found")
|
||||
post["deleted_at"] = datetime.now()
|
||||
await self.services.post.save(post)
|
||||
logger.info(f"Post {post_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_drives(self, user_uid: str = None, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing drives: user_uid={user_uid}, page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
if user_uid:
|
||||
filters["user_uid"] = user_uid
|
||||
drives = []
|
||||
async for drive in self.services.drive.find(**filters):
|
||||
user = await self.services.user.get(uid=drive["user_uid"])
|
||||
item_count = await self.services.drive_item.count(drive_uid=drive["uid"], deleted_at=None)
|
||||
drives.append({
|
||||
"uid": drive["uid"],
|
||||
"name": drive["name"] or "Drive",
|
||||
"user_uid": drive["user_uid"],
|
||||
"user_nick": user["nick"] if user else "Unknown",
|
||||
"created_at": str(drive["created_at"]) if drive["created_at"] else None,
|
||||
"item_count": item_count,
|
||||
})
|
||||
total = await self.services.drive.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(drives)} drives, total={total}")
|
||||
return {"drives": drives, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def list_drive_items(self, drive_uid: str, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing items for drive {drive_uid}: page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"drive_uid": drive_uid, "deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
items = []
|
||||
async for item in self.services.drive_item.find(**filters):
|
||||
items.append({
|
||||
"uid": item["uid"],
|
||||
"name": item["name"] or "",
|
||||
"path": item["path"] or "",
|
||||
"size": item["size"] or 0,
|
||||
"mime_type": item["mime_type"] or "",
|
||||
"created_at": str(item["created_at"]) if item["created_at"] else None,
|
||||
})
|
||||
total = await self.services.drive_item.count(drive_uid=drive_uid, deleted_at=None)
|
||||
logger.debug(f"Found {len(items)} items, total={total}")
|
||||
return {"items": items, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def delete_drive_item(self, admin_uid: str, item_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting drive item {item_uid}")
|
||||
item = await self.services.drive_item.get(uid=item_uid)
|
||||
if not item:
|
||||
raise ValueError("Drive item not found")
|
||||
item["deleted_at"] = datetime.now()
|
||||
await self.services.drive_item.save(item)
|
||||
logger.info(f"Drive item {item_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_repositories(self, user_uid: str = None, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing repositories: user_uid={user_uid}, page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
if user_uid:
|
||||
filters["user_uid"] = user_uid
|
||||
repositories = []
|
||||
async for repo in self.services.repository.find(**filters):
|
||||
user = await self.services.user.get(uid=repo["user_uid"])
|
||||
repositories.append({
|
||||
"uid": repo["uid"],
|
||||
"name": repo["name"],
|
||||
"user_uid": repo["user_uid"],
|
||||
"user_nick": user["nick"] if user else "Unknown",
|
||||
"is_private": repo["is_private"] or False,
|
||||
"created_at": str(repo["created_at"]) if repo["created_at"] else None,
|
||||
})
|
||||
total = await self.services.repository.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(repositories)} repositories, total={total}")
|
||||
return {"repositories": repositories, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def delete_repository(self, admin_uid: str, repo_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting repository {repo_uid}")
|
||||
repo = await self.services.repository.get(uid=repo_uid)
|
||||
if not repo:
|
||||
raise ValueError("Repository not found")
|
||||
repo["deleted_at"] = datetime.now()
|
||||
await self.services.repository.save(repo)
|
||||
logger.info(f"Repository {repo_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_notifications(self, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing notifications: page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset, "_order_by": "-created_at"}
|
||||
notifications = []
|
||||
async for notif in self.services.notification.find(**filters):
|
||||
user = await self.services.user.get(uid=notif["user_uid"])
|
||||
notifications.append({
|
||||
"uid": notif["uid"],
|
||||
"message": (notif["message"] or "")[:100],
|
||||
"type": notif["type"] or "",
|
||||
"user_uid": notif["user_uid"],
|
||||
"user_nick": user["nick"] if user else "Unknown",
|
||||
"is_read": notif["is_read"] or False,
|
||||
"created_at": str(notif["created_at"]) if notif["created_at"] else None,
|
||||
})
|
||||
total = await self.services.notification.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(notifications)} notifications, total={total}")
|
||||
return {"notifications": notifications, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def mass_notify(self, admin_uid: str, message: str, user_uids: list[str] = None) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} sending mass notification")
|
||||
if not message or len(message) > 500:
|
||||
raise ValueError("Message must be between 1 and 500 characters")
|
||||
sent_count = 0
|
||||
if user_uids:
|
||||
target_users = user_uids
|
||||
else:
|
||||
target_users = []
|
||||
async for user in self.services.user.find(deleted_at=None, is_banned=False):
|
||||
target_users.append(user["uid"])
|
||||
for user_uid in target_users:
|
||||
await self.services.notification.create(
|
||||
user_uid=user_uid,
|
||||
message=message,
|
||||
notification_type="admin",
|
||||
)
|
||||
sent_count += 1
|
||||
logger.info(f"Mass notification sent to {sent_count} users by admin {admin_uid}")
|
||||
return {"success": True, "sent_count": sent_count}
|
||||
|
||||
async def get_kv_entries(self) -> list[dict]:
|
||||
logger.info("Fetching KV store entries")
|
||||
entries = []
|
||||
for row in self.app.db.query("SELECT key, value FROM kv ORDER BY key"):
|
||||
entries.append({"key": row["key"], "value": row["value"]})
|
||||
logger.debug(f"Found {len(entries)} KV entries")
|
||||
return entries
|
||||
|
||||
async def set_kv_entry(self, admin_uid: str, key: str, value: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} setting KV entry: key={key}")
|
||||
if not key or len(key) > 255:
|
||||
raise ValueError("Key must be between 1 and 255 characters")
|
||||
if len(value) > 10000:
|
||||
raise ValueError("Value must be less than 10000 characters")
|
||||
self.app.db["kv"].upsert({"key": key, "value": value}, ["key"])
|
||||
logger.info(f"KV entry set: key={key}")
|
||||
return {"success": True}
|
||||
|
||||
async def delete_kv_entry(self, admin_uid: str, key: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting KV entry: key={key}")
|
||||
self.app.db["kv"].delete(key=key)
|
||||
logger.info(f"KV entry deleted: key={key}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_push_registrations(self, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing push registrations: page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
registrations = []
|
||||
async for reg in self.services.push.find(**filters):
|
||||
user = await self.services.user.get(uid=reg["user_uid"])
|
||||
registrations.append({
|
||||
"uid": reg["uid"],
|
||||
"user_uid": reg["user_uid"],
|
||||
"user_nick": user["nick"] if user else "Unknown",
|
||||
"endpoint": (reg["endpoint"] or "")[:50] + "..." if reg["endpoint"] else "",
|
||||
"created_at": str(reg["created_at"]) if reg["created_at"] else None,
|
||||
})
|
||||
total = await self.services.push.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(registrations)} push registrations, total={total}")
|
||||
return {"registrations": registrations, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def delete_push_registration(self, admin_uid: str, registration_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting push registration {registration_uid}")
|
||||
reg = await self.services.push.get(uid=registration_uid)
|
||||
if not reg:
|
||||
raise ValueError("Push registration not found")
|
||||
reg["deleted_at"] = datetime.now()
|
||||
await self.services.push.save(reg)
|
||||
logger.info(f"Push registration {registration_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def run_maintenance(self, admin_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} running maintenance")
|
||||
self.app.db.query("VACUUM")
|
||||
self.app.db.query("ANALYZE")
|
||||
logger.info("Maintenance completed: VACUUM and ANALYZE")
|
||||
return {"success": True, "message": "Database maintenance completed"}
|
||||
@@ -44,14 +44,6 @@ class ChatService(BaseService):
|
||||
await self.app.create_task(
|
||||
self.services.notification.create_channel_message(message_uid)
|
||||
)
|
||||
await self.app.create_task(
|
||||
self.services.mention.create_mentions_for_message(
|
||||
message_uid=channel_message["uid"],
|
||||
channel_uid=channel_message["channel_uid"],
|
||||
message_text=channel_message["message"],
|
||||
author_user_uid=channel_message["user_uid"]
|
||||
)
|
||||
)
|
||||
|
||||
async def send(self, user_uid, channel_uid, message, is_final=True):
|
||||
logger.info(f"send: user_uid={user_uid}, channel_uid={channel_uid}, message_len={len(message) if message else 0}, is_final={is_final}")
|
||||
@@ -105,14 +97,5 @@ class ChatService(BaseService):
|
||||
await self.app.create_task(
|
||||
self.services.notification.create_channel_message(channel_message_uid)
|
||||
)
|
||||
if is_final:
|
||||
await self.app.create_task(
|
||||
self.services.mention.create_mentions_for_message(
|
||||
message_uid=channel_message["uid"],
|
||||
channel_uid=channel_uid,
|
||||
message_text=channel_message["message"],
|
||||
author_user_uid=user_uid
|
||||
)
|
||||
)
|
||||
|
||||
return channel_message
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import re
|
||||
import logging
|
||||
|
||||
from snek.system.model import now
|
||||
from snek.system.service import BaseService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MentionService(BaseService):
|
||||
mapper_name = "mention"
|
||||
|
||||
MENTION_REGEX = re.compile(r'@([a-zA-Z0-9_-]+)')
|
||||
|
||||
def extract_mentions(self, text):
|
||||
if not text:
|
||||
return []
|
||||
return self.MENTION_REGEX.findall(text)
|
||||
|
||||
async def resolve_mention_to_user(self, mention_text):
|
||||
if not mention_text:
|
||||
return None
|
||||
user = await self.services.user.get(username=mention_text)
|
||||
if user:
|
||||
return user
|
||||
async for u in self.services.user.find(nick=mention_text, _limit=1):
|
||||
return u
|
||||
return None
|
||||
|
||||
async def create_mentions_for_message(self, message_uid, channel_uid, message_text, author_user_uid):
|
||||
mentions = self.extract_mentions(message_text)
|
||||
if not mentions:
|
||||
return []
|
||||
|
||||
created_mentions = []
|
||||
seen_user_uids = set()
|
||||
|
||||
for mention_text in mentions:
|
||||
user = await self.resolve_mention_to_user(mention_text)
|
||||
if not user:
|
||||
continue
|
||||
if user["uid"] == author_user_uid:
|
||||
continue
|
||||
if user["uid"] in seen_user_uids:
|
||||
continue
|
||||
seen_user_uids.add(user["uid"])
|
||||
|
||||
model = await self.new()
|
||||
model["message_uid"] = message_uid
|
||||
model["channel_uid"] = channel_uid
|
||||
model["mentioned_user_uid"] = user["uid"]
|
||||
model["author_user_uid"] = author_user_uid
|
||||
|
||||
try:
|
||||
if await self.save(model):
|
||||
created_mentions.append(model)
|
||||
await self.services.socket.send_to_user(
|
||||
user["uid"],
|
||||
{
|
||||
"event": "new_mention",
|
||||
"data": {
|
||||
"mention_uid": model["uid"],
|
||||
"message_uid": message_uid,
|
||||
"channel_uid": channel_uid,
|
||||
"author_user_uid": author_user_uid
|
||||
}
|
||||
}
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to create mention: {ex}")
|
||||
|
||||
return created_mentions
|
||||
|
||||
async def get_unread_mentions(self, user_uid, channel_uid=None):
|
||||
kwargs = {
|
||||
"mentioned_user_uid": user_uid,
|
||||
"read_at": None,
|
||||
"deleted_at": None
|
||||
}
|
||||
if channel_uid:
|
||||
kwargs["channel_uid"] = channel_uid
|
||||
|
||||
results = []
|
||||
async for mention in self.find(**kwargs):
|
||||
results.append(mention)
|
||||
return results
|
||||
|
||||
async def get_unread_count(self, user_uid, channel_uid=None):
|
||||
kwargs = {
|
||||
"mentioned_user_uid": user_uid,
|
||||
"read_at": None,
|
||||
"deleted_at": None
|
||||
}
|
||||
if channel_uid:
|
||||
kwargs["channel_uid"] = channel_uid
|
||||
return await self.count(**kwargs)
|
||||
|
||||
async def mark_read(self, mention_uid, user_uid):
|
||||
mention = await self.get(uid=mention_uid)
|
||||
if not mention:
|
||||
return False
|
||||
if mention["mentioned_user_uid"] != user_uid:
|
||||
return False
|
||||
mention["read_at"] = now()
|
||||
return await self.save(mention)
|
||||
|
||||
async def mark_all_read_in_channel(self, user_uid, channel_uid):
|
||||
count = 0
|
||||
async for mention in self.find(
|
||||
mentioned_user_uid=user_uid,
|
||||
channel_uid=channel_uid,
|
||||
read_at=None,
|
||||
deleted_at=None
|
||||
):
|
||||
mention["read_at"] = now()
|
||||
try:
|
||||
if await self.save(mention):
|
||||
count += 1
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to mark mention as read: {ex}")
|
||||
return count
|
||||
|
||||
async def get_oldest_unread_mention(self, user_uid, channel_uid):
|
||||
sql = """
|
||||
SELECT m.* FROM mention m
|
||||
JOIN channel_message cm ON m.message_uid = cm.uid
|
||||
WHERE m.mentioned_user_uid = :user_uid
|
||||
AND m.channel_uid = :channel_uid
|
||||
AND m.read_at IS NULL
|
||||
AND m.deleted_at IS NULL
|
||||
AND cm.deleted_at IS NULL
|
||||
ORDER BY cm.created_at ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
async for record in self.query(sql, {"user_uid": user_uid, "channel_uid": channel_uid}):
|
||||
return record
|
||||
return None
|
||||
@@ -70,7 +70,7 @@ class SocketService(BaseService):
|
||||
self.subscribed_channels.clear()
|
||||
try:
|
||||
if self.ws and not self.ws.closed:
|
||||
await asyncio.wait_for(self.ws.close(), timeout=300.0)
|
||||
await asyncio.wait_for(self.ws.close(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Socket close timed out")
|
||||
except Exception as ex:
|
||||
|
||||
@@ -102,8 +102,6 @@ class UserService(BaseService):
|
||||
model.email.value = email
|
||||
model.username.value = username
|
||||
model.password.value = await security.hash(password)
|
||||
if not await self.count():
|
||||
model["is_admin"] = True
|
||||
if await self.save(model):
|
||||
if model:
|
||||
channel = await self.services.channel.ensure_public_channel(
|
||||
|
||||
@@ -1,696 +0,0 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
html:has(body.admin-page) {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body.admin-page {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
|
||||
body.admin-page main {
|
||||
overflow: visible;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--admin-bg: #000000;
|
||||
--admin-bg-secondary: #0f0f0f;
|
||||
--admin-bg-tertiary: #1a1a1a;
|
||||
--admin-accent: #f05a28;
|
||||
--admin-accent-hover: #e04924;
|
||||
--admin-text: #e6e6e6;
|
||||
--admin-text-muted: #888;
|
||||
--admin-text-faded: #aaa;
|
||||
--admin-border: #333;
|
||||
--admin-border-light: #444;
|
||||
--admin-danger: #8b0000;
|
||||
--admin-danger-hover: #a00000;
|
||||
--admin-warning: #c9a227;
|
||||
--admin-success: #2e7d32;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 20px;
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.admin-sidebar h2 {
|
||||
color: var(--admin-accent);
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.admin-sidebar hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--admin-border);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.admin-stats {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.admin-stats h2 {
|
||||
margin-bottom: 20px;
|
||||
color: var(--admin-accent);
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.admin-stats h3 {
|
||||
margin-top: 25px;
|
||||
margin-bottom: 15px;
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 0.9em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.time-cell {
|
||||
font-size: 0.85em;
|
||||
color: var(--admin-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
transition: border-color 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.stat-card.stat-highlight {
|
||||
border-color: var(--admin-accent);
|
||||
background: linear-gradient(135deg, var(--admin-bg-secondary) 0%, rgba(240, 90, 40, 0.1) 100%);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 0.75em;
|
||||
color: var(--admin-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 2.5em;
|
||||
font-weight: bold;
|
||||
color: var(--admin-accent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
display: block;
|
||||
font-size: 0.8em;
|
||||
color: var(--admin-success);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
display: block;
|
||||
font-size: 0.8em;
|
||||
color: var(--admin-text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
display: block;
|
||||
font-size: 1.1em;
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.admin-activity {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.admin-activity h2 {
|
||||
margin-bottom: 15px;
|
||||
color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--admin-bg-secondary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-table thead {
|
||||
background: var(--admin-bg-tertiary);
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
text-align: left;
|
||||
padding: 12px 15px;
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 0.85em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
}
|
||||
|
||||
.admin-table td {
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.admin-table tbody tr:hover {
|
||||
background: var(--admin-bg-tertiary);
|
||||
}
|
||||
|
||||
.admin-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.message-preview,
|
||||
.description-preview,
|
||||
.value-preview,
|
||||
.path-preview,
|
||||
.endpoint-preview {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-banned {
|
||||
background: rgba(139, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.row-inactive {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.row-pinned {
|
||||
background: rgba(240, 90, 40, 0.1);
|
||||
}
|
||||
|
||||
.row-locked {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.row-read {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--admin-border);
|
||||
background: var(--admin-bg-tertiary);
|
||||
color: var(--admin-text);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #2a2a2a;
|
||||
border-color: var(--admin-border-light);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--admin-accent);
|
||||
border-color: var(--admin-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--admin-accent-hover);
|
||||
border-color: var(--admin-accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--admin-bg-tertiary);
|
||||
border-color: var(--admin-border);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--admin-danger);
|
||||
border-color: var(--admin-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--admin-danger-hover);
|
||||
border-color: var(--admin-danger-hover);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: var(--admin-warning);
|
||||
border-color: var(--admin-warning);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #d4ab2a;
|
||||
border-color: #d4ab2a;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-search {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-search form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 4px;
|
||||
background: var(--admin-bg-secondary);
|
||||
color: var(--admin-text);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.search-select {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 4px;
|
||||
background: var(--admin-bg-secondary);
|
||||
color: var(--admin-text);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.search-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.admin-pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
padding: 15px;
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.pagination-pages {
|
||||
color: var(--admin-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
}
|
||||
|
||||
.admin-header h2 {
|
||||
color: var(--admin-accent);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.admin-header p {
|
||||
color: var(--admin-text-muted);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
max-width: 600px;
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-form h2 {
|
||||
color: var(--admin-accent);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="color"],
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 4px;
|
||||
background: var(--admin-bg);
|
||||
color: var(--admin-text);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.form-group.checkbox label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.form-group.checkbox input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-actions-inline {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.radio-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.radio-group input[type="radio"] {
|
||||
accent-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.user-checkboxes {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
background: var(--admin-bg);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: block;
|
||||
padding: 5px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
margin-right: 10px;
|
||||
accent-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
border-color: var(--admin-danger);
|
||||
background: rgba(139, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.danger-zone h2 {
|
||||
color: var(--admin-danger);
|
||||
}
|
||||
|
||||
.danger-zone p {
|
||||
color: var(--admin-text-muted);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-info {
|
||||
max-width: 600px;
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
}
|
||||
|
||||
.admin-info h3 {
|
||||
color: var(--admin-accent);
|
||||
margin-bottom: 15px;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.admin-info dl {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-info dt {
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.admin-info dd {
|
||||
color: var(--admin-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-list {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-list h2 {
|
||||
color: var(--admin-accent);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.admin-actions-section {
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
margin-bottom: 20px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.admin-actions-section h2 {
|
||||
color: var(--admin-accent);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.admin-actions-section p {
|
||||
color: var(--admin-text-muted);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.admin-links {
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.admin-links h2 {
|
||||
color: var(--admin-accent);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.admin-links ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.admin-links li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.admin-links a {
|
||||
color: var(--admin-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.admin-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 15px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: rgba(139, 0, 0, 0.2);
|
||||
border: 1px solid var(--admin-danger);
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: rgba(46, 125, 50, 0.2);
|
||||
border: 1px solid var(--admin-success);
|
||||
color: #81c784;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--admin-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-content {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-input,
|
||||
.search-select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
+22
-91
@@ -43,22 +43,6 @@ body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html:has(body.profile-page) {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body.profile-page {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
|
||||
body.profile-page main {
|
||||
overflow: visible;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -394,15 +378,6 @@ input[type="text"], .chat-input textarea {
|
||||
}
|
||||
}
|
||||
|
||||
.message.highlighted {
|
||||
animation: mention-highlight 2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes mention-highlight {
|
||||
0% { background-color: rgba(240, 90, 40, 0.3); }
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
|
||||
/* The entire scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
@@ -522,40 +497,31 @@ a {
|
||||
header {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: nowrap !important;
|
||||
align-items: center !important;
|
||||
justify-content: space-between !important;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
header > * {
|
||||
display: inline-block !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
header .logo {
|
||||
flex: 1 1 auto !important;
|
||||
min-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
header .logo h2 {
|
||||
font-size: 14px;
|
||||
}
|
||||
.logo {
|
||||
display: block;
|
||||
flex: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
header nav {
|
||||
display: none !important;
|
||||
}
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
header nav-menu,
|
||||
header channel-menu {
|
||||
flex: 0 0 auto !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
nav {
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -860,41 +826,6 @@ dialog .dialog-button.danger:disabled {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.message-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 8px 0;
|
||||
font-size: 0.9em;
|
||||
background-color: var(--bg-secondary, #1e1e1e);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-content thead th {
|
||||
background-color: var(--accent-color, #f05a28);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
th {
|
||||
min-width: 100px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.message-content tbody td {
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
.message-content tbody tr:nth-child(even) {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.message-content tbody tr:hover {
|
||||
background-color: rgba(240, 90, 40, 0.08);
|
||||
}
|
||||
|
||||
.message-content .table-wrapper {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
channel-menu {
|
||||
display: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
channel-menu {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-menu-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
background: none;
|
||||
color: #888;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2em;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.channel-menu-toggle:hover {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
channel-menu[open] .channel-menu-toggle {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.channel-menu-panel {
|
||||
position: fixed;
|
||||
top: 50px;
|
||||
right: 8px;
|
||||
left: 8px;
|
||||
background-color: #111;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 8px 0;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 70px);
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
channel-menu[open] .channel-menu-panel {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.channel-menu-section {
|
||||
padding: 8px 16px 4px;
|
||||
font-size: 0.75em;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.channel-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
color: #888;
|
||||
text-decoration: none;
|
||||
font-size: 0.95em;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.channel-menu-item:hover {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.channel-menu-item.active {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.channel-menu-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channel-menu-count {
|
||||
background: #f05a28;
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.8em;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.channel-menu-empty {
|
||||
padding: 16px;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.channel-menu-divider {
|
||||
height: 1px;
|
||||
background: #333;
|
||||
margin: 8px 0;
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { app } from "./app.js";
|
||||
|
||||
class ChannelMenu extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this._isOpen = false;
|
||||
this._channels = [];
|
||||
this._boundClickOutside = this._handleClickOutside.bind(this);
|
||||
|
||||
this._container = document.createElement('div');
|
||||
this._container.className = 'channel-menu-container';
|
||||
|
||||
this._toggleButton = document.createElement('button');
|
||||
this._toggleButton.className = 'channel-menu-toggle';
|
||||
this._toggleButton.setAttribute('aria-label', 'Toggle channel menu');
|
||||
this._toggleButton.innerHTML = 'đź’¬';
|
||||
this._toggleButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._toggle();
|
||||
});
|
||||
|
||||
this._menuPanel = document.createElement('div');
|
||||
this._menuPanel.className = 'channel-menu-panel';
|
||||
|
||||
this._container.appendChild(this._toggleButton);
|
||||
this._container.appendChild(this._menuPanel);
|
||||
this.appendChild(this._container);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await this._loadChannels();
|
||||
app.addEventListener('channel-message', (data) => {
|
||||
if (data.is_final && data.channel_uid) {
|
||||
this._incrementCount(data.channel_uid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async _loadChannels() {
|
||||
try {
|
||||
this._channels = await app.rpc.getChannels();
|
||||
this._renderChannels();
|
||||
} catch (e) {
|
||||
this._menuPanel.innerHTML = '<div class="channel-menu-empty">Failed to load channels</div>';
|
||||
}
|
||||
}
|
||||
|
||||
_renderChannels() {
|
||||
this._menuPanel.innerHTML = '';
|
||||
|
||||
const publicChannels = this._channels.filter(c => !c.is_private);
|
||||
const privateChannels = this._channels.filter(c => c.is_private);
|
||||
|
||||
if (publicChannels.length > 0) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'channel-menu-section';
|
||||
header.textContent = 'Channels';
|
||||
this._menuPanel.appendChild(header);
|
||||
|
||||
publicChannels.forEach(channel => {
|
||||
this._menuPanel.appendChild(this._createChannelItem(channel));
|
||||
});
|
||||
}
|
||||
|
||||
if (privateChannels.length > 0) {
|
||||
if (publicChannels.length > 0) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'channel-menu-divider';
|
||||
this._menuPanel.appendChild(divider);
|
||||
}
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'channel-menu-section';
|
||||
header.textContent = 'Private';
|
||||
this._menuPanel.appendChild(header);
|
||||
|
||||
privateChannels.forEach(channel => {
|
||||
this._menuPanel.appendChild(this._createChannelItem(channel));
|
||||
});
|
||||
}
|
||||
|
||||
if (this._channels.length === 0) {
|
||||
this._menuPanel.innerHTML = '<div class="channel-menu-empty">No channels available</div>';
|
||||
}
|
||||
}
|
||||
|
||||
_createChannelItem(channel) {
|
||||
const item = document.createElement('a');
|
||||
item.className = 'channel-menu-item';
|
||||
item.href = `/channel/${channel.uid}.html`;
|
||||
item.dataset.channelUid = channel.uid;
|
||||
|
||||
if (window.location.pathname.includes(channel.uid)) {
|
||||
item.classList.add('active');
|
||||
}
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'channel-menu-name';
|
||||
name.textContent = channel.name;
|
||||
if (channel.color) {
|
||||
name.style.color = channel.color;
|
||||
}
|
||||
item.appendChild(name);
|
||||
|
||||
if (channel.new_count > 0) {
|
||||
const count = document.createElement('span');
|
||||
count.className = 'channel-menu-count';
|
||||
count.textContent = channel.new_count;
|
||||
item.appendChild(count);
|
||||
}
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
this._close();
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
_incrementCount(channelUid) {
|
||||
const item = this._menuPanel.querySelector(`[data-channel-uid="${channelUid}"]`);
|
||||
if (item && !item.classList.contains('active')) {
|
||||
let countEl = item.querySelector('.channel-menu-count');
|
||||
if (!countEl) {
|
||||
countEl = document.createElement('span');
|
||||
countEl.className = 'channel-menu-count';
|
||||
countEl.textContent = '1';
|
||||
item.appendChild(countEl);
|
||||
} else {
|
||||
const current = parseInt(countEl.textContent) || 0;
|
||||
countEl.textContent = current + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_toggle() {
|
||||
if (this._isOpen) {
|
||||
this._close();
|
||||
} else {
|
||||
this._open();
|
||||
}
|
||||
}
|
||||
|
||||
_open() {
|
||||
this._isOpen = true;
|
||||
this.setAttribute('open', '');
|
||||
this._loadChannels();
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', this._boundClickOutside);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
_close() {
|
||||
this._isOpen = false;
|
||||
this.removeAttribute('open');
|
||||
document.removeEventListener('click', this._boundClickOutside);
|
||||
}
|
||||
|
||||
_handleClickOutside(event) {
|
||||
if (!this.contains(event.target)) {
|
||||
this._close();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
document.removeEventListener('click', this._boundClickOutside);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('channel-menu', ChannelMenu);
|
||||
@@ -4,8 +4,6 @@ import { app } from "./app.js";
|
||||
import { NjetComponent, eventBus } from "./njet.js";
|
||||
import { FileUploadGrid } from "./file-upload-grid.js";
|
||||
import { loggerFactory } from "./logger.js";
|
||||
import "./toolbar-menu.js";
|
||||
import "./mention-nav.js";
|
||||
|
||||
const log = loggerFactory.getLogger("ChatInput");
|
||||
|
||||
@@ -375,7 +373,7 @@ textToLeetAdvanced(text) {
|
||||
this.fileUploadGrid = new FileUploadGrid();
|
||||
this.fileUploadGrid.setAttribute("channel", this.channelUid);
|
||||
this.fileUploadGrid.style.display = "none";
|
||||
this.parentElement.insertBefore(this.fileUploadGrid, this);
|
||||
this.appendChild(this.fileUploadGrid);
|
||||
|
||||
this.textarea.setAttribute("placeholder", "Type a message...");
|
||||
this.textarea.setAttribute("rows", "2");
|
||||
@@ -383,7 +381,10 @@ textToLeetAdvanced(text) {
|
||||
this.appendChild(this.textarea);
|
||||
|
||||
this.sttButton = document.createElement("stt-button");
|
||||
this.appendChild(this.sttButton);
|
||||
|
||||
this.ttsButton = document.createElement("tts-button");
|
||||
this.appendChild(this.ttsButton);
|
||||
|
||||
this.uploadButton = document.createElement("upload-button");
|
||||
this.uploadButton.setAttribute("channel", this.channelUid);
|
||||
@@ -399,25 +400,10 @@ textToLeetAdvanced(text) {
|
||||
});
|
||||
this.subscribe("file-uploading", (e) => {
|
||||
this.fileUploadGrid.style.display = "block";
|
||||
});
|
||||
|
||||
this.mentionNav = document.createElement('mention-nav');
|
||||
this.mentionNav.setAttribute('channel', this.channelUid);
|
||||
this.mentionNav.addEventListener('navigate', async (e) => {
|
||||
const messageList = document.querySelector('message-list');
|
||||
const { messageUid, mentionUid } = e.detail;
|
||||
if (messageList && messageList.scrollToMessage(messageUid)) {
|
||||
await app.rpc.markMentionRead(mentionUid);
|
||||
this.mentionNav.decrementCount();
|
||||
}
|
||||
});
|
||||
|
||||
this.toolbarMenu = document.createElement("toolbar-menu");
|
||||
this.toolbarMenu.addButton(this.mentionNav, 'mention');
|
||||
this.toolbarMenu.addButton(this.sttButton, 'stt');
|
||||
this.toolbarMenu.addButton(this.ttsButton, 'tts');
|
||||
this.toolbarMenu.addButton(this.uploadButton, 'upload');
|
||||
this.appendChild(this.toolbarMenu);
|
||||
this.uploadButton.style.display = "none";
|
||||
this.textarea.style.display = "none";
|
||||
})
|
||||
this.appendChild(this.uploadButton);
|
||||
|
||||
this.textarea.addEventListener("blur", () => {
|
||||
this.updateFromInput(this.value, true).then(
|
||||
@@ -426,6 +412,8 @@ textToLeetAdvanced(text) {
|
||||
});
|
||||
|
||||
this.subscribe("file-uploads-done", (data)=>{
|
||||
this.textarea.style.display = "block";
|
||||
this.uploadButton.style.display = "block";
|
||||
this.fileUploadGrid.style.display = "none";
|
||||
let msg =data.reduce((message, file) => {
|
||||
return `${message}[${file.filename || file.name || file.remoteFile}](/channel/attachment/${file.remoteFile})`;
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
.fug-root {
|
||||
background: #111;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
margin: 0 15px 8px 15px;
|
||||
background: #181818;
|
||||
color: white;
|
||||
font-family: sans-serif;
|
||||
max-width: calc(100% - 30px);
|
||||
/*min-height: 100vh;*/
|
||||
}
|
||||
.fug-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin: 10px;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
justify-content: flex-start;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 150px);
|
||||
gap: 20px;
|
||||
margin: 30px;
|
||||
}
|
||||
.fug-tile {
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
background: #111;
|
||||
border: 2px solid #ff6600;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px 8px 8px 8px;
|
||||
padding: 16px 8px 8px 8px;
|
||||
box-shadow: 0 0 4px #333;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
mention-nav {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mention-nav-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
padding: 6px 12px;
|
||||
background: #f05a28;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
font-family: 'Courier New', monospace;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.mention-nav-button:hover {
|
||||
background: #d94d1f;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.mention-nav-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.mention-nav-badge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
background: #fff;
|
||||
color: #f05a28;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.7em;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mention-nav-button {
|
||||
padding: 4px 8px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.mention-nav-badge {
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
padding: 1px 4px;
|
||||
font-size: 0.65em;
|
||||
min-width: 14px;
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { app } from "./app.js";
|
||||
|
||||
class MentionNav extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this._count = 0;
|
||||
this._channelUid = null;
|
||||
this._button = null;
|
||||
this._badge = null;
|
||||
this._initialized = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (!this._initialized) {
|
||||
this._button = document.createElement('button');
|
||||
this._button.className = 'mention-nav-button';
|
||||
this._button.setAttribute('aria-label', 'Go to unread mention');
|
||||
this._button.innerHTML = '@';
|
||||
this._button.style.display = 'none';
|
||||
|
||||
this._badge = document.createElement('span');
|
||||
this._badge.className = 'mention-nav-badge';
|
||||
this._button.appendChild(this._badge);
|
||||
|
||||
this._button.addEventListener('click', () => this._handleClick());
|
||||
this.appendChild(this._button);
|
||||
this._initialized = true;
|
||||
}
|
||||
|
||||
this._channelUid = this.getAttribute('channel');
|
||||
this._loadCount();
|
||||
|
||||
this._mentionHandler = (data) => {
|
||||
if (data.data && data.data.channel_uid === this._channelUid) {
|
||||
this._count++;
|
||||
this._updateUI();
|
||||
}
|
||||
};
|
||||
app.ws.addEventListener('new_mention', this._mentionHandler);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._mentionHandler) {
|
||||
app.ws.removeEventListener('new_mention', this._mentionHandler);
|
||||
}
|
||||
}
|
||||
|
||||
async _loadCount() {
|
||||
if (!this._channelUid) return;
|
||||
try {
|
||||
this._count = await app.rpc.getUnreadMentionCount(this._channelUid);
|
||||
this._updateUI();
|
||||
} catch (e) {
|
||||
console.warn('Failed to load mention count:', e);
|
||||
}
|
||||
}
|
||||
|
||||
_updateUI() {
|
||||
if (this._count > 0) {
|
||||
this._button.style.display = 'flex';
|
||||
this._badge.textContent = this._count > 99 ? '99+' : this._count;
|
||||
} else {
|
||||
this._button.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async _handleClick() {
|
||||
try {
|
||||
const mention = await app.rpc.getNextUnreadMention(this._channelUid);
|
||||
if (mention) {
|
||||
this.dispatchEvent(new CustomEvent('navigate', {
|
||||
bubbles: true,
|
||||
detail: {
|
||||
messageUid: mention.message_uid,
|
||||
mentionUid: mention.mention_uid
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to get next mention:', e);
|
||||
}
|
||||
}
|
||||
|
||||
decrementCount() {
|
||||
if (this._count > 0) {
|
||||
this._count--;
|
||||
this._updateUI();
|
||||
}
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this._loadCount();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('mention-nav', MentionNav);
|
||||
@@ -259,20 +259,6 @@ class MessageList extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
scrollToMessage(messageUid, options = {}) {
|
||||
const message = this.messageMap.get(messageUid);
|
||||
if (message) {
|
||||
message.scrollIntoView({
|
||||
behavior: options.behavior || 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
message.classList.add('highlighted');
|
||||
setTimeout(() => message.classList.remove('highlighted'), 2000);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
triggerGlow(uid, color) {
|
||||
if (color && app.starField) app.starField.glowColor(color);
|
||||
if (!uid) return;
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
class NavMenu extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this._isOpen = false;
|
||||
this._boundClickOutside = this._handleClickOutside.bind(this);
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
:host {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.menu-toggle .bar {
|
||||
width: 18px;
|
||||
height: 2px;
|
||||
background-color: #888;
|
||||
border-radius: 1px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.menu-toggle:hover .bar {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.menu-toggle:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
:host([open]) .menu-toggle {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
:host([open]) .menu-toggle .bar {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
:host([open]) .menu-toggle .bar:nth-child(1) {
|
||||
transform: rotate(45deg) translate(4px, 4px);
|
||||
}
|
||||
|
||||
:host([open]) .menu-toggle .bar:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
:host([open]) .menu-toggle .bar:nth-child(3) {
|
||||
transform: rotate(-45deg) translate(4px, -4px);
|
||||
}
|
||||
|
||||
.menu-panel {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 8px;
|
||||
background-color: #111;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 8px 0;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
min-width: 180px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
:host([open]) .menu-panel {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
color: #888;
|
||||
text-decoration: none;
|
||||
font-size: 0.95em;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.menu-item .icon {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.menu-item.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.menu-panel {
|
||||
position: fixed;
|
||||
top: 50px;
|
||||
right: 8px;
|
||||
left: 8px;
|
||||
margin-top: 0;
|
||||
min-width: unset;
|
||||
max-height: calc(100vh - 70px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'nav-container';
|
||||
|
||||
this._menuPanel = document.createElement('div');
|
||||
this._menuPanel.className = 'menu-panel';
|
||||
|
||||
this._toggleButton = document.createElement('button');
|
||||
this._toggleButton.className = 'menu-toggle';
|
||||
this._toggleButton.setAttribute('aria-label', 'Toggle navigation menu');
|
||||
this._toggleButton.innerHTML = '<span class="bar"></span><span class="bar"></span><span class="bar"></span>';
|
||||
|
||||
this._toggleButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._toggle();
|
||||
});
|
||||
|
||||
this._createMenuItems();
|
||||
|
||||
container.appendChild(this._menuPanel);
|
||||
container.appendChild(this._toggleButton);
|
||||
this.shadowRoot.append(style, container);
|
||||
}
|
||||
|
||||
_createMenuItems() {
|
||||
const items = [
|
||||
{ icon: '🏠', label: 'Home', href: '/web.html' },
|
||||
{ icon: 'đź“‚', label: 'Files', href: '/drive.html' },
|
||||
{ icon: '🔍', label: 'Search', href: '/search-user.html' },
|
||||
{ icon: '📥', label: 'Install', href: '#', id: 'install-button', hidden: true },
|
||||
{ icon: 'đź’¬', label: 'Forum', href: '/forum/index.html' },
|
||||
{ icon: '⚙️', label: 'Settings', href: '/settings/index.html' },
|
||||
{ icon: '✉️', label: 'Notifications', href: '#', action: 'notifications' },
|
||||
{ icon: 'đź”’', label: 'Logout', href: '/logout.html' }
|
||||
];
|
||||
|
||||
items.forEach(item => {
|
||||
const link = document.createElement('a');
|
||||
link.className = 'menu-item';
|
||||
if (item.hidden) {
|
||||
link.classList.add('hidden');
|
||||
}
|
||||
link.href = item.href;
|
||||
if (item.id) {
|
||||
link.id = item.id;
|
||||
this['_' + item.id.replace('-', '')] = link;
|
||||
}
|
||||
if (item.action === 'notifications') {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this._close();
|
||||
if (typeof window.registerNotificationsServiceWorker === 'function') {
|
||||
window.registerNotificationsServiceWorker();
|
||||
}
|
||||
});
|
||||
} else if (item.href !== '#') {
|
||||
link.addEventListener('click', () => {
|
||||
this._close();
|
||||
});
|
||||
}
|
||||
link.innerHTML = `<span class="icon">${item.icon}</span><span>${item.label}</span>`;
|
||||
this._menuPanel.appendChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
_toggle() {
|
||||
if (this._isOpen) {
|
||||
this._close();
|
||||
} else {
|
||||
this._open();
|
||||
}
|
||||
}
|
||||
|
||||
_open() {
|
||||
this._isOpen = true;
|
||||
this.setAttribute('open', '');
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', this._boundClickOutside);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
_close() {
|
||||
this._isOpen = false;
|
||||
this.removeAttribute('open');
|
||||
document.removeEventListener('click', this._boundClickOutside);
|
||||
}
|
||||
|
||||
_handleClickOutside(event) {
|
||||
const path = event.composedPath();
|
||||
if (!path.includes(this)) {
|
||||
this._close();
|
||||
}
|
||||
}
|
||||
|
||||
showInstallButton() {
|
||||
const installBtn = this.shadowRoot.getElementById('install-button');
|
||||
if (installBtn) {
|
||||
installBtn.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
getInstallButton() {
|
||||
return this.shadowRoot.getElementById('install-button');
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
document.removeEventListener('click', this._boundClickOutside);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('nav-menu', NavMenu);
|
||||
@@ -1,293 +0,0 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
background: #0a0a0a;
|
||||
color: #ccc;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a { color: #7ef; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
.site-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.site-sidebar {
|
||||
width: 200px;
|
||||
background: #0f0f0f;
|
||||
padding: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.site-sidebar .logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-sidebar .logo:hover { text-decoration: none; }
|
||||
|
||||
.site-sidebar .logo img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.site-sidebar nav { margin-bottom: 2rem; }
|
||||
|
||||
.site-sidebar nav a {
|
||||
display: block;
|
||||
padding: 0.5rem 0;
|
||||
color: #7ef;
|
||||
font-size: 0.95rem;
|
||||
border-left: 2px solid transparent;
|
||||
padding-left: 0.75rem;
|
||||
margin-left: -0.75rem;
|
||||
}
|
||||
|
||||
.site-sidebar nav a:hover {
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-sidebar nav a.active {
|
||||
color: #fff;
|
||||
border-left-color: #7ef;
|
||||
}
|
||||
|
||||
.site-main {
|
||||
flex: 1;
|
||||
padding: 2rem 3rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.site-main h1 {
|
||||
color: #fff;
|
||||
font-size: 1.75rem;
|
||||
font-weight: normal;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.site-main h2 {
|
||||
color: #7ef;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 2rem 0 1rem 0;
|
||||
}
|
||||
|
||||
.site-main h3 {
|
||||
color: #0fa;
|
||||
font-size: 1rem;
|
||||
margin: 1.5rem 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.site-main p { margin-bottom: 1rem; }
|
||||
|
||||
.site-main ul, .site-main ol {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.site-main li { margin-bottom: 0.4rem; }
|
||||
|
||||
.site-main code {
|
||||
background: #1a1a1a;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
color: #7ef;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.site-main pre {
|
||||
background: #111;
|
||||
border: 1px solid #222;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.site-section {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.site-section:last-child { border-bottom: none; }
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
background: #1a1a1a;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #2a2a2a;
|
||||
border-color: #444;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #0d3d3d;
|
||||
border-color: #7ef;
|
||||
color: #7ef;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1a4a4a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
border-color: #633;
|
||||
color: #f66;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #2a1515;
|
||||
border-color: #844;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
border-radius: 3px;
|
||||
background: #1a2a2a;
|
||||
color: #7ef;
|
||||
}
|
||||
|
||||
.badge-green {
|
||||
background: #1a2a1a;
|
||||
color: #4a4;
|
||||
}
|
||||
|
||||
.badge-orange {
|
||||
background: #2a2a1a;
|
||||
color: #f90;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #111;
|
||||
border: 1px solid #1a1a1a;
|
||||
border-radius: 4px;
|
||||
padding: 1.25rem;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.card:hover { border-color: #333; }
|
||||
|
||||
.card h3 {
|
||||
color: #7ef;
|
||||
font-size: 1rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
th {
|
||||
color: #888;
|
||||
font-weight: normal;
|
||||
font-size: 0.85rem;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
td { color: #ccc; }
|
||||
|
||||
tr:hover td { background: #111; }
|
||||
|
||||
.method-name {
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #0fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.method-sig {
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.site-layout { flex-direction: column; }
|
||||
|
||||
.site-sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.site-sidebar nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.site-sidebar nav a {
|
||||
border-left: none;
|
||||
padding: 0.4rem 0.75rem;
|
||||
margin: 0;
|
||||
background: #1a1a1a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.site-sidebar nav a.active {
|
||||
background: #0d3d3d;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.site-main { padding: 1.5rem; }
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export class Socket extends EventHandler {
|
||||
_maxReconnectAttempts = 50;
|
||||
_reconnectDelay = 4000;
|
||||
_pendingCalls = new Map();
|
||||
_callTimeout = 300000;
|
||||
_callTimeout = 30000;
|
||||
_isDestroyed = false;
|
||||
|
||||
get isConnected() {
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
class ToolbarMenu extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this._isOpen = false;
|
||||
this._sttButton = null;
|
||||
this._ttsButton = null;
|
||||
this._uploadButton = null;
|
||||
this._observer = null;
|
||||
this._boundClickOutside = this._handleClickOutside.bind(this);
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
:host {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toolbar-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 20px;
|
||||
margin-left: 10px;
|
||||
background-color: #000000;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
transition: background-color 0.2s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.menu-toggle:hover {
|
||||
background-color: #222222;
|
||||
}
|
||||
|
||||
.menu-toggle:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
:host([open]) .menu-toggle {
|
||||
background-color: #222222;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status-indicator.listening {
|
||||
display: block;
|
||||
background-color: #d32f2f;
|
||||
animation: pulse-indicator 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-indicator.tts-enabled {
|
||||
display: block;
|
||||
background-color: #2e7d32;
|
||||
}
|
||||
|
||||
@keyframes pulse-indicator {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(211, 47, 47, 0.6);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 4px rgba(211, 47, 47, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-panel {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 8px;
|
||||
background-color: #111111;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 60px;
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
:host([open]) .menu-panel {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'toolbar-container';
|
||||
|
||||
this._menuPanel = document.createElement('div');
|
||||
this._menuPanel.className = 'menu-panel';
|
||||
|
||||
this._toggleButton = document.createElement('button');
|
||||
this._toggleButton.className = 'menu-toggle';
|
||||
this._toggleButton.setAttribute('aria-label', 'Toggle toolbar menu');
|
||||
this._toggleButton.innerHTML = '☰';
|
||||
|
||||
this._statusIndicator = document.createElement('span');
|
||||
this._statusIndicator.className = 'status-indicator';
|
||||
this._toggleButton.appendChild(this._statusIndicator);
|
||||
|
||||
this._toggleButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._toggle();
|
||||
});
|
||||
|
||||
container.appendChild(this._menuPanel);
|
||||
container.appendChild(this._toggleButton);
|
||||
this.shadowRoot.append(style, container);
|
||||
}
|
||||
|
||||
_toggle() {
|
||||
if (this._isOpen) {
|
||||
this._close();
|
||||
} else {
|
||||
this._open();
|
||||
}
|
||||
}
|
||||
|
||||
_open() {
|
||||
this._isOpen = true;
|
||||
this.setAttribute('open', '');
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', this._boundClickOutside);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
_close() {
|
||||
this._isOpen = false;
|
||||
this.removeAttribute('open');
|
||||
document.removeEventListener('click', this._boundClickOutside);
|
||||
}
|
||||
|
||||
_handleClickOutside(event) {
|
||||
const path = event.composedPath();
|
||||
if (!path.includes(this)) {
|
||||
this._close();
|
||||
}
|
||||
}
|
||||
|
||||
_observeButtonStates() {
|
||||
if (this._observer) {
|
||||
this._observer.disconnect();
|
||||
}
|
||||
|
||||
this._observer = new MutationObserver(() => {
|
||||
this._updateStatusIndicator();
|
||||
});
|
||||
|
||||
if (this._sttButton) {
|
||||
this._observer.observe(this._sttButton, {
|
||||
attributes: true,
|
||||
attributeFilter: ['listening']
|
||||
});
|
||||
}
|
||||
|
||||
if (this._ttsButton) {
|
||||
this._observer.observe(this._ttsButton, {
|
||||
attributes: true,
|
||||
attributeFilter: ['enabled']
|
||||
});
|
||||
}
|
||||
|
||||
this._updateStatusIndicator();
|
||||
}
|
||||
|
||||
_updateStatusIndicator() {
|
||||
this._statusIndicator.classList.remove('listening', 'tts-enabled');
|
||||
|
||||
if (this._sttButton?.hasAttribute('listening')) {
|
||||
this._statusIndicator.classList.add('listening');
|
||||
} else if (this._ttsButton?.hasAttribute('enabled')) {
|
||||
this._statusIndicator.classList.add('tts-enabled');
|
||||
}
|
||||
}
|
||||
|
||||
addButton(button, type) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'menu-item';
|
||||
|
||||
if (button.style) {
|
||||
button.style.marginLeft = '0';
|
||||
}
|
||||
|
||||
wrapper.appendChild(button);
|
||||
this._menuPanel.appendChild(wrapper);
|
||||
|
||||
if (type === 'stt') {
|
||||
this._sttButton = button;
|
||||
} else if (type === 'tts') {
|
||||
this._ttsButton = button;
|
||||
} else if (type === 'upload') {
|
||||
this._uploadButton = button;
|
||||
}
|
||||
|
||||
this._observeButtonStates();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this._observeButtonStates();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
document.removeEventListener('click', this._boundClickOutside);
|
||||
if (this._observer) {
|
||||
this._observer.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('toolbar-menu', ToolbarMenu);
|
||||
@@ -7,7 +7,6 @@ from app.cache import time_cache_async, time_cache
|
||||
from mistune import HTMLRenderer, Markdown
|
||||
from mistune.plugins.formatting import strikethrough
|
||||
from mistune.plugins.spoiler import spoiler
|
||||
from mistune.plugins.table import table
|
||||
from mistune.plugins.url import url
|
||||
from pygments import highlight
|
||||
from pygments.formatters import html
|
||||
@@ -81,7 +80,7 @@ class MarkdownRenderer(HTMLRenderer):
|
||||
|
||||
def render_markdown_sync(app, markdown_string):
|
||||
renderer = MarkdownRenderer(app, None)
|
||||
markdown = Markdown(renderer=renderer, plugins=[url, strikethrough, spoiler, table])
|
||||
markdown = Markdown(renderer=renderer, plugins=[url, strikethrough, spoiler])
|
||||
return markdown(markdown_string)
|
||||
|
||||
|
||||
|
||||
@@ -145,14 +145,8 @@
|
||||
<div class="container">
|
||||
<a class="home-link" href="/">
|
||||
<img src="/image/snek_logo_256x256.png" alt="Snek Home Logo" />
|
||||
Snek
|
||||
Snek Community
|
||||
</a>
|
||||
<a href="/docs.html">Docs</a>
|
||||
<a href="/architecture.html">Architecture</a>
|
||||
<a href="/api.html">API</a>
|
||||
<a href="/bots.html">Bots</a>
|
||||
<a href="/contribute.html">Contribute</a>
|
||||
<a href="/about.html" class="active">About</a>
|
||||
</div>
|
||||
</nav>
|
||||
<header class="container about-hero">
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{% extends "app.html" %}
|
||||
|
||||
{% block body_class %}admin-page{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include "admin/sidebar.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="/admin.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Admin Panel</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
{% endblock main %}
|
||||
@@ -1,26 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Clear Channel History</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-form danger-zone">
|
||||
<h2>{{ channel.name }}</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<p>This action will permanently delete all messages in this channel. This cannot be undone.</p>
|
||||
|
||||
<form method="post" action="/admin/channels/{{ channel.uid }}/clear.html">
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-danger">Clear All Messages</button>
|
||||
<a href="/admin/channels/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,26 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Delete Channel</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-form danger-zone">
|
||||
<h2>{{ channel.name }}</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<p>This action will permanently delete this channel and all its messages. This cannot be undone.</p>
|
||||
|
||||
<form method="post" action="/admin/channels/{{ channel.uid }}/delete.html">
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-danger">Delete Channel</button>
|
||||
<a href="/admin/channels/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,53 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Edit Channel</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-form">
|
||||
<h2>{{ channel.name }}</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/channels/{{ channel.uid }}/edit.html">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" value="{{ channel.name }}" maxlength="50" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" rows="3" maxlength="500">{{ channel.description or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="is_private" {% if channel.is_private %}checked{% endif %}>
|
||||
Private Channel
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
<a href="/admin/channels/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-info">
|
||||
<h3>Channel Information</h3>
|
||||
<dl>
|
||||
<dt>UID</dt>
|
||||
<dd>{{ channel.uid }}</dd>
|
||||
<dt>Type</dt>
|
||||
<dd>{{ channel.tag or 'channel' }}</dd>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ channel.created_at }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,70 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Channels</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-search">
|
||||
<form method="get" action="/admin/channels/index.html">
|
||||
<input type="text" name="search" placeholder="Search channels..." value="{{ search }}" class="search-input">
|
||||
<button type="submit" class="btn btn-primary">Search</button>
|
||||
{% if search %}
|
||||
<a href="/admin/channels/index.html" class="btn btn-secondary">Clear</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Private</th>
|
||||
<th>Members</th>
|
||||
<th>Messages</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for channel in channels %}
|
||||
<tr>
|
||||
<td>{{ channel.name }}</td>
|
||||
<td>{{ channel.tag or 'channel' }}</td>
|
||||
<td>{% if channel.is_private %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ channel.member_count }}</td>
|
||||
<td>{{ channel.message_count }}</td>
|
||||
<td>{{ channel.created_at }}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/channels/{{ channel.uid }}/members.html" class="btn btn-small">Members</a>
|
||||
<a href="/admin/channels/{{ channel.uid }}/edit.html" class="btn btn-small">Edit</a>
|
||||
<a href="/admin/channels/{{ channel.uid }}/clear.html" class="btn btn-small btn-warning">Clear</a>
|
||||
<a href="/admin/channels/{{ channel.uid }}/delete.html" class="btn btn-small btn-danger">Delete</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7">No channels found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="admin-pagination">
|
||||
<span class="pagination-info">Showing {{ ((page - 1) * per_page) + 1 }}-{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }} channels</span>
|
||||
<div class="pagination-controls">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/channels/index.html?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span class="pagination-pages">Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/channels/index.html?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,48 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Channel Members</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-header">
|
||||
<h2>{{ channel.name }}</h2>
|
||||
<p>{{ channel.description }}</p>
|
||||
<a href="/admin/channels/index.html" class="btn btn-secondary">Back to Channels</a>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Nick</th>
|
||||
<th>Owner</th>
|
||||
<th>Banned</th>
|
||||
<th>Joined</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for member in members %}
|
||||
<tr class="{% if member.is_banned %}row-banned{% endif %}">
|
||||
<td>{{ member.username }}</td>
|
||||
<td>{{ member.nick }}</td>
|
||||
<td>{% if member.is_owner %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{% if member.is_banned %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ member.joined_at }}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/users/{{ member.user_uid }}/edit.html" class="btn btn-small">Edit User</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6">No members found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,67 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Drives</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-search">
|
||||
<form method="get" action="/admin/files/index.html">
|
||||
<select name="user_uid" class="search-select">
|
||||
<option value="">All Users</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.uid }}" {% if user.uid == user_uid %}selected{% endif %}>{{ user.nick }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary">Filter</button>
|
||||
{% if user_uid %}
|
||||
<a href="/admin/files/index.html" class="btn btn-secondary">Clear</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Owner</th>
|
||||
<th>Items</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for drive in drives %}
|
||||
<tr>
|
||||
<td>{{ drive.name }}</td>
|
||||
<td><a href="/admin/users/{{ drive.user_uid }}/edit.html">{{ drive.user_nick }}</a></td>
|
||||
<td>{{ drive.item_count }}</td>
|
||||
<td>{{ drive.created_at }}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/files/{{ drive.uid }}/items.html" class="btn btn-small">View Items</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">No drives found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/files/index.html?page={{ page - 1 }}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/files/index.html?page={{ page + 1 }}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,63 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Drive Items</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-header">
|
||||
<h2>{{ drive.name or 'Drive' }}</h2>
|
||||
<p>Owner: {{ user.nick if user else 'Unknown' }}</p>
|
||||
<a href="/admin/files/index.html" class="btn btn-secondary">Back to Drives</a>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Path</th>
|
||||
<th>Size</th>
|
||||
<th>Type</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>{{ item.name }}</td>
|
||||
<td class="path-preview">{{ item.path }}</td>
|
||||
<td>{{ item.size }}</td>
|
||||
<td>{{ item.mime_type }}</td>
|
||||
<td>{{ item.created_at }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/files/{{ drive.uid }}/items.html" style="display:inline;">
|
||||
<input type="hidden" name="item_uid" value="{{ item.uid }}">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this item?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6">No items found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/files/{{ drive.uid }}/items.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/files/{{ drive.uid }}/items.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,70 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Repositories</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-search">
|
||||
<form method="get" action="/admin/files/repositories.html">
|
||||
<select name="user_uid" class="search-select">
|
||||
<option value="">All Users</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.uid }}" {% if user.uid == user_uid %}selected{% endif %}>{{ user.nick }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary">Filter</button>
|
||||
{% if user_uid %}
|
||||
<a href="/admin/files/repositories.html" class="btn btn-secondary">Clear</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Owner</th>
|
||||
<th>Private</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for repo in repositories %}
|
||||
<tr>
|
||||
<td>{{ repo.name }}</td>
|
||||
<td><a href="/admin/users/{{ repo.user_uid }}/edit.html">{{ repo.user_nick }}</a></td>
|
||||
<td>{% if repo.is_private %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ repo.created_at }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/files/repositories.html?{% if user_uid %}user_uid={{ user_uid }}{% endif %}" style="display:inline;">
|
||||
<input type="hidden" name="repo_uid" value="{{ repo.uid }}">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this repository?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">No repositories found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/files/repositories.html?page={{ page - 1 }}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/files/repositories.html?page={{ page + 1 }}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,64 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Forums</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Active</th>
|
||||
<th>Threads</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for forum in forums %}
|
||||
<tr class="{% if not forum.is_active %}row-inactive{% endif %}">
|
||||
<td>{{ forum.name }}</td>
|
||||
<td class="description-preview">{{ forum.description }}</td>
|
||||
<td>{% if forum.is_active %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ forum.thread_count }}</td>
|
||||
<td>{{ forum.created_at }}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/forums/{{ forum.uid }}/threads.html" class="btn btn-small">Threads</a>
|
||||
<form method="post" action="/admin/forums/index.html" style="display:inline;">
|
||||
<input type="hidden" name="forum_uid" value="{{ forum.uid }}">
|
||||
<input type="hidden" name="action" value="toggle_active">
|
||||
<button type="submit" class="btn btn-small">{% if forum.is_active %}Deactivate{% else %}Activate{% endif %}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/forums/index.html" style="display:inline;">
|
||||
<input type="hidden" name="forum_uid" value="{{ forum.uid }}">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this forum?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6">No forums found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/forums/index.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/forums/index.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,60 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Posts</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-header">
|
||||
<h2>{{ thread.title }}</h2>
|
||||
<p>Forum: {{ forum.name }}</p>
|
||||
<a href="/admin/forums/{{ forum.uid }}/threads.html" class="btn btn-secondary">Back to Threads</a>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Author</th>
|
||||
<th>Content</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for post in posts %}
|
||||
<tr>
|
||||
<td><a href="/admin/users/{{ post.created_by_uid }}/edit.html">{{ post.created_by_nick }}</a></td>
|
||||
<td class="message-preview">{{ post.content }}</td>
|
||||
<td>{{ post.created_at }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/forums/threads/{{ thread.uid }}/posts.html" style="display:inline;">
|
||||
<input type="hidden" name="post_uid" value="{{ post.uid }}">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this post?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4">No posts found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/forums/threads/{{ thread.uid }}/posts.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/forums/threads/{{ thread.uid }}/posts.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,77 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Threads</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-header">
|
||||
<h2>{{ forum.name }}</h2>
|
||||
<p>{{ forum.description }}</p>
|
||||
<a href="/admin/forums/index.html" class="btn btn-secondary">Back to Forums</a>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Author</th>
|
||||
<th>Pinned</th>
|
||||
<th>Locked</th>
|
||||
<th>Posts</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for thread in threads %}
|
||||
<tr class="{% if thread.is_pinned %}row-pinned{% endif %} {% if thread.is_locked %}row-locked{% endif %}">
|
||||
<td>{{ thread.title }}</td>
|
||||
<td><a href="/admin/users/{{ thread.created_by_uid }}/edit.html">{{ thread.created_by_nick }}</a></td>
|
||||
<td>{% if thread.is_pinned %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{% if thread.is_locked %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ thread.post_count }}</td>
|
||||
<td>{{ thread.created_at }}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/forums/threads/{{ thread.uid }}/posts.html" class="btn btn-small">Posts</a>
|
||||
<form method="post" action="/admin/forums/{{ forum.uid }}/threads.html" style="display:inline;">
|
||||
<input type="hidden" name="thread_uid" value="{{ thread.uid }}">
|
||||
<input type="hidden" name="action" value="toggle_pin">
|
||||
<button type="submit" class="btn btn-small">{% if thread.is_pinned %}Unpin{% else %}Pin{% endif %}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/forums/{{ forum.uid }}/threads.html" style="display:inline;">
|
||||
<input type="hidden" name="thread_uid" value="{{ thread.uid }}">
|
||||
<input type="hidden" name="action" value="toggle_lock">
|
||||
<button type="submit" class="btn btn-small">{% if thread.is_locked %}Unlock{% else %}Lock{% endif %}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/forums/{{ forum.uid }}/threads.html" style="display:inline;">
|
||||
<input type="hidden" name="thread_uid" value="{{ thread.uid }}">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this thread?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7">No threads found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/forums/{{ forum.uid }}/threads.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/forums/{{ forum.uid }}/threads.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,88 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Dashboard</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-stats">
|
||||
<h2>System Overview</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card stat-highlight">
|
||||
<span class="stat-label">Total Users</span>
|
||||
<span class="stat-value">{{ stats.users }}</span>
|
||||
<span class="stat-sub">{{ stats.online }} currently online</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Channels</span>
|
||||
<span class="stat-value">{{ stats.channels }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Messages</span>
|
||||
<span class="stat-value">{{ stats.messages }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Notifications</span>
|
||||
<span class="stat-value">{{ stats.notifications }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Forums</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Forums</span>
|
||||
<span class="stat-value">{{ stats.forums }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Threads</span>
|
||||
<span class="stat-value">{{ stats.threads }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Posts</span>
|
||||
<span class="stat-value">{{ stats.posts }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Storage</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">User Drives</span>
|
||||
<span class="stat-value">{{ stats.drives }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Git Repositories</span>
|
||||
<span class="stat-value">{{ stats.repositories }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-activity">
|
||||
<h2>Recent Messages</h2>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Channel</th>
|
||||
<th>Message</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in activity %}
|
||||
<tr>
|
||||
<td><a href="/admin/users/{{ item.user_uid }}/edit.html">{{ item.user_nick }}</a></td>
|
||||
<td><a href="/admin/channels/{{ item.channel_uid }}/edit.html">{{ item.channel_name }}</a></td>
|
||||
<td class="message-preview">{{ item.content }}</td>
|
||||
<td class="time-cell">{{ item.created_at }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4">No recent activity</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,80 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Messages</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-search">
|
||||
<form method="get" action="/admin/messages/index.html">
|
||||
<div class="search-row">
|
||||
<input type="text" name="query" placeholder="Search messages..." value="{{ query }}" class="search-input">
|
||||
<select name="channel_uid" class="search-select">
|
||||
<option value="">All Channels</option>
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.uid }}" {% if channel.uid == channel_uid %}selected{% endif %}>{{ channel.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="user_uid" class="search-select">
|
||||
<option value="">All Users</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.uid }}" {% if user.uid == user_uid %}selected{% endif %}>{{ user.nick }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary">Search</button>
|
||||
{% if query or channel_uid or user_uid %}
|
||||
<a href="/admin/messages/index.html" class="btn btn-secondary">Clear</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Channel</th>
|
||||
<th>Message</th>
|
||||
<th>Time</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for message in messages %}
|
||||
<tr>
|
||||
<td><a href="/admin/users/{{ message.user_uid }}/edit.html">{{ message.user_nick }}</a></td>
|
||||
<td><a href="/admin/channels/{{ message.channel_uid }}/edit.html">{{ message.channel_name }}</a></td>
|
||||
<td class="message-preview">{{ message.content }}</td>
|
||||
<td>{{ message.created_at }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/messages/index.html?{{ request.query_string }}" style="display:inline;">
|
||||
<input type="hidden" name="message_uid" value="{{ message.uid }}">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this message?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">No messages found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="admin-pagination">
|
||||
<span class="pagination-info">Showing {{ ((page - 1) * per_page) + 1 }}-{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }} messages</span>
|
||||
<div class="pagination-controls">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/messages/index.html?page={{ page - 1 }}{% if query %}&query={{ query }}{% endif %}{% if channel_uid %}&channel_uid={{ channel_uid }}{% endif %}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span class="pagination-pages">Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/messages/index.html?page={{ page + 1 }}{% if query %}&query={{ query }}{% endif %}{% if channel_uid %}&channel_uid={{ channel_uid }}{% endif %}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,65 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Create Notification</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-form">
|
||||
<h2>Mass Notification</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/notifications/create.html">
|
||||
<div class="form-group">
|
||||
<label for="message">Message</label>
|
||||
<textarea id="message" name="message" rows="4" maxlength="500" required placeholder="Enter notification message...">{{ message or '' }}</textarea>
|
||||
<small>Maximum 500 characters</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Recipients</label>
|
||||
<div class="radio-group">
|
||||
<label>
|
||||
<input type="radio" name="target" value="all" checked>
|
||||
All Users
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="target" value="selected">
|
||||
Selected Users
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="user-select-group" style="display:none;">
|
||||
<label>Select Users</label>
|
||||
<div class="user-checkboxes">
|
||||
{% for user in users %}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="user_uids" value="{{ user.uid }}">
|
||||
{{ user.nick }} ({{ user.username }})
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Send Notification</button>
|
||||
<a href="/admin/notifications/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('input[name="target"]').forEach(function(radio) {
|
||||
radio.addEventListener('change', function() {
|
||||
var userGroup = document.getElementById('user-select-group');
|
||||
userGroup.style.display = this.value === 'selected' ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock main %}
|
||||
@@ -1,55 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Notifications</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-header">
|
||||
<a href="/admin/notifications/create.html" class="btn btn-primary">Create Mass Notification</a>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Type</th>
|
||||
<th>Message</th>
|
||||
<th>Read</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for notif in notifications %}
|
||||
<tr class="{% if notif.is_read %}row-read{% endif %}">
|
||||
<td><a href="/admin/users/{{ notif.user_uid }}/edit.html">{{ notif.user_nick }}</a></td>
|
||||
<td>{{ notif.type }}</td>
|
||||
<td class="message-preview">{{ notif.message }}</td>
|
||||
<td>{% if notif.is_read %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ notif.created_at }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">No notifications found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="admin-pagination">
|
||||
<span class="pagination-info">Showing {{ ((page - 1) * per_page) + 1 }}-{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }}</span>
|
||||
<div class="pagination-controls">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/notifications/index.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span class="pagination-pages">Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/notifications/index.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,20 +0,0 @@
|
||||
<aside class="sidebar admin-sidebar" id="channelSidebar">
|
||||
<h2>Admin</h2>
|
||||
<ul>
|
||||
<li><a class="no-select" href="/admin/index.html">Dashboard</a></li>
|
||||
<li><a class="no-select" href="/admin/users/index.html">Users</a></li>
|
||||
<li><a class="no-select" href="/admin/channels/index.html">Channels</a></li>
|
||||
<li><a class="no-select" href="/admin/messages/index.html">Messages</a></li>
|
||||
<li><a class="no-select" href="/admin/forums/index.html">Forums</a></li>
|
||||
<li><a class="no-select" href="/admin/files/index.html">Files</a></li>
|
||||
<li><a class="no-select" href="/admin/files/repositories.html">Repositories</a></li>
|
||||
<li><a class="no-select" href="/admin/notifications/index.html">Notifications</a></li>
|
||||
<li><a class="no-select" href="/admin/system/index.html">System</a></li>
|
||||
<li><a class="no-select" href="/admin/system/kv.html">KV Store</a></li>
|
||||
<li><a class="no-select" href="/admin/system/push.html">Push</a></li>
|
||||
</ul>
|
||||
<hr>
|
||||
<ul>
|
||||
<li><a class="no-select" href="/settings/index.html">Back to Settings</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
@@ -1,52 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>System</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
{% if message %}
|
||||
<div class="alert alert-success">{{ message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="admin-stats">
|
||||
<h2>System Information</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Uptime</span>
|
||||
<span class="info-value">{{ uptime }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Total Users</span>
|
||||
<span class="info-value">{{ stats.users }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Online Users</span>
|
||||
<span class="info-value">{{ stats.online }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Total Messages</span>
|
||||
<span class="info-value">{{ stats.messages }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-actions-section">
|
||||
<h2>Maintenance</h2>
|
||||
<form method="post" action="/admin/system/index.html">
|
||||
<input type="hidden" name="action" value="maintenance">
|
||||
<p>Run database maintenance (VACUUM and ANALYZE) to optimize performance.</p>
|
||||
<button type="submit" class="btn btn-primary">Run Maintenance</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-links">
|
||||
<h2>Quick Links</h2>
|
||||
<ul>
|
||||
<li><a href="/admin/system/kv.html">KV Store Editor</a></li>
|
||||
<li><a href="/admin/system/push.html">Push Registrations</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,65 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>KV Store</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="admin-form">
|
||||
<h2>Add / Update Entry</h2>
|
||||
<form method="post" action="/admin/system/kv.html">
|
||||
<input type="hidden" name="action" value="set">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="key">Key</label>
|
||||
<input type="text" id="key" name="key" maxlength="255" required placeholder="key_name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="value">Value</label>
|
||||
<input type="text" id="value" name="value" maxlength="10000" required placeholder="value">
|
||||
</div>
|
||||
<div class="form-group form-actions-inline">
|
||||
<button type="submit" class="btn btn-primary">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<h2>Current Entries</h2>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
<td><code>{{ entry.key }}</code></td>
|
||||
<td class="value-preview">{{ entry.value }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/system/kv.html" style="display:inline;">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="key" value="{{ entry.key }}">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this entry?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3">No entries found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,53 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Push Registrations</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Endpoint</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for reg in registrations %}
|
||||
<tr>
|
||||
<td><a href="/admin/users/{{ reg.user_uid }}/edit.html">{{ reg.user_nick }}</a></td>
|
||||
<td class="endpoint-preview">{{ reg.endpoint }}</td>
|
||||
<td>{{ reg.created_at }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/system/push.html" style="display:inline;">
|
||||
<input type="hidden" name="registration_uid" value="{{ reg.uid }}">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this registration?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4">No push registrations found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/system/push.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/system/push.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,37 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>{% if target_user.is_banned %}Unban{% else %}Ban{% endif %} User</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-form danger-zone">
|
||||
<h2>{{ target_user.username }} ({{ target_user.nick }})</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if target_user.is_banned %}
|
||||
<p>This user is currently banned. Unbanning will restore their access to the system.</p>
|
||||
<form method="post" action="/admin/users/{{ target_user.uid }}/ban.html">
|
||||
<input type="hidden" name="action" value="unban">
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Unban User</button>
|
||||
<a href="/admin/users/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>Banning this user will prevent them from logging in and accessing the system.</p>
|
||||
<form method="post" action="/admin/users/{{ target_user.uid }}/ban.html">
|
||||
<input type="hidden" name="action" value="ban">
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-danger">Ban User</button>
|
||||
<a href="/admin/users/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,64 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Edit User</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-form">
|
||||
<h2>{{ target_user.username }}</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/users/{{ target_user.uid }}/edit.html">
|
||||
<div class="form-group">
|
||||
<label for="nick">Nickname</label>
|
||||
<input type="text" id="nick" name="nick" value="{{ target_user.nick }}" maxlength="20" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="color">Color</label>
|
||||
<input type="color" id="color" name="color" value="{{ target_user.color or '#ffffff' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="is_admin" {% if target_user.is_admin %}checked{% endif %}>
|
||||
Administrator
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="is_banned" {% if target_user.is_banned %}checked{% endif %}>
|
||||
Banned
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
<a href="/admin/users/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-info">
|
||||
<h3>User Information</h3>
|
||||
<dl>
|
||||
<dt>UID</dt>
|
||||
<dd>{{ target_user.uid }}</dd>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ target_user.created_at }}</dd>
|
||||
<dt>Last Online</dt>
|
||||
<dd>{{ target_user.last_online or 'Never' }}</dd>
|
||||
<dt>Location</dt>
|
||||
<dd>{{ target_user.city }}{% if target_user.country_long %}, {{ target_user.country_long }}{% endif %}</dd>
|
||||
<dt>IP</dt>
|
||||
<dd>{{ target_user.ip or 'Unknown' }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,68 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Users</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-search">
|
||||
<form method="get" action="/admin/users/index.html">
|
||||
<input type="text" name="search" placeholder="Search users..." value="{{ search }}" class="search-input">
|
||||
<button type="submit" class="btn btn-primary">Search</button>
|
||||
{% if search %}
|
||||
<a href="/admin/users/index.html" class="btn btn-secondary">Clear</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Nick</th>
|
||||
<th>Admin</th>
|
||||
<th>Banned</th>
|
||||
<th>Location</th>
|
||||
<th>Last Online</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr class="{% if user.is_banned %}row-banned{% endif %}">
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{{ user.nick }}</td>
|
||||
<td>{% if user.is_admin %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{% if user.is_banned %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ user.city }}{% if user.country_short %}, {{ user.country_short }}{% endif %}</td>
|
||||
<td>{{ user.last_online or 'Never' }}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/users/{{ user.uid }}/edit.html" class="btn btn-small">Edit</a>
|
||||
<a href="/admin/users/{{ user.uid }}/ban.html" class="btn btn-small btn-danger">{% if user.is_banned %}Unban{% else %}Ban{% endif %}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7">No users found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="admin-pagination">
|
||||
<span class="pagination-info">Showing {{ ((page - 1) * per_page) + 1 }}-{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }} users</span>
|
||||
<div class="pagination-controls">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/users/index.html?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span class="pagination-pages">Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/users/index.html?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
+25
-21
@@ -13,8 +13,6 @@
|
||||
<script src="/file-upload-grid.js" type="module"></script>
|
||||
<script src="/polyfills/Promise.withResolvers.js" type="module"></script>
|
||||
|
||||
<script src="/nav-menu.js" type="module"></script>
|
||||
<script src="/channel-menu.js" type="module"></script>
|
||||
<script src="/push.js" type="module"></script>
|
||||
<script src="/fancy-button.js" type="module"></script>
|
||||
<script src="/upload-button.js" type="module"></script>
|
||||
@@ -36,17 +34,23 @@
|
||||
<link rel="stylesheet" href="/buttons.css">
|
||||
<link rel="stylesheet" href="/inputs.css">
|
||||
<link rel="stylesheet" href="/lists.css">
|
||||
<link rel="stylesheet" href="/channel-menu.css">
|
||||
<link rel="stylesheet" href="/mention-nav.css">
|
||||
<link rel="icon" type="image/png" href="/image/snek_logo_32x32.png" sizes="32x32">
|
||||
<link rel="icon" type="image/png" href="/image/snek_logo_64x64.png" sizes="64x64">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo no-select">{% block header_text %}{% endblock %}</div>
|
||||
<channel-menu></channel-menu>
|
||||
<nav-menu></nav-menu>
|
||||
<nav class="no-select" style="overflow:hidden;scroll-behavior:smooth">
|
||||
<a class="no-select" href="/web.html">🏠</a>
|
||||
<a class="no-select" href="/drive.html">đź“‚</a>
|
||||
<a class="no-select" href="/search-user.html">🔍</a>
|
||||
<a class="no-select" style="display:none" id="install-button" href="#">📥</a>
|
||||
<a class="no-select" href="/forum/index.html">đź’¬</a>
|
||||
<a class="no-select" href="/settings/index.html">⚙️</a>
|
||||
<a class="no-select" href="#" onclick="registerNotificationsServiceWorker()">✉️</a>
|
||||
<a class="no-select" href="/logout.html">đź”’</a>
|
||||
</nav>
|
||||
|
||||
</header>
|
||||
|
||||
{% block sidebar %}
|
||||
@@ -148,21 +152,21 @@ app.starField.renderWord("H4x0r 1337")
|
||||
}
|
||||
})
|
||||
|
||||
let installPrompt = null
|
||||
let installPrompt = null
|
||||
window.addEventListener("beforeinstallprompt", (e) => {
|
||||
//e.preventDefault();
|
||||
installPrompt = e;
|
||||
const navMenu = document.querySelector('nav-menu');
|
||||
if (navMenu) {
|
||||
navMenu.showInstallButton();
|
||||
const button = navMenu.getInstallButton();
|
||||
if (button) {
|
||||
button.addEventListener("click", async (evt) => {
|
||||
evt.preventDefault();
|
||||
const result = await installPrompt.prompt();
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
//document.addEventListener("DOMContentLoaded", () => {
|
||||
const button = document.getElementById("install-button")
|
||||
|
||||
button.style.display = 'inline-block'
|
||||
|
||||
button.addEventListener("click", async ()=>{
|
||||
const result = await installPrompt.prompt()
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
|
||||
;
|
||||
</script>
|
||||
|
||||
@@ -1,290 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Documentation - Snek Platform</title>
|
||||
<meta name="description" content="Snek platform documentation hub. Learn about architecture, API, bot development, and how to contribute." />
|
||||
<link rel="stylesheet" href="/sandbox.css" />
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: 'Segoe UI',sans-serif;
|
||||
background: #111;
|
||||
color: #eee;
|
||||
line-height:1.5;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
a { color: #7ef; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }
|
||||
.page-hero {
|
||||
text-align: center;
|
||||
padding: 3rem 0 2rem 0;
|
||||
}
|
||||
.page-hero img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.page-hero h1 {
|
||||
font-size: 2.5rem;
|
||||
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
.page-hero p {
|
||||
color: #aaa;
|
||||
font-size: 1.1rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.doc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
.doc-card {
|
||||
background: #181818;
|
||||
border-radius: 6px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
}
|
||||
.doc-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
text-decoration: none;
|
||||
}
|
||||
.doc-card h2 {
|
||||
color: #7ef;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.doc-card p {
|
||||
color: #aaa;
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
}
|
||||
.doc-card .arrow {
|
||||
color: #0fa;
|
||||
float: right;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.section {
|
||||
background: #181818;
|
||||
border-radius: 6px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
.section h2 {
|
||||
color: #7ef;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.section p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.section code {
|
||||
background: #222;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
color: #7ef;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
.section pre {
|
||||
background: #222;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
color: #7ef;
|
||||
}
|
||||
.topnav {
|
||||
width: 100%;
|
||||
background: #181818;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
|
||||
padding: 0.6rem 0;
|
||||
}
|
||||
.topnav .nav-container {
|
||||
width: 90%;
|
||||
max-width: 960px;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.topnav a {
|
||||
color: #7ef;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
transition: background 0.14s;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.topnav a:hover, .topnav a.active {
|
||||
background: #222b;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.topnav .home-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.topnav .home-link img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 5px;
|
||||
background: #222;
|
||||
}
|
||||
footer {
|
||||
width: 100%;
|
||||
background: #181818;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
padding: 1.4rem 0;
|
||||
font-size: 1rem;
|
||||
margin-top: auto;
|
||||
box-shadow: 0 -1px 8px rgba(0,0,0,0.22);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.page-hero h1 { font-size: 2rem; }
|
||||
.section { padding: 1rem; }
|
||||
.topnav .nav-container {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.topnav a {
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.doc-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="topnav">
|
||||
<div class="nav-container">
|
||||
<a class="home-link" href="/">
|
||||
<img src="/image/snek_logo_256x256.png" alt="Snek" />
|
||||
Snek
|
||||
</a>
|
||||
<a href="/docs.html" class="active">Docs</a>
|
||||
<a href="/architecture.html">Architecture</a>
|
||||
<a href="/api.html">API</a>
|
||||
<a href="/bots.html">Bots</a>
|
||||
<a href="/contribute.html">Contribute</a>
|
||||
<a href="/about.html">About</a>
|
||||
</div>
|
||||
</nav>
|
||||
{% extends "base.html" %}
|
||||
|
||||
<header class="container page-hero">
|
||||
<img src="/image/snek_logo_256x256.png" alt="Snek Logo" />
|
||||
<h1>Documentation</h1>
|
||||
<p>Learn how to use, extend, and contribute to the Snek platform</p>
|
||||
</header>
|
||||
{% block main %}
|
||||
<div class="dialog">
|
||||
|
||||
<main class="container">
|
||||
<nav class="doc-grid">
|
||||
<a href="/architecture.html" class="doc-card">
|
||||
<span class="arrow">→</span>
|
||||
<h2>Architecture</h2>
|
||||
<p>Understand the technical architecture: backend services, frontend modules, database design, and real-time communication.</p>
|
||||
</a>
|
||||
<fancy-button size="auto" text="Back" url="/back"></fancy-button>
|
||||
<html-frame url="/docs.md"></html-frame>
|
||||
|
||||
<a href="/design.html" class="doc-card">
|
||||
<span class="arrow">→</span>
|
||||
<h2>Design Decisions</h2>
|
||||
<p>Learn why specific technologies were chosen and the philosophy behind the platform's design.</p>
|
||||
</a>
|
||||
|
||||
<a href="/api.html" class="doc-card">
|
||||
<span class="arrow">→</span>
|
||||
<h2>API Reference</h2>
|
||||
<p>Complete WebSocket RPC API documentation with all available methods for channels, messages, and more.</p>
|
||||
</a>
|
||||
|
||||
<a href="/bots.html" class="doc-card">
|
||||
<span class="arrow">→</span>
|
||||
<h2>Bot Development</h2>
|
||||
<p>Build automated bots with complete Python and JavaScript examples. Integrate with AI services.</p>
|
||||
</a>
|
||||
|
||||
<a href="/contribute.html" class="doc-card">
|
||||
<span class="arrow">→</span>
|
||||
<h2>Contribute</h2>
|
||||
<p>Set up development environment, understand project structure, and learn how to add features.</p>
|
||||
</a>
|
||||
|
||||
<a href="/about.html" class="doc-card">
|
||||
<span class="arrow">→</span>
|
||||
<h2>About</h2>
|
||||
<p>Learn about the platform's mission, principles, and why it exists.</p>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<section class="section">
|
||||
<h2>Quick Start</h2>
|
||||
<p>Get Snek running locally in under a minute:</p>
|
||||
<pre>pip install git+https://retoor.molodetz.nl/retoor/snek.git
|
||||
snek serve</pre>
|
||||
<p>The server starts at <code>http://localhost:8080</code>. Register a user and start chatting.</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Key Features</h2>
|
||||
<p>Snek is a privacy-focused platform for developers:</p>
|
||||
<ul style="list-style: disc inside; margin-bottom: 1rem;">
|
||||
<li>Real-time chat with WebSocket communication</li>
|
||||
<li>File sharing via WebDAV and SFTP</li>
|
||||
<li>Git repository hosting</li>
|
||||
<li>In-browser Ubuntu terminal</li>
|
||||
<li>AI integration for chat assistance</li>
|
||||
<li>Bot development support</li>
|
||||
<li>No email required, no activity logging</li>
|
||||
<li>Self-hosting with single command deployment</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Connection Details</h2>
|
||||
<p>Access the platform via multiple protocols:</p>
|
||||
<ul style="list-style: none; margin-bottom: 1rem;">
|
||||
<li><strong>WebDAV:</strong> <code>davs://molodetz.online/webdav</code></li>
|
||||
<li><strong>SFTP:</strong> <code>sftp://molodetz.online:2242</code></li>
|
||||
<li><strong>Git:</strong> <code>git@molodetz.online:username/repo.git</code></li>
|
||||
<li><strong>WebSocket:</strong> <code>wss://snek.community/rpc.ws</code></li>
|
||||
</ul>
|
||||
<p>All protocols use your Snek credentials.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 Snek Platform - retoor <retoor@molodetz.nl></p>
|
||||
</footer>
|
||||
{% include "sandbox.html" %}
|
||||
</body>
|
||||
</html>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -168,8 +168,7 @@
|
||||
<p>Professional Platform for Developers, Testers & AI Professionals</p>
|
||||
<a href="/login.html" class="btn">Login</a>
|
||||
<a href="/register.html" class="btn btn-primary">Register</a>
|
||||
<a href="/docs.html" class="btn">Docs</a>
|
||||
<a href="/about.html" class="btn">About</a>
|
||||
<a href="/about.html" class="about-link">About</a>
|
||||
</header>
|
||||
<main class="container">
|
||||
<section id="features" class="grid">
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
{% extends "app.html" %}
|
||||
|
||||
{% block body_class %}profile-page{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
<aside class="sidebar" id="channelSidebar">
|
||||
<h2>Navigation</h2>
|
||||
|
||||
@@ -567,7 +567,7 @@ const count = Array.from(messages).filter(el => el.textContent.trim() === text).
|
||||
|
||||
|
||||
|
||||
const starField = new StarField({count: 200});
|
||||
const starField = new StarField({starCount: 50});
|
||||
app.starField = starField;
|
||||
|
||||
class DemoSequence {
|
||||
|
||||
@@ -5,10 +5,4 @@
|
||||
<li><a class="no-select" href="/settings/profile_pages/index.html">Profile Pages</a></li>
|
||||
<li><a class="no-select" href="/settings/repositories/index.html">Repositories</a></li>
|
||||
</ul>
|
||||
{% if user and user.is_admin %}
|
||||
<h2>Administration</h2>
|
||||
<ul>
|
||||
<li><a class="no-select" href="/admin/index.html">Admin Panel</a></li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
</aside>
|
||||
|
||||
@@ -1,630 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>API Reference - Snek Platform Documentation</title>
|
||||
<meta name="description" content="Complete API reference for the Snek platform WebSocket RPC interface. Documentation for all available methods including authentication, channels, messages, and more." />
|
||||
<link rel="stylesheet" href="/sandbox.css" />
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: 'Segoe UI',sans-serif;
|
||||
background: #111;
|
||||
color: #eee;
|
||||
line-height:1.5;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
a { color: #7ef; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }
|
||||
.page-hero {
|
||||
text-align: center;
|
||||
padding: 3rem 0 2rem 0;
|
||||
}
|
||||
.page-hero h1 {
|
||||
font-size: 2.5rem;
|
||||
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
.page-hero p {
|
||||
color: #aaa;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.section {
|
||||
background: #181818;
|
||||
border-radius: 6px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
.section h2 {
|
||||
color: #7ef;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.section p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.section code {
|
||||
background: #222;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
color: #7ef;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
.section pre {
|
||||
background: #222;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #7ef;
|
||||
}
|
||||
.method {
|
||||
border-bottom: 1px solid #333;
|
||||
padding: 1.25rem 0;
|
||||
}
|
||||
.method:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.method-name {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 1.1rem;
|
||||
color: #0fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
.method-sig {
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.method-desc {
|
||||
margin: 0.75rem 0;
|
||||
color: #ccc;
|
||||
}
|
||||
.method-params {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.method-params strong {
|
||||
color: #f05a28;
|
||||
}
|
||||
.method-returns {
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
}
|
||||
.method-returns strong {
|
||||
color: #7ef;
|
||||
}
|
||||
.auth-required {
|
||||
display: inline-block;
|
||||
background: #f05a28;
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
margin-left: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.toc {
|
||||
background: #1a1a1a;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.toc h3 {
|
||||
color: #7ef;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.toc ul {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.toc a {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.topnav {
|
||||
width: 100%;
|
||||
background: #181818;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
|
||||
padding: 0.6rem 0;
|
||||
}
|
||||
.topnav .nav-container {
|
||||
width: 90%;
|
||||
max-width: 960px;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.topnav a {
|
||||
color: #7ef;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
transition: background 0.14s;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.topnav a:hover, .topnav a.active {
|
||||
background: #222b;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.topnav .home-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.topnav .home-link img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 5px;
|
||||
background: #222;
|
||||
}
|
||||
footer {
|
||||
width: 100%;
|
||||
background: #181818;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
padding: 1.4rem 0;
|
||||
font-size: 1rem;
|
||||
margin-top: auto;
|
||||
box-shadow: 0 -1px 8px rgba(0,0,0,0.22);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.page-hero h1 { font-size: 2rem; }
|
||||
.section { padding: 1rem; }
|
||||
.topnav .nav-container {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.topnav a {
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.toc ul {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="topnav">
|
||||
<div class="nav-container">
|
||||
<a class="home-link" href="/">
|
||||
<img src="/image/snek_logo_256x256.png" alt="Snek" />
|
||||
Snek
|
||||
</a>
|
||||
<a href="/docs.html">Docs</a>
|
||||
<a href="/architecture.html">Architecture</a>
|
||||
<a href="/api.html" class="active">API</a>
|
||||
<a href="/bots.html">Bots</a>
|
||||
<a href="/contribute.html">Contribute</a>
|
||||
<a href="/about.html">About</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header class="container page-hero">
|
||||
<h1>API Reference</h1>
|
||||
<p>WebSocket RPC interface documentation</p>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<nav class="toc">
|
||||
<h3>Categories</h3>
|
||||
<ul>
|
||||
<li><a href="#protocol">Protocol</a></li>
|
||||
<li><a href="#authentication">Authentication</a></li>
|
||||
<li><a href="#user">User</a></li>
|
||||
<li><a href="#channels">Channels</a></li>
|
||||
<li><a href="#messages">Messages</a></li>
|
||||
<li><a href="#presence">Presence</a></li>
|
||||
<li><a href="#container">Container</a></li>
|
||||
<li><a href="#database">Database</a></li>
|
||||
<li><a href="#utility">Utility</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<section class="section" id="protocol">
|
||||
<h2>Protocol</h2>
|
||||
<p>Snek uses a JSON-RPC style protocol over WebSocket. Connect to <code>/rpc.ws</code> to establish a connection.</p>
|
||||
|
||||
<h3 style="color:#0fa;margin:1rem 0 0.5rem 0;">Request Format</h3>
|
||||
<pre>{
|
||||
"callId": "unique-id",
|
||||
"method": "method_name",
|
||||
"args": [arg1, arg2, ...]
|
||||
}</pre>
|
||||
|
||||
<h3 style="color:#0fa;margin:1rem 0 0.5rem 0;">Response Format</h3>
|
||||
<pre>{
|
||||
"callId": "unique-id",
|
||||
"success": true,
|
||||
"data": { ... }
|
||||
}</pre>
|
||||
|
||||
<h3 style="color:#0fa;margin:1rem 0 0.5rem 0;">Server Events</h3>
|
||||
<p>The server pushes events without a callId:</p>
|
||||
<pre>{
|
||||
"channel_uid": "...",
|
||||
"event": "new_message",
|
||||
"data": { ... }
|
||||
}</pre>
|
||||
</section>
|
||||
|
||||
<section class="section" id="authentication">
|
||||
<h2>Authentication</h2>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">login</span><span class="method-sig">(username, password)</span>
|
||||
<p class="method-desc">Authenticate and establish a session. On success, the WebSocket connection is associated with the user and subscribed to all their channels.</p>
|
||||
<p class="method-params"><strong>username</strong> (string) - The username</p>
|
||||
<p class="method-params"><strong>password</strong> (string) - The password</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> User record (without password field) on success, error on failure</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">ping</span><span class="method-sig">()</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Keep the connection alive and update the user's last activity timestamp.</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> <code>{"pong": []}</code></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="user">
|
||||
<h2>User</h2>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">get_user</span><span class="method-sig">(user_uid=None)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Get user information. If no user_uid is provided, returns the current user's data.</p>
|
||||
<p class="method-params"><strong>user_uid</strong> (string, optional) - Target user's UID</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> User record (email hidden for other users)</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">search_user</span><span class="method-sig">(query)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Search for users by username.</p>
|
||||
<p class="method-params"><strong>query</strong> (string) - Search query</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Array of matching usernames</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="channels">
|
||||
<h2>Channels</h2>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">get_channels</span><span class="method-sig">()</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Get all channels the current user is a member of.</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Array of channel objects with name, uid, tag, new_count, is_moderator, is_read_only, color</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">create_channel</span><span class="method-sig">(name, description=None, is_private=False)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Create a new channel. The creating user becomes the moderator.</p>
|
||||
<p class="method-params"><strong>name</strong> (string) - Channel name</p>
|
||||
<p class="method-params"><strong>description</strong> (string, optional) - Channel description</p>
|
||||
<p class="method-params"><strong>is_private</strong> (boolean, optional) - Whether the channel is private</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> <code>{success: true, uid: "...", name: "..."}</code></p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">update_channel</span><span class="method-sig">(channel_uid, name, description=None, is_private=False)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Update channel settings. Only the channel creator or admin can update.</p>
|
||||
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
|
||||
<p class="method-params"><strong>name</strong> (string) - New channel name</p>
|
||||
<p class="method-params"><strong>description</strong> (string, optional) - New description</p>
|
||||
<p class="method-params"><strong>is_private</strong> (boolean, optional) - Privacy setting</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> <code>{success: true, name: "..."}</code></p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">delete_channel</span><span class="method-sig">(channel_uid)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Delete a channel (soft delete). Only the channel creator or admin can delete. Public channel cannot be deleted.</p>
|
||||
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> <code>{success: true}</code></p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">invite_user</span><span class="method-sig">(channel_uid, username)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Invite a user to a channel. Requires membership in the channel.</p>
|
||||
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
|
||||
<p class="method-params"><strong>username</strong> (string) - Username to invite</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> <code>{success: true, username: "..."}</code></p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">leave_channel</span><span class="method-sig">(channel_uid)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Leave a channel. Cannot leave the public channel.</p>
|
||||
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> <code>{success: true}</code></p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">clear_channel</span><span class="method-sig">(channel_uid)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Clear all messages from a channel. Admin only.</p>
|
||||
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Boolean success</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="messages">
|
||||
<h2>Messages</h2>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">send_message</span><span class="method-sig">(channel_uid, message, is_final=True)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Send a message to a channel. If is_final is false, the message can be updated (for typing indicators).</p>
|
||||
<p class="method-params"><strong>channel_uid</strong> (string) - Target channel UID</p>
|
||||
<p class="method-params"><strong>message</strong> (string) - Message content</p>
|
||||
<p class="method-params"><strong>is_final</strong> (boolean, optional) - Whether the message is final (default true)</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Message UID on success</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">get_messages</span><span class="method-sig">(channel_uid, offset=0, timestamp=None)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Get messages from a channel with pagination.</p>
|
||||
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
|
||||
<p class="method-params"><strong>offset</strong> (integer, optional) - Pagination offset</p>
|
||||
<p class="method-params"><strong>timestamp</strong> (string, optional) - Filter by timestamp</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Array of message objects</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">update_message_text</span><span class="method-sig">(message_uid, text)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Update a non-final message's text. Only the message author can update. Message must be less than 8 seconds old.</p>
|
||||
<p class="method-params"><strong>message_uid</strong> (string) - Message UID</p>
|
||||
<p class="method-params"><strong>text</strong> (string) - New text content</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> <code>{success: true}</code></p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">finalize_message</span><span class="method-sig">(message_uid)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Mark a message as final, preventing further updates.</p>
|
||||
<p class="method-params"><strong>message_uid</strong> (string) - Message UID</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Boolean success</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">mark_as_read</span><span class="method-sig">(channel_uid)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Mark all messages in a channel as read for the current user.</p>
|
||||
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Boolean success</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">get_first_unread_message_uid</span><span class="method-sig">(channel_uid)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Get the UID of the first unread message in a channel.</p>
|
||||
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Message UID or null</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="presence">
|
||||
<h2>Presence</h2>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">set_typing</span><span class="method-sig">(channel_uid, color=None)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Broadcast a typing indicator to channel members.</p>
|
||||
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
|
||||
<p class="method-params"><strong>color</strong> (string, optional) - Indicator color (defaults to user's color)</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Boolean success</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">get_online_users</span><span class="method-sig">(channel_uid)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Get users currently online in a channel.</p>
|
||||
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Array of user objects sorted by nick</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">get_recent_users</span><span class="method-sig">(channel_uid)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Get users with recent activity in a channel.</p>
|
||||
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Array with uid, username, nick, last_ping</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">get_users</span><span class="method-sig">(channel_uid)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Get all members of a channel.</p>
|
||||
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Array with uid, username, nick, last_ping</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="container">
|
||||
<h2>Container</h2>
|
||||
<p>Container methods provide access to channel-specific Docker containers (Ubuntu terminals).</p>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">get_container</span><span class="method-sig">(channel_uid)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Get container information for a channel.</p>
|
||||
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Container object with name, cpus, memory, image, status</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">start_container</span><span class="method-sig">(channel_uid)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Start the container for a channel.</p>
|
||||
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Boolean success</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">stop_container</span><span class="method-sig">(channel_uid)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Stop the container for a channel.</p>
|
||||
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Boolean success</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">get_container_status</span><span class="method-sig">(channel_uid)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Get the current status of a channel's container.</p>
|
||||
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Status string</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">write_container</span><span class="method-sig">(channel_uid, content, timeout=3)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Write to container stdin and capture output.</p>
|
||||
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
|
||||
<p class="method-params"><strong>content</strong> (string) - Content to write to stdin</p>
|
||||
<p class="method-params"><strong>timeout</strong> (integer, optional) - Wait timeout in seconds (max 30)</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Output string</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="database">
|
||||
<h2>Database</h2>
|
||||
<p>Database methods provide direct access to user-scoped database operations.</p>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">db_insert</span><span class="method-sig">(table_name, record)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Insert a record into a user-scoped table.</p>
|
||||
<p class="method-params"><strong>table_name</strong> (string) - Table name</p>
|
||||
<p class="method-params"><strong>record</strong> (object) - Record to insert</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Inserted record</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">db_update</span><span class="method-sig">(table_name, record)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Update a record in a user-scoped table.</p>
|
||||
<p class="method-params"><strong>table_name</strong> (string) - Table name</p>
|
||||
<p class="method-params"><strong>record</strong> (object) - Record with updates</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Updated record</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">db_delete</span><span class="method-sig">(table_name, record)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Delete a record from a user-scoped table.</p>
|
||||
<p class="method-params"><strong>table_name</strong> (string) - Table name</p>
|
||||
<p class="method-params"><strong>record</strong> (object) - Record identifier</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Boolean success</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">db_get</span><span class="method-sig">(table_name, record)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Get a single record from a user-scoped table.</p>
|
||||
<p class="method-params"><strong>table_name</strong> (string) - Table name</p>
|
||||
<p class="method-params"><strong>record</strong> (object) - Query criteria</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Record or null</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">db_find</span><span class="method-sig">(table_name, record)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Find records in a user-scoped table.</p>
|
||||
<p class="method-params"><strong>table_name</strong> (string) - Table name</p>
|
||||
<p class="method-params"><strong>record</strong> (object) - Query criteria</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Array of records</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">db_upsert</span><span class="method-sig">(table_name, record, keys)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Insert or update a record based on key fields.</p>
|
||||
<p class="method-params"><strong>table_name</strong> (string) - Table name</p>
|
||||
<p class="method-params"><strong>record</strong> (object) - Record data</p>
|
||||
<p class="method-params"><strong>keys</strong> (array) - Key fields for matching</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Upserted record</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">db_query</span><span class="method-sig">(sql, args)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Execute a raw SQL query on user-scoped data.</p>
|
||||
<p class="method-params"><strong>sql</strong> (string) - SQL query</p>
|
||||
<p class="method-params"><strong>args</strong> (array) - Query parameters</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Query results</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">query</span><span class="method-sig">(sql)</span>
|
||||
<span class="auth-required">Auth Required</span>
|
||||
<p class="method-desc">Execute a read-only SQL query. Forbidden keywords (DROP, ALTER, etc.) are blocked.</p>
|
||||
<p class="method-params"><strong>sql</strong> (string) - SQL SELECT query</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Array of records (sensitive fields filtered)</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="utility">
|
||||
<h2>Utility</h2>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">echo</span><span class="method-sig">(*args)</span>
|
||||
<p class="method-desc">Echo back the provided arguments. Useful for testing.</p>
|
||||
<p class="method-params"><strong>args</strong> - Any arguments</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> The same arguments</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">echo_raw</span><span class="method-sig">(obj)</span>
|
||||
<p class="method-desc">Send a raw JSON object through the WebSocket. No response is sent back.</p>
|
||||
<p class="method-params"><strong>obj</strong> (object) - Object to send</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> No response</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<span class="method-name">stars_render</span><span class="method-sig">(channel_uid, message)</span>
|
||||
<p class="method-desc">Broadcast a stars render event to all online users in a channel.</p>
|
||||
<p class="method-params"><strong>channel_uid</strong> (string) - Channel UID</p>
|
||||
<p class="method-params"><strong>message</strong> (string) - Message content</p>
|
||||
<p class="method-returns"><strong>Returns:</strong> Boolean success</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 Snek Platform - retoor <retoor@molodetz.nl></p>
|
||||
</footer>
|
||||
{% include "sandbox.html" %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,367 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Architecture - Snek Platform Documentation</title>
|
||||
<meta name="description" content="Technical architecture documentation for the Snek platform. Learn about the layered architecture, backend services, frontend modules, and real-time communication systems." />
|
||||
<link rel="stylesheet" href="/sandbox.css" />
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: 'Segoe UI',sans-serif;
|
||||
background: #111;
|
||||
color: #eee;
|
||||
line-height:1.5;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
a { color: #7ef; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }
|
||||
.page-hero {
|
||||
text-align: center;
|
||||
padding: 3rem 0 2rem 0;
|
||||
}
|
||||
.page-hero h1 {
|
||||
font-size: 2.5rem;
|
||||
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
.page-hero p {
|
||||
color: #aaa;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.section {
|
||||
background: #181818;
|
||||
border-radius: 6px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
.section h2 {
|
||||
color: #7ef;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.section h3 {
|
||||
color: #0fa;
|
||||
margin: 1.5rem 0 0.75rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.section p, .section ul {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.section ul {
|
||||
list-style: disc inside;
|
||||
}
|
||||
.section li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.section code {
|
||||
background: #222;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
color: #7ef;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
.section pre {
|
||||
background: #222;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
color: #7ef;
|
||||
}
|
||||
.topnav {
|
||||
width: 100%;
|
||||
background: #181818;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
|
||||
padding: 0.6rem 0;
|
||||
}
|
||||
.topnav .nav-container {
|
||||
width: 90%;
|
||||
max-width: 960px;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.topnav a {
|
||||
color: #7ef;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
transition: background 0.14s;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.topnav a:hover, .topnav a.active {
|
||||
background: #222b;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.topnav .home-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.topnav .home-link img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 5px;
|
||||
background: #222;
|
||||
}
|
||||
.arch-diagram {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
color: #ccc;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.arch-diagram .layer {
|
||||
border: 1px dashed #444;
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.arch-diagram .layer-name {
|
||||
color: #7ef;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
footer {
|
||||
width: 100%;
|
||||
background: #181818;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
padding: 1.4rem 0;
|
||||
font-size: 1rem;
|
||||
margin-top: auto;
|
||||
box-shadow: 0 -1px 8px rgba(0,0,0,0.22);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.page-hero h1 { font-size: 2rem; }
|
||||
.section { padding: 1rem; }
|
||||
.topnav .nav-container {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.topnav a {
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="topnav">
|
||||
<div class="nav-container">
|
||||
<a class="home-link" href="/">
|
||||
<img src="/image/snek_logo_256x256.png" alt="Snek" />
|
||||
Snek
|
||||
</a>
|
||||
<a href="/docs.html">Docs</a>
|
||||
<a href="/architecture.html" class="active">Architecture</a>
|
||||
<a href="/api.html">API</a>
|
||||
<a href="/bots.html">Bots</a>
|
||||
<a href="/contribute.html">Contribute</a>
|
||||
<a href="/about.html">About</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header class="container page-hero">
|
||||
<h1>Architecture</h1>
|
||||
<p>Technical architecture and system design of the Snek platform</p>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<section class="section">
|
||||
<h2>System Overview</h2>
|
||||
<p>Snek follows a layered architecture pattern with clear separation of concerns. The system is built on Python's aiohttp framework for asynchronous HTTP and WebSocket handling.</p>
|
||||
<div class="arch-diagram">
|
||||
<div class="layer">
|
||||
<div class="layer-name">Frontend Layer</div>
|
||||
Vanilla JavaScript ES6 Modules | Custom Elements | WebSocket Client
|
||||
</div>
|
||||
<div class="layer">
|
||||
<div class="layer-name">View Layer</div>
|
||||
HTTP Handlers | WebSocket RPC | Template Rendering | Form Processing
|
||||
</div>
|
||||
<div class="layer">
|
||||
<div class="layer-name">Service Layer</div>
|
||||
Business Logic | Caching | Event Broadcasting | Authentication
|
||||
</div>
|
||||
<div class="layer">
|
||||
<div class="layer-name">Mapper Layer</div>
|
||||
Data Access | CRUD Operations | Soft Delete | Query Building
|
||||
</div>
|
||||
<div class="layer">
|
||||
<div class="layer-name">Database Layer</div>
|
||||
SQLite | Dataset ORM | Schema Management
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Backend Architecture</h2>
|
||||
|
||||
<h3>aiohttp Framework</h3>
|
||||
<p>The backend is built on aiohttp, a high-performance asynchronous HTTP client/server framework. Key features:</p>
|
||||
<ul>
|
||||
<li>Fully asynchronous request handling with async/await</li>
|
||||
<li>Native WebSocket support for real-time communication</li>
|
||||
<li>Session management via aiohttp-session</li>
|
||||
<li>Static file serving and template rendering</li>
|
||||
</ul>
|
||||
|
||||
<h3>Service Layer Pattern</h3>
|
||||
<p>Services encapsulate business logic and are accessed via a singleton registry:</p>
|
||||
<pre>user = await app.services.user.get(uid=user_uid)
|
||||
channel = await app.services.channel.create(label="#general")</pre>
|
||||
<p>Available services include: user, channel, channel_member, channel_message, chat, socket, container, db, and more.</p>
|
||||
|
||||
<h3>Mapper Pattern</h3>
|
||||
<p>Mappers provide data access abstraction with consistent CRUD operations:</p>
|
||||
<pre>await mapper.get(uid=uid)
|
||||
await mapper.find(field=value)
|
||||
await mapper.save(model)
|
||||
async for record in mapper.query(sql, params): ...</pre>
|
||||
<p>All mappers support soft delete via the <code>deleted_at</code> field.</p>
|
||||
|
||||
<h3>Caching Strategy</h3>
|
||||
<p>The service layer implements UID-based caching for frequently accessed data. Cache invalidation occurs on model updates.</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Frontend Architecture</h2>
|
||||
|
||||
<h3>Vanilla JavaScript Philosophy</h3>
|
||||
<p>The frontend uses pure ES6 JavaScript modules without any frameworks. This ensures:</p>
|
||||
<ul>
|
||||
<li>Minimal bundle size and fast load times</li>
|
||||
<li>No framework upgrade cycles or breaking changes</li>
|
||||
<li>Direct DOM manipulation for maximum performance</li>
|
||||
<li>Easy debugging without transpilation</li>
|
||||
</ul>
|
||||
|
||||
<h3>Module Structure</h3>
|
||||
<pre>src/snek/static/
|
||||
app.js # Main Application class (singleton)
|
||||
socket.js # WebSocket RPC client
|
||||
chat-window.js # Chat interface component
|
||||
message-list.js # Message rendering
|
||||
chat-input.js # Input handling
|
||||
...</pre>
|
||||
|
||||
<h3>Custom Elements</h3>
|
||||
<p>UI components are implemented as Custom Elements:</p>
|
||||
<pre><chat-window></chat-window>
|
||||
<message-list></message-list>
|
||||
<channel-menu></channel-menu>
|
||||
<nav-menu></nav-menu></pre>
|
||||
<p>Each component has its own CSS file (no Shadow DOM per project guidelines).</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Database Design</h2>
|
||||
|
||||
<h3>Dataset ORM</h3>
|
||||
<p>Snek uses Dataset, a simple database abstraction layer built on SQLAlchemy. It provides:</p>
|
||||
<ul>
|
||||
<li>Automatic table creation and schema inference</li>
|
||||
<li>Dictionary-based record access</li>
|
||||
<li>Transaction support</li>
|
||||
<li>Raw SQL query execution when needed</li>
|
||||
</ul>
|
||||
|
||||
<h3>Core Tables</h3>
|
||||
<pre>user # User accounts
|
||||
channel # Chat channels
|
||||
channel_member # Channel membership
|
||||
channel_message# Chat messages
|
||||
session # User sessions
|
||||
...</pre>
|
||||
|
||||
<h3>Soft Delete Pattern</h3>
|
||||
<p>Records are never physically deleted. Instead, the <code>deleted_at</code> timestamp is set. All queries filter by <code>deleted_at IS NULL</code> by default.</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Real-time Communication</h2>
|
||||
|
||||
<h3>WebSocket RPC System</h3>
|
||||
<p>Real-time features use a JSON-RPC style protocol over WebSocket:</p>
|
||||
<pre>{
|
||||
"callId": "1",
|
||||
"method": "send_message",
|
||||
"args": ["channel_uid", "Hello world", true]
|
||||
}</pre>
|
||||
<p>The server responds with:</p>
|
||||
<pre>{
|
||||
"callId": "1",
|
||||
"success": true,
|
||||
"data": "message_uid"
|
||||
}</pre>
|
||||
|
||||
<h3>Event Broadcasting</h3>
|
||||
<p>Server-initiated events are broadcast to channel subscribers:</p>
|
||||
<pre>{
|
||||
"channel_uid": "...",
|
||||
"event": "new_message",
|
||||
"data": { ... }
|
||||
}</pre>
|
||||
|
||||
<h3>Channel Subscription Model</h3>
|
||||
<p>Users automatically subscribe to all their channels on WebSocket connection. The socket service manages subscriptions and delivers messages to the appropriate clients.</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Directory Structure</h2>
|
||||
<pre>src/snek/
|
||||
__main__.py # CLI entry point
|
||||
app.py # Application setup and routing
|
||||
view/ # HTTP/WebSocket handlers
|
||||
index.py # Landing page
|
||||
rpc.py # WebSocket RPC
|
||||
settings/ # Settings views
|
||||
service/ # Business logic layer
|
||||
mapper/ # Data persistence
|
||||
model/ # Data models
|
||||
form/ # Form definitions
|
||||
system/ # Core infrastructure
|
||||
view.py # BaseView class
|
||||
service.py # BaseService class
|
||||
mapper.py # BaseMapper class
|
||||
model.py # BaseModel class
|
||||
static/ # Frontend JavaScript and CSS
|
||||
templates/ # Jinja2 templates</pre>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 Snek Platform - retoor <retoor@molodetz.nl></p>
|
||||
</footer>
|
||||
{% include "sandbox.html" %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,655 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Bot Development - Snek Platform Documentation</title>
|
||||
<meta name="description" content="Guide to developing bots for the Snek platform. Complete examples in Python and JavaScript for building automated chat bots." />
|
||||
<link rel="stylesheet" href="/sandbox.css" />
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: 'Segoe UI',sans-serif;
|
||||
background: #111;
|
||||
color: #eee;
|
||||
line-height:1.5;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
a { color: #7ef; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }
|
||||
.page-hero {
|
||||
text-align: center;
|
||||
padding: 3rem 0 2rem 0;
|
||||
}
|
||||
.page-hero h1 {
|
||||
font-size: 2.5rem;
|
||||
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
.page-hero p {
|
||||
color: #aaa;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.section {
|
||||
background: #181818;
|
||||
border-radius: 6px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
.section h2 {
|
||||
color: #7ef;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.section h3 {
|
||||
color: #0fa;
|
||||
margin: 1.5rem 0 0.75rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.section p, .section ul {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.section ul {
|
||||
list-style: disc inside;
|
||||
}
|
||||
.section li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.section code {
|
||||
background: #222;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
color: #7ef;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
.section pre {
|
||||
background: #222;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #ccc;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.section pre .kw { color: #f05a28; }
|
||||
.section pre .fn { color: #7ef; }
|
||||
.section pre .str { color: #0fa; }
|
||||
.section pre .cm { color: #666; }
|
||||
.code-label {
|
||||
display: inline-block;
|
||||
background: #f05a28;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px 3px 0 0;
|
||||
margin-bottom: -1px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.topnav {
|
||||
width: 100%;
|
||||
background: #181818;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
|
||||
padding: 0.6rem 0;
|
||||
}
|
||||
.topnav .nav-container {
|
||||
width: 90%;
|
||||
max-width: 960px;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.topnav a {
|
||||
color: #7ef;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
transition: background 0.14s;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.topnav a:hover, .topnav a.active {
|
||||
background: #222b;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.topnav .home-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.topnav .home-link img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 5px;
|
||||
background: #222;
|
||||
}
|
||||
footer {
|
||||
width: 100%;
|
||||
background: #181818;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
padding: 1.4rem 0;
|
||||
font-size: 1rem;
|
||||
margin-top: auto;
|
||||
box-shadow: 0 -1px 8px rgba(0,0,0,0.22);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.page-hero h1 { font-size: 2rem; }
|
||||
.section { padding: 1rem; }
|
||||
.section pre { font-size: 0.7rem; }
|
||||
.topnav .nav-container {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.topnav a {
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="topnav">
|
||||
<div class="nav-container">
|
||||
<a class="home-link" href="/">
|
||||
<img src="/image/snek_logo_256x256.png" alt="Snek" />
|
||||
Snek
|
||||
</a>
|
||||
<a href="/docs.html">Docs</a>
|
||||
<a href="/architecture.html">Architecture</a>
|
||||
<a href="/api.html">API</a>
|
||||
<a href="/bots.html" class="active">Bots</a>
|
||||
<a href="/contribute.html">Contribute</a>
|
||||
<a href="/about.html">About</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header class="container page-hero">
|
||||
<h1>Bot Development</h1>
|
||||
<p>Build automated bots for the Snek platform</p>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<section class="section">
|
||||
<h2>Introduction</h2>
|
||||
<p>Snek bots are automated clients that connect via WebSocket and interact with the platform using the RPC API. Bots can:</p>
|
||||
<ul>
|
||||
<li>Monitor channels and respond to messages</li>
|
||||
<li>Send automated notifications</li>
|
||||
<li>Integrate with external services (CI/CD, monitoring, etc.)</li>
|
||||
<li>Provide AI-powered assistance</li>
|
||||
<li>Automate moderation tasks</li>
|
||||
</ul>
|
||||
<p>Bots authenticate like regular users. Create a dedicated user account for your bot.</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Protocol Overview</h2>
|
||||
<p>Bots communicate over WebSocket using a JSON-RPC style protocol:</p>
|
||||
|
||||
<h3>Connection</h3>
|
||||
<p>Connect to <code>wss://your-server/rpc.ws</code> (or <code>ws://</code> for local development).</p>
|
||||
|
||||
<h3>Request Format</h3>
|
||||
<pre>{
|
||||
"callId": "1",
|
||||
"method": "login",
|
||||
"args": ["bot_username", "bot_password"]
|
||||
}</pre>
|
||||
|
||||
<h3>Response Format</h3>
|
||||
<pre>{
|
||||
"callId": "1",
|
||||
"success": true,
|
||||
"data": { "uid": "...", "username": "bot_username", ... }
|
||||
}</pre>
|
||||
|
||||
<h3>Server Events</h3>
|
||||
<p>After login, the server pushes events for new messages:</p>
|
||||
<pre>{
|
||||
"channel_uid": "abc123",
|
||||
"event": "new_message",
|
||||
"data": {
|
||||
"uid": "msg_uid",
|
||||
"message": "Hello bot!",
|
||||
"user_uid": "sender_uid",
|
||||
"username": "sender_name"
|
||||
}
|
||||
}</pre>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Python Bot Example</h2>
|
||||
<p>A complete, working Python bot using aiohttp:</p>
|
||||
|
||||
<span class="code-label">Python</span>
|
||||
<pre># retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import aiohttp
|
||||
|
||||
|
||||
class SnekBot:
|
||||
def __init__(self, base_url, username, password):
|
||||
self.base_url = base_url
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.ws = None
|
||||
self.session = None
|
||||
self.call_id = 0
|
||||
self.pending = {}
|
||||
self.running = True
|
||||
|
||||
async def connect(self):
|
||||
self.session = aiohttp.ClientSession()
|
||||
ws_url = self.base_url.replace("https", "wss").replace("http", "ws")
|
||||
ws_url = f"{ws_url}/rpc.ws"
|
||||
self.ws = await self.session.ws_connect(ws_url, heartbeat=30)
|
||||
asyncio.create_task(self._message_loop())
|
||||
user = await self.login()
|
||||
print(f"Logged in as {user.get('username')}")
|
||||
return user
|
||||
|
||||
async def _message_loop(self):
|
||||
try:
|
||||
async for msg in self.ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
data = json.loads(msg.data)
|
||||
call_id = data.get("callId")
|
||||
if call_id and call_id in self.pending:
|
||||
self.pending[call_id].set_result(data)
|
||||
elif data.get("event") == "new_message":
|
||||
await self.on_message(data.get("data", {}))
|
||||
elif data.get("event"):
|
||||
await self.on_event(data)
|
||||
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Message loop error: {e}")
|
||||
finally:
|
||||
self.running = False
|
||||
|
||||
async def call(self, method, *args):
|
||||
self.call_id += 1
|
||||
call_id = str(self.call_id)
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
self.pending[call_id] = future
|
||||
await self.ws.send_json({
|
||||
"callId": call_id,
|
||||
"method": method,
|
||||
"args": list(args)
|
||||
})
|
||||
try:
|
||||
result = await asyncio.wait_for(future, timeout=30)
|
||||
return result.get("data")
|
||||
finally:
|
||||
self.pending.pop(call_id, None)
|
||||
|
||||
async def login(self):
|
||||
return await self.call("login", self.username, self.password)
|
||||
|
||||
async def send_message(self, channel_uid, message):
|
||||
return await self.call("send_message", channel_uid, message, True)
|
||||
|
||||
async def get_channels(self):
|
||||
return await self.call("get_channels")
|
||||
|
||||
async def on_message(self, data):
|
||||
message = data.get("message", "")
|
||||
channel_uid = data.get("channel_uid")
|
||||
username = data.get("username", "")
|
||||
|
||||
if username == self.username:
|
||||
return
|
||||
|
||||
if message.lower().startswith("!ping"):
|
||||
await self.send_message(channel_uid, "Pong!")
|
||||
|
||||
elif message.lower().startswith("!help"):
|
||||
help_text = "Available commands: !ping, !help, !time"
|
||||
await self.send_message(channel_uid, help_text)
|
||||
|
||||
elif message.lower().startswith("!time"):
|
||||
import datetime
|
||||
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
await self.send_message(channel_uid, f"Current time: {now}")
|
||||
|
||||
async def on_event(self, data):
|
||||
event = data.get("event")
|
||||
print(f"Event: {event}")
|
||||
|
||||
async def close(self):
|
||||
self.running = False
|
||||
if self.ws:
|
||||
await self.ws.close()
|
||||
if self.session:
|
||||
await self.session.close()
|
||||
|
||||
|
||||
async def main():
|
||||
bot = SnekBot(
|
||||
base_url="https://snek.community",
|
||||
username="my_bot",
|
||||
password="my_bot_password"
|
||||
)
|
||||
|
||||
try:
|
||||
await bot.connect()
|
||||
channels = await bot.get_channels()
|
||||
print(f"Member of {len(channels)} channels")
|
||||
|
||||
while bot.running:
|
||||
await asyncio.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print("Shutting down...")
|
||||
finally:
|
||||
await bot.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())</pre>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>JavaScript Bot Example</h2>
|
||||
<p>A complete Node.js bot using the ws package:</p>
|
||||
|
||||
<span class="code-label">JavaScript (Node.js)</span>
|
||||
<pre>// retoor <retoor@molodetz.nl>
|
||||
|
||||
const WebSocket = require("ws")
|
||||
|
||||
class SnekBot {
|
||||
constructor(baseUrl, username, password) {
|
||||
this.baseUrl = baseUrl
|
||||
this.username = username
|
||||
this.password = password
|
||||
this.ws = null
|
||||
this.callId = 0
|
||||
this.pending = new Map()
|
||||
this.running = true
|
||||
}
|
||||
|
||||
connect() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wsUrl = this.baseUrl
|
||||
.replace("https", "wss")
|
||||
.replace("http", "ws") + "/rpc.ws"
|
||||
|
||||
this.ws = new WebSocket(wsUrl)
|
||||
|
||||
this.ws.on("open", async () => {
|
||||
try {
|
||||
const user = await this.login()
|
||||
console.log(`Logged in as ${user.username}`)
|
||||
resolve(user)
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.on("message", (data) => {
|
||||
const parsed = JSON.parse(data.toString())
|
||||
const callId = parsed.callId
|
||||
|
||||
if (callId && this.pending.has(callId)) {
|
||||
this.pending.get(callId).resolve(parsed)
|
||||
this.pending.delete(callId)
|
||||
} else if (parsed.event === "new_message") {
|
||||
this.onMessage(parsed.data || {})
|
||||
} else if (parsed.event) {
|
||||
this.onEvent(parsed)
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.on("close", () => {
|
||||
this.running = false
|
||||
console.log("Connection closed")
|
||||
})
|
||||
|
||||
this.ws.on("error", (err) => {
|
||||
console.error("WebSocket error:", err.message)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
call(method, ...args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.callId++
|
||||
const callId = String(this.callId)
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.pending.delete(callId)
|
||||
reject(new Error("Request timeout"))
|
||||
}, 30000)
|
||||
|
||||
this.pending.set(callId, {
|
||||
resolve: (data) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(data.data)
|
||||
},
|
||||
reject
|
||||
})
|
||||
|
||||
this.ws.send(JSON.stringify({
|
||||
callId,
|
||||
method,
|
||||
args
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
login() {
|
||||
return this.call("login", this.username, this.password)
|
||||
}
|
||||
|
||||
sendMessage(channelUid, message) {
|
||||
return this.call("send_message", channelUid, message, true)
|
||||
}
|
||||
|
||||
getChannels() {
|
||||
return this.call("get_channels")
|
||||
}
|
||||
|
||||
async onMessage(data) {
|
||||
const message = data.message || ""
|
||||
const channelUid = data.channel_uid
|
||||
const username = data.username || ""
|
||||
|
||||
if (username === this.username) {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.toLowerCase().startsWith("!ping")) {
|
||||
await this.sendMessage(channelUid, "Pong!")
|
||||
} else if (message.toLowerCase().startsWith("!help")) {
|
||||
await this.sendMessage(channelUid, "Commands: !ping, !help, !time")
|
||||
} else if (message.toLowerCase().startsWith("!time")) {
|
||||
const now = new Date().toISOString()
|
||||
await this.sendMessage(channelUid, `Current time: ${now}`)
|
||||
}
|
||||
}
|
||||
|
||||
onEvent(data) {
|
||||
console.log(`Event: ${data.event}`)
|
||||
}
|
||||
|
||||
close() {
|
||||
this.running = false
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const bot = new SnekBot(
|
||||
"https://snek.community",
|
||||
"my_bot",
|
||||
"my_bot_password"
|
||||
)
|
||||
|
||||
try {
|
||||
await bot.connect()
|
||||
const channels = await bot.getChannels()
|
||||
console.log(`Member of ${channels.length} channels`)
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
console.log("Shutting down...")
|
||||
bot.close()
|
||||
process.exit(0)
|
||||
})
|
||||
} catch (e) {
|
||||
console.error("Failed to connect:", e.message)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main()</pre>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Event Handling</h2>
|
||||
<p>Bots receive various events after authentication:</p>
|
||||
|
||||
<h3>new_message</h3>
|
||||
<p>Received when a message is posted to a channel the bot is a member of.</p>
|
||||
<pre>{
|
||||
"channel_uid": "...",
|
||||
"event": "new_message",
|
||||
"data": {
|
||||
"uid": "message_uid",
|
||||
"message": "Message content",
|
||||
"user_uid": "sender_uid",
|
||||
"username": "sender_username",
|
||||
"nick": "Sender Nick",
|
||||
"created_at": "2025-01-01T12:00:00"
|
||||
}
|
||||
}</pre>
|
||||
|
||||
<h3>set_typing</h3>
|
||||
<p>Received when a user starts typing.</p>
|
||||
<pre>{
|
||||
"channel_uid": "...",
|
||||
"event": "set_typing",
|
||||
"data": {
|
||||
"user_uid": "...",
|
||||
"username": "...",
|
||||
"nick": "...",
|
||||
"color": "#7ef"
|
||||
}
|
||||
}</pre>
|
||||
|
||||
<h3>update_message_text</h3>
|
||||
<p>Received when a message is edited (non-final messages only).</p>
|
||||
<pre>{
|
||||
"channel_uid": "...",
|
||||
"event": "update_message_text",
|
||||
"data": {
|
||||
"message_uid": "...",
|
||||
"text": "Updated message content"
|
||||
}
|
||||
}</pre>
|
||||
|
||||
<h3>user_presence</h3>
|
||||
<p>Received when a user's online status changes.</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Best Practices</h2>
|
||||
|
||||
<h3>Rate Limiting</h3>
|
||||
<p>Implement rate limiting to avoid overwhelming the server:</p>
|
||||
<ul>
|
||||
<li>Limit outgoing messages to 1-2 per second per channel</li>
|
||||
<li>Batch multiple operations where possible</li>
|
||||
<li>Add delays between bulk operations</li>
|
||||
</ul>
|
||||
|
||||
<h3>Error Handling</h3>
|
||||
<p>Handle errors gracefully:</p>
|
||||
<ul>
|
||||
<li>Catch and log all exceptions</li>
|
||||
<li>Handle authentication failures specifically</li>
|
||||
<li>Validate input before sending to the API</li>
|
||||
</ul>
|
||||
|
||||
<h3>Reconnection Strategy</h3>
|
||||
<p>Implement automatic reconnection:</p>
|
||||
<ul>
|
||||
<li>Detect disconnection via heartbeat timeout</li>
|
||||
<li>Use exponential backoff (1s, 2s, 4s, 8s, max 60s)</li>
|
||||
<li>Re-authenticate after reconnection</li>
|
||||
<li>Restore subscriptions after login</li>
|
||||
</ul>
|
||||
|
||||
<h3>Resource Cleanup</h3>
|
||||
<p>Clean up resources on shutdown:</p>
|
||||
<ul>
|
||||
<li>Close WebSocket connections properly</li>
|
||||
<li>Cancel pending timers and promises</li>
|
||||
<li>Handle SIGINT/SIGTERM signals</li>
|
||||
</ul>
|
||||
|
||||
<h3>Security</h3>
|
||||
<ul>
|
||||
<li>Store credentials in environment variables, not code</li>
|
||||
<li>Use a dedicated bot account with minimal permissions</li>
|
||||
<li>Validate and sanitize user input before processing</li>
|
||||
<li>Log actions for audit purposes</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Advanced Topics</h2>
|
||||
|
||||
<h3>AI Integration</h3>
|
||||
<p>Integrate with AI services by calling external APIs when messages match certain patterns:</p>
|
||||
<pre>async def on_message(self, data):
|
||||
message = data.get("message", "")
|
||||
if message.startswith("!ask "):
|
||||
query = message[5:]
|
||||
response = await self.call_ai_api(query)
|
||||
await self.send_message(data["channel_uid"], response)</pre>
|
||||
|
||||
<h3>Scheduled Tasks</h3>
|
||||
<p>Use asyncio tasks for periodic operations:</p>
|
||||
<pre>async def daily_report(self):
|
||||
while self.running:
|
||||
await asyncio.sleep(86400) # 24 hours
|
||||
for channel in await self.get_channels():
|
||||
await self.send_message(channel["uid"], "Daily report...")</pre>
|
||||
|
||||
<h3>Multiple Bots</h3>
|
||||
<p>Run multiple bot instances for different purposes. Each bot needs its own user account and WebSocket connection.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 Snek Platform - retoor <retoor@molodetz.nl></p>
|
||||
</footer>
|
||||
{% include "sandbox.html" %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,444 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Contribute - Snek Platform Documentation</title>
|
||||
<meta name="description" content="Guide to contributing to the Snek platform. Learn the project structure, code standards, and how to add new features." />
|
||||
<link rel="stylesheet" href="/sandbox.css" />
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: 'Segoe UI',sans-serif;
|
||||
background: #111;
|
||||
color: #eee;
|
||||
line-height:1.5;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
a { color: #7ef; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }
|
||||
.page-hero {
|
||||
text-align: center;
|
||||
padding: 3rem 0 2rem 0;
|
||||
}
|
||||
.page-hero h1 {
|
||||
font-size: 2.5rem;
|
||||
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
.page-hero p {
|
||||
color: #aaa;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.section {
|
||||
background: #181818;
|
||||
border-radius: 6px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
.section h2 {
|
||||
color: #7ef;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.section h3 {
|
||||
color: #0fa;
|
||||
margin: 1.5rem 0 0.75rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.section p, .section ul, .section ol {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.section ul {
|
||||
list-style: disc inside;
|
||||
}
|
||||
.section ol {
|
||||
list-style: decimal inside;
|
||||
}
|
||||
.section li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.section code {
|
||||
background: #222;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
color: #7ef;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
.section pre {
|
||||
background: #222;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #7ef;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.dir-tree {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
padding: 1rem 1.5rem;
|
||||
margin: 1rem 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #ccc;
|
||||
}
|
||||
.dir-tree .dir { color: #7ef; }
|
||||
.dir-tree .file { color: #aaa; }
|
||||
.topnav {
|
||||
width: 100%;
|
||||
background: #181818;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
|
||||
padding: 0.6rem 0;
|
||||
}
|
||||
.topnav .nav-container {
|
||||
width: 90%;
|
||||
max-width: 960px;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.topnav a {
|
||||
color: #7ef;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
transition: background 0.14s;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.topnav a:hover, .topnav a.active {
|
||||
background: #222b;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.topnav .home-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.topnav .home-link img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 5px;
|
||||
background: #222;
|
||||
}
|
||||
footer {
|
||||
width: 100%;
|
||||
background: #181818;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
padding: 1.4rem 0;
|
||||
font-size: 1rem;
|
||||
margin-top: auto;
|
||||
box-shadow: 0 -1px 8px rgba(0,0,0,0.22);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.page-hero h1 { font-size: 2rem; }
|
||||
.section { padding: 1rem; }
|
||||
.topnav .nav-container {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.topnav a {
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="topnav">
|
||||
<div class="nav-container">
|
||||
<a class="home-link" href="/">
|
||||
<img src="/image/snek_logo_256x256.png" alt="Snek" />
|
||||
Snek
|
||||
</a>
|
||||
<a href="/docs.html">Docs</a>
|
||||
<a href="/architecture.html">Architecture</a>
|
||||
<a href="/api.html">API</a>
|
||||
<a href="/bots.html">Bots</a>
|
||||
<a href="/contribute.html" class="active">Contribute</a>
|
||||
<a href="/about.html">About</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header class="container page-hero">
|
||||
<h1>Contribute</h1>
|
||||
<p>How to contribute to the Snek platform</p>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<section class="section">
|
||||
<h2>Getting Started</h2>
|
||||
|
||||
<h3>Clone the Repository</h3>
|
||||
<pre>git clone https://retoor.molodetz.nl/retoor/snek.git
|
||||
cd snek</pre>
|
||||
|
||||
<h3>Setup Development Environment</h3>
|
||||
<pre>make install</pre>
|
||||
<p>This creates a virtual environment and installs all dependencies.</p>
|
||||
|
||||
<h3>Run Locally</h3>
|
||||
<pre>make run</pre>
|
||||
<p>The server starts at <code>http://localhost:8080</code> by default.</p>
|
||||
|
||||
<h3>Run Tests</h3>
|
||||
<pre>pytest tests/</pre>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Project Structure</h2>
|
||||
|
||||
<div class="dir-tree">
|
||||
<span class="dir">src/snek/</span><br>
|
||||
<span class="file">__main__.py</span> CLI entry point<br>
|
||||
<span class="file">app.py</span> Application setup, routing<br>
|
||||
<span class="dir">view/</span> HTTP/WebSocket handlers<br>
|
||||
<span class="file">index.py</span> Landing page<br>
|
||||
<span class="file">rpc.py</span> WebSocket RPC<br>
|
||||
<span class="dir">settings/</span> Settings views<br>
|
||||
<span class="dir">service/</span> Business logic layer<br>
|
||||
<span class="file">user.py</span> User service<br>
|
||||
<span class="file">channel.py</span> Channel service<br>
|
||||
<span class="file">chat.py</span> Chat service<br>
|
||||
<span class="dir">mapper/</span> Data persistence<br>
|
||||
<span class="dir">model/</span> Data models<br>
|
||||
<span class="dir">form/</span> Form definitions<br>
|
||||
<span class="dir">system/</span> Core infrastructure<br>
|
||||
<span class="file">view.py</span> BaseView class<br>
|
||||
<span class="file">service.py</span> BaseService class<br>
|
||||
<span class="file">mapper.py</span> BaseMapper class<br>
|
||||
<span class="file">model.py</span> BaseModel class<br>
|
||||
<span class="dir">static/</span> Frontend JS and CSS<br>
|
||||
<span class="file">app.js</span> Application singleton<br>
|
||||
<span class="file">socket.js</span> WebSocket client<br>
|
||||
<span class="dir">templates/</span> Jinja2 templates<br>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Code Standards</h2>
|
||||
|
||||
<h3>Author Attribution</h3>
|
||||
<p>Every file must include the author tag at the top:</p>
|
||||
<pre># retoor <retoor@molodetz.nl></pre>
|
||||
<p>For JavaScript:</p>
|
||||
<pre>// retoor <retoor@molodetz.nl></pre>
|
||||
|
||||
<h3>No Comments</h3>
|
||||
<p>Code should be self-documenting. If you need to explain something, refactor the code to be clearer. Comments indicate code that needs improvement.</p>
|
||||
|
||||
<h3>No JavaScript Frameworks</h3>
|
||||
<p>The frontend uses vanilla ES6 JavaScript only. No React, Vue, Angular, or similar frameworks. Use Custom Elements for component encapsulation.</p>
|
||||
|
||||
<h3>Explicit Over Implicit</h3>
|
||||
<p>Prefer explicit code paths. No magic imports, no hidden side effects, no implicit type conversions.</p>
|
||||
|
||||
<h3>Variable Naming</h3>
|
||||
<p>Use clear, descriptive names. Avoid abbreviations except for common conventions (uid, url, etc.). No suffixes like <code>_super</code> or <code>_refactored</code>.</p>
|
||||
|
||||
<h3>Module Organization</h3>
|
||||
<p>JavaScript: one class per file. Python: related classes can share a file, but keep files focused. Use directories to group related modules.</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Adding a New View</h2>
|
||||
<p>Views handle HTTP requests and render responses.</p>
|
||||
|
||||
<h3>1. Create the View Class</h3>
|
||||
<pre># retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.view import BaseView
|
||||
|
||||
|
||||
class MyFeatureView(BaseView):
|
||||
login_required = True
|
||||
|
||||
async def get(self):
|
||||
data = await self.services.my_service.get_data()
|
||||
return await self.render_template("my_feature.html", {"data": data})</pre>
|
||||
|
||||
<h3>2. Create the Template</h3>
|
||||
<p>Add a template at <code>templates/my_feature.html</code>:</p>
|
||||
<pre>{% raw %}{% extends "base.html" %}
|
||||
|
||||
{% block title %}My Feature{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="container">
|
||||
<h1>My Feature</h1>
|
||||
<p>{{ data }}</p>
|
||||
</div>
|
||||
{% endblock %}{% endraw %}</pre>
|
||||
|
||||
<h3>3. Register the Route</h3>
|
||||
<p>In <code>app.py</code> setup_router method:</p>
|
||||
<pre>from snek.view.my_feature import MyFeatureView
|
||||
|
||||
self.router.add_view("/my-feature.html", MyFeatureView)</pre>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Adding a New Service</h2>
|
||||
<p>Services contain business logic and are accessed via <code>app.services.name</code>.</p>
|
||||
|
||||
<h3>1. Create the Service Class</h3>
|
||||
<pre># retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.service import BaseService
|
||||
|
||||
|
||||
class MyService(BaseService):
|
||||
mapper_class = None
|
||||
|
||||
async def process_data(self, data):
|
||||
return {"processed": data}</pre>
|
||||
|
||||
<h3>2. Register the Service</h3>
|
||||
<p>Services are auto-discovered from the <code>service/</code> directory. The service name is derived from the filename (e.g., <code>my_service.py</code> becomes <code>app.services.my_service</code>).</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Adding RPC Methods</h2>
|
||||
<p>RPC methods are defined in the <code>RPCApi</code> class in <code>view/rpc.py</code>.</p>
|
||||
|
||||
<h3>Add the Method</h3>
|
||||
<pre>async def my_method(self, param1, param2=None):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
|
||||
if not param1 or not isinstance(param1, str):
|
||||
raise ValueError("Invalid param1")
|
||||
|
||||
result = await self.services.my_service.process(param1, param2)
|
||||
return result</pre>
|
||||
|
||||
<h3>Method Guidelines</h3>
|
||||
<ul>
|
||||
<li>Use <code>self._require_login()</code> for authenticated methods</li>
|
||||
<li>Use <code>self._require_services()</code> to ensure services are available</li>
|
||||
<li>Validate all input parameters</li>
|
||||
<li>Return serializable data (dicts, lists, primitives)</li>
|
||||
<li>Raise <code>ValueError</code> for invalid input</li>
|
||||
<li>Raise <code>PermissionError</code> for authorization failures</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Adding Frontend Components</h2>
|
||||
<p>Frontend components use Custom Elements.</p>
|
||||
|
||||
<h3>1. Create the Component</h3>
|
||||
<pre>// retoor <retoor@molodetz.nl>
|
||||
|
||||
class MyComponent extends HTMLElement {
|
||||
constructor() {
|
||||
super()
|
||||
this.data = null
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render()
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div class="my-component">
|
||||
<h2>My Component</h2>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("my-component", MyComponent)
|
||||
|
||||
export { MyComponent }</pre>
|
||||
|
||||
<h3>2. Create the CSS</h3>
|
||||
<p>Add styles in a separate CSS file (no Shadow DOM):</p>
|
||||
<pre>.my-component {
|
||||
background: #1a1a1a;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.my-component h2 {
|
||||
color: #7ef;
|
||||
}</pre>
|
||||
|
||||
<h3>3. Import in the Template</h3>
|
||||
<pre><script type="module" src="/my-component.js"></script>
|
||||
<link rel="stylesheet" href="/my-component.css"></pre>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Testing</h2>
|
||||
|
||||
<h3>Running Tests</h3>
|
||||
<pre>pytest tests/ # All tests
|
||||
pytest tests/test_user.py # Single file
|
||||
pytest tests/test_user.py::test_login # Single test
|
||||
pytest -m unit # Unit tests only
|
||||
pytest -m integration # Integration tests</pre>
|
||||
|
||||
<h3>Writing Tests</h3>
|
||||
<pre># retoor <retoor@molodetz.nl>
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_my_feature():
|
||||
result = await my_function()
|
||||
assert result is not None</pre>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Pull Request Guidelines</h2>
|
||||
|
||||
<ol>
|
||||
<li>Create a feature branch from <code>main</code></li>
|
||||
<li>Make focused, atomic commits</li>
|
||||
<li>Write clear commit messages</li>
|
||||
<li>Ensure all tests pass</li>
|
||||
<li>Follow the code standards</li>
|
||||
<li>Update documentation if needed</li>
|
||||
<li>Submit PR with description of changes</li>
|
||||
</ol>
|
||||
|
||||
<h3>Commit Message Format</h3>
|
||||
<pre>feat: add user profile editing
|
||||
fix: resolve channel subscription issue
|
||||
docs: update API documentation
|
||||
refactor: simplify message handling</pre>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 Snek Platform - retoor <retoor@molodetz.nl></p>
|
||||
</footer>
|
||||
{% include "sandbox.html" %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,320 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Design Decisions - Snek Platform Documentation</title>
|
||||
<meta name="description" content="Design philosophy and technical decisions behind the Snek platform. Learn why we chose specific technologies and architectural patterns." />
|
||||
<link rel="stylesheet" href="/sandbox.css" />
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: 'Segoe UI',sans-serif;
|
||||
background: #111;
|
||||
color: #eee;
|
||||
line-height:1.5;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
a { color: #7ef; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }
|
||||
.page-hero {
|
||||
text-align: center;
|
||||
padding: 3rem 0 2rem 0;
|
||||
}
|
||||
.page-hero h1 {
|
||||
font-size: 2.5rem;
|
||||
background: linear-gradient(90deg,#7ef 0%,#0fa 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
.page-hero p {
|
||||
color: #aaa;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.section {
|
||||
background: #181818;
|
||||
border-radius: 6px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
.section h2 {
|
||||
color: #7ef;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.section h3 {
|
||||
color: #0fa;
|
||||
margin: 1.5rem 0 0.75rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.section p, .section ul {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.section ul {
|
||||
list-style: disc inside;
|
||||
}
|
||||
.section li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.section code {
|
||||
background: #222;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
color: #7ef;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
.decision-box {
|
||||
background: #1a1a1a;
|
||||
border-left: 3px solid #f05a28;
|
||||
padding: 1rem 1.25rem;
|
||||
margin: 1rem 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
.decision-box strong {
|
||||
color: #f05a28;
|
||||
}
|
||||
.topnav {
|
||||
width: 100%;
|
||||
background: #181818;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
|
||||
padding: 0.6rem 0;
|
||||
}
|
||||
.topnav .nav-container {
|
||||
width: 90%;
|
||||
max-width: 960px;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.topnav a {
|
||||
color: #7ef;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
transition: background 0.14s;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.topnav a:hover, .topnav a.active {
|
||||
background: #222b;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.topnav .home-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.topnav .home-link img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 5px;
|
||||
background: #222;
|
||||
}
|
||||
footer {
|
||||
width: 100%;
|
||||
background: #181818;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
padding: 1.4rem 0;
|
||||
font-size: 1rem;
|
||||
margin-top: auto;
|
||||
box-shadow: 0 -1px 8px rgba(0,0,0,0.22);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.page-hero h1 { font-size: 2rem; }
|
||||
.section { padding: 1rem; }
|
||||
.topnav .nav-container {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.topnav a {
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="topnav">
|
||||
<div class="nav-container">
|
||||
<a class="home-link" href="/">
|
||||
<img src="/image/snek_logo_256x256.png" alt="Snek" />
|
||||
Snek
|
||||
</a>
|
||||
<a href="/docs.html">Docs</a>
|
||||
<a href="/architecture.html">Architecture</a>
|
||||
<a href="/api.html">API</a>
|
||||
<a href="/bots.html">Bots</a>
|
||||
<a href="/contribute.html">Contribute</a>
|
||||
<a href="/about.html">About</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header class="container page-hero">
|
||||
<h1>Design Decisions</h1>
|
||||
<p>Philosophy and technical choices that shape the Snek platform</p>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<section class="section">
|
||||
<h2>Core Philosophy</h2>
|
||||
<p>Snek is built on a set of guiding principles that inform every technical and design decision:</p>
|
||||
<ul>
|
||||
<li><strong>Privacy First:</strong> No tracking, no analytics, no email required. User data belongs to users.</li>
|
||||
<li><strong>Performance Focus:</strong> Fast is a feature. Minimal dependencies, optimized code paths.</li>
|
||||
<li><strong>Simplicity Over Complexity:</strong> Prefer straightforward solutions. Complexity must justify itself.</li>
|
||||
<li><strong>Self-Hosting First:</strong> The platform should be trivial to deploy and maintain independently.</li>
|
||||
<li><strong>No Framework Lock-in:</strong> Avoid dependencies that impose architectural constraints.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Technical Decisions</h2>
|
||||
|
||||
<h3>Why aiohttp Over Flask/Django</h3>
|
||||
<div class="decision-box">
|
||||
<strong>Decision:</strong> Use aiohttp as the web framework.
|
||||
</div>
|
||||
<p>Rationale:</p>
|
||||
<ul>
|
||||
<li>Native async/await support without ASGI adapters</li>
|
||||
<li>Built-in WebSocket support with no additional libraries</li>
|
||||
<li>Lower memory footprint than Django</li>
|
||||
<li>No ORM assumptions or admin interface overhead</li>
|
||||
<li>Direct control over request/response lifecycle</li>
|
||||
</ul>
|
||||
|
||||
<h3>Why Dataset ORM Over SQLAlchemy</h3>
|
||||
<div class="decision-box">
|
||||
<strong>Decision:</strong> Use Dataset for database access.
|
||||
</div>
|
||||
<p>Rationale:</p>
|
||||
<ul>
|
||||
<li>Zero configuration required for basic operations</li>
|
||||
<li>Dictionary-based interface matches JSON API patterns</li>
|
||||
<li>Automatic table creation simplifies development</li>
|
||||
<li>Raw SQL available when needed for complex queries</li>
|
||||
<li>Significantly less boilerplate than SQLAlchemy models</li>
|
||||
</ul>
|
||||
|
||||
<h3>Why Vanilla JavaScript Over React/Vue</h3>
|
||||
<div class="decision-box">
|
||||
<strong>Decision:</strong> Use vanilla ES6 JavaScript with no frameworks.
|
||||
</div>
|
||||
<p>Rationale:</p>
|
||||
<ul>
|
||||
<li>No build step required - direct browser execution</li>
|
||||
<li>No framework version upgrades or breaking changes</li>
|
||||
<li>Smaller payload - no framework runtime overhead</li>
|
||||
<li>Easier debugging - no virtual DOM abstraction</li>
|
||||
<li>Custom Elements provide component encapsulation</li>
|
||||
<li>Direct DOM access for performance-critical paths</li>
|
||||
</ul>
|
||||
|
||||
<h3>Why SQLite as Default Database</h3>
|
||||
<div class="decision-box">
|
||||
<strong>Decision:</strong> Use SQLite as the default database.
|
||||
</div>
|
||||
<p>Rationale:</p>
|
||||
<ul>
|
||||
<li>Zero configuration deployment - single file database</li>
|
||||
<li>No separate database server process required</li>
|
||||
<li>Excellent read performance for chat workloads</li>
|
||||
<li>Easy backup - copy a single file</li>
|
||||
<li>PostgreSQL available for high-write scenarios</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Security Decisions</h2>
|
||||
|
||||
<h3>No IP Logging</h3>
|
||||
<div class="decision-box">
|
||||
<strong>Decision:</strong> Do not log IP addresses or user activity.
|
||||
</div>
|
||||
<p>Snek does not record IP addresses, request logs, or user behavior patterns. This is a fundamental privacy commitment, not a configuration option.</p>
|
||||
|
||||
<h3>No Email Required</h3>
|
||||
<div class="decision-box">
|
||||
<strong>Decision:</strong> Registration requires only username and password.
|
||||
</div>
|
||||
<p>Email is optional. Users can register and participate without providing any personally identifiable information. Password recovery relies on administrator assistance.</p>
|
||||
|
||||
<h3>Session Handling</h3>
|
||||
<p>Sessions use secure, HTTP-only cookies with server-side storage. Session data is encrypted and expires after configurable inactivity periods.</p>
|
||||
|
||||
<h3>Password Storage</h3>
|
||||
<p>Passwords are hashed using SHA-256 with a configurable salt. The plain password is never stored or logged.</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>UI/UX Decisions</h2>
|
||||
|
||||
<h3>Dark Theme Default</h3>
|
||||
<div class="decision-box">
|
||||
<strong>Decision:</strong> Use dark theme as the default and primary design.
|
||||
</div>
|
||||
<p>The dark theme reduces eye strain for extended use, is preferred by developers, and provides better contrast for code display.</p>
|
||||
|
||||
<h3>Card-Based Layouts</h3>
|
||||
<p>Content is organized in cards with consistent padding, border-radius, and shadow. Cards provide visual grouping and work well across screen sizes.</p>
|
||||
|
||||
<h3>Minimal Animation</h3>
|
||||
<p>Animations are limited to subtle transitions (hover states, menu opens). No gratuitous motion that could distract or slow down interaction.</p>
|
||||
|
||||
<h3>Typography</h3>
|
||||
<ul>
|
||||
<li>Primary font: Segoe UI (system font stack fallback)</li>
|
||||
<li>Code font: Courier New (monospace)</li>
|
||||
<li>Base size: 16px with 1.5 line height</li>
|
||||
<li>Color palette: #eee (text), #7ef (links/accents), #0fa (secondary)</li>
|
||||
</ul>
|
||||
|
||||
<h3>No Component Shadow DOM</h3>
|
||||
<div class="decision-box">
|
||||
<strong>Decision:</strong> Custom Elements use light DOM with separate CSS files.
|
||||
</div>
|
||||
<p>Each component has its own CSS file that integrates with the global stylesheet. This allows consistent theming and easier debugging compared to Shadow DOM isolation.</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Code Standards</h2>
|
||||
|
||||
<h3>No Comments</h3>
|
||||
<p>Code should be self-documenting through clear naming and structure. Comments indicate code that needs refactoring.</p>
|
||||
|
||||
<h3>Author Attribution</h3>
|
||||
<p>Every file includes the author tag at the top:</p>
|
||||
<code># retoor <retoor@molodetz.nl></code>
|
||||
|
||||
<h3>Modular Organization</h3>
|
||||
<p>Code is organized into small, focused modules. One class per file for JavaScript. Related functionality grouped in directories.</p>
|
||||
|
||||
<h3>Explicit Over Implicit</h3>
|
||||
<p>Prefer explicit code paths over magic behavior. No auto-imports, no implicit conversions, no hidden side effects.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 Snek Platform - retoor <retoor@molodetz.nl></p>
|
||||
</footer>
|
||||
{% include "sandbox.html" %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,34 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.view.admin.index import AdminIndexView
|
||||
from snek.view.admin.users import AdminUsersIndexView, AdminUserEditView, AdminUserBanView
|
||||
from snek.view.admin.channels import AdminChannelsIndexView, AdminChannelMembersView, AdminChannelEditView, AdminChannelClearView, AdminChannelDeleteView
|
||||
from snek.view.admin.messages import AdminMessagesIndexView
|
||||
from snek.view.admin.forums import AdminForumsIndexView, AdminThreadsView, AdminPostsView
|
||||
from snek.view.admin.files import AdminFilesIndexView, AdminDriveItemsView, AdminRepositoriesView
|
||||
from snek.view.admin.notifications import AdminNotificationsIndexView, AdminNotificationCreateView
|
||||
from snek.view.admin.system import AdminSystemIndexView, AdminKVView, AdminPushView
|
||||
|
||||
__all__ = [
|
||||
"AdminIndexView",
|
||||
"AdminUsersIndexView",
|
||||
"AdminUserEditView",
|
||||
"AdminUserBanView",
|
||||
"AdminChannelsIndexView",
|
||||
"AdminChannelMembersView",
|
||||
"AdminChannelEditView",
|
||||
"AdminChannelClearView",
|
||||
"AdminChannelDeleteView",
|
||||
"AdminMessagesIndexView",
|
||||
"AdminForumsIndexView",
|
||||
"AdminThreadsView",
|
||||
"AdminPostsView",
|
||||
"AdminFilesIndexView",
|
||||
"AdminDriveItemsView",
|
||||
"AdminRepositoriesView",
|
||||
"AdminNotificationsIndexView",
|
||||
"AdminNotificationCreateView",
|
||||
"AdminSystemIndexView",
|
||||
"AdminKVView",
|
||||
"AdminPushView",
|
||||
]
|
||||
@@ -1,24 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.system.view import BaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminBaseView(BaseView):
|
||||
|
||||
login_required = True
|
||||
|
||||
async def _iter(self):
|
||||
if not self.session.get("logged_in") or not self.session.get("uid"):
|
||||
logger.warning("Unauthorized admin access attempt: not logged in")
|
||||
return web.HTTPFound("/login.html")
|
||||
user = await self.services.user.get(uid=self.session.get("uid"))
|
||||
if not user or not user["is_admin"]:
|
||||
logger.warning(f"Forbidden admin access attempt by user {self.session.get('uid')}")
|
||||
return web.HTTPForbidden(text="Admin access required")
|
||||
return await super()._iter()
|
||||
@@ -1,148 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminChannelsIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin channels list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
search = self.request.query.get("search", "")
|
||||
result = await self.services.admin.list_channels(page=page, search=search if search else None)
|
||||
return await self.render_template(
|
||||
"admin/channels/index.html",
|
||||
{
|
||||
"channels": result["channels"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
"search": search,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdminChannelMembersView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel members accessed for {channel_uid} by {self.session.get('uid')}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
return web.HTTPNotFound(text="Channel not found")
|
||||
members = await self.services.admin.get_channel_members(channel_uid)
|
||||
return await self.render_template(
|
||||
"admin/channels/members.html",
|
||||
{
|
||||
"channel": channel,
|
||||
"members": members,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdminChannelEditView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel edit accessed for {channel_uid} by {self.session.get('uid')}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
return web.HTTPNotFound(text="Channel not found")
|
||||
return await self.render_template(
|
||||
"admin/channels/edit.html",
|
||||
{"channel": channel}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel edit POST for {channel_uid} by {self.session.get('uid')}")
|
||||
try:
|
||||
data = await self.request.post()
|
||||
updates = {}
|
||||
if "name" in data:
|
||||
updates["name"] = data["name"]
|
||||
if "description" in data:
|
||||
updates["description"] = data["description"]
|
||||
if "is_private" in data:
|
||||
updates["is_private"] = data["is_private"] == "on"
|
||||
await self.services.admin.update_channel(
|
||||
admin_uid=self.session.get("uid"),
|
||||
channel_uid=channel_uid,
|
||||
updates=updates
|
||||
)
|
||||
return web.HTTPFound("/admin/channels/index.html")
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Channel update error: {ex}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
return await self.render_template(
|
||||
"admin/channels/edit.html",
|
||||
{"channel": channel, "error": str(ex)}
|
||||
)
|
||||
|
||||
|
||||
class AdminChannelClearView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel clear accessed for {channel_uid} by {self.session.get('uid')}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
return web.HTTPNotFound(text="Channel not found")
|
||||
return await self.render_template(
|
||||
"admin/channels/clear.html",
|
||||
{"channel": channel}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel clear POST for {channel_uid} by {self.session.get('uid')}")
|
||||
try:
|
||||
result = await self.services.admin.clear_channel_history(
|
||||
admin_uid=self.session.get("uid"),
|
||||
channel_uid=channel_uid
|
||||
)
|
||||
return web.HTTPFound(f"/admin/channels/index.html?cleared={result['deleted_count']}")
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Channel clear error: {ex}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
return await self.render_template(
|
||||
"admin/channels/clear.html",
|
||||
{"channel": channel, "error": str(ex)}
|
||||
)
|
||||
|
||||
|
||||
class AdminChannelDeleteView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel delete accessed for {channel_uid} by {self.session.get('uid')}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
return web.HTTPNotFound(text="Channel not found")
|
||||
return await self.render_template(
|
||||
"admin/channels/delete.html",
|
||||
{"channel": channel}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel delete POST for {channel_uid} by {self.session.get('uid')}")
|
||||
try:
|
||||
await self.services.admin.delete_channel(
|
||||
admin_uid=self.session.get("uid"),
|
||||
channel_uid=channel_uid
|
||||
)
|
||||
return web.HTTPFound("/admin/channels/index.html")
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Channel delete error: {ex}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
return await self.render_template(
|
||||
"admin/channels/delete.html",
|
||||
{"channel": channel, "error": str(ex)}
|
||||
)
|
||||
@@ -1,110 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminFilesIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin files list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
user_uid = self.request.query.get("user_uid", "")
|
||||
result = await self.services.admin.list_drives(
|
||||
user_uid=user_uid if user_uid else None,
|
||||
page=page
|
||||
)
|
||||
users_result = await self.services.admin.list_users(per_page=100)
|
||||
return await self.render_template(
|
||||
"admin/files/index.html",
|
||||
{
|
||||
"drives": result["drives"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
"user_uid": user_uid,
|
||||
"users": users_result["users"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdminDriveItemsView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
drive_uid = self.request.match_info.get("drive_uid")
|
||||
logger.info(f"Admin drive items accessed for {drive_uid} by {self.session.get('uid')}")
|
||||
drive = await self.services.drive.get(uid=drive_uid)
|
||||
if not drive:
|
||||
return web.HTTPNotFound(text="Drive not found")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
result = await self.services.admin.list_drive_items(drive_uid=drive_uid, page=page)
|
||||
user = await self.services.user.get(uid=drive["user_uid"])
|
||||
return await self.render_template(
|
||||
"admin/files/items.html",
|
||||
{
|
||||
"drive": drive,
|
||||
"user": user,
|
||||
"items": result["items"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
drive_uid = self.request.match_info.get("drive_uid")
|
||||
logger.info(f"Admin drive item delete POST for {drive_uid} by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
item_uid = data.get("item_uid")
|
||||
if item_uid:
|
||||
try:
|
||||
await self.services.admin.delete_drive_item(
|
||||
admin_uid=self.session.get("uid"),
|
||||
item_uid=item_uid
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Drive item delete error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
|
||||
|
||||
class AdminRepositoriesView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin repositories list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
user_uid = self.request.query.get("user_uid", "")
|
||||
result = await self.services.admin.list_repositories(
|
||||
user_uid=user_uid if user_uid else None,
|
||||
page=page
|
||||
)
|
||||
users_result = await self.services.admin.list_users(per_page=100)
|
||||
return await self.render_template(
|
||||
"admin/files/repositories.html",
|
||||
{
|
||||
"repositories": result["repositories"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
"user_uid": user_uid,
|
||||
"users": users_result["users"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin repository delete POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
repo_uid = data.get("repo_uid")
|
||||
if repo_uid:
|
||||
try:
|
||||
await self.services.admin.delete_repository(
|
||||
admin_uid=self.session.get("uid"),
|
||||
repo_uid=repo_uid
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Repository delete error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
@@ -1,139 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminForumsIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin forums list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
result = await self.services.admin.list_forums(page=page)
|
||||
return await self.render_template(
|
||||
"admin/forums/index.html",
|
||||
{
|
||||
"forums": result["forums"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin forum action POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
forum_uid = data.get("forum_uid")
|
||||
action = data.get("action")
|
||||
if forum_uid and action:
|
||||
try:
|
||||
if action == "delete":
|
||||
await self.services.admin.delete_forum(
|
||||
admin_uid=self.session.get("uid"),
|
||||
forum_uid=forum_uid
|
||||
)
|
||||
elif action == "toggle_active":
|
||||
forum = await self.services.forum.get(uid=forum_uid)
|
||||
if forum:
|
||||
await self.services.admin.update_forum(
|
||||
admin_uid=self.session.get("uid"),
|
||||
forum_uid=forum_uid,
|
||||
updates={"is_active": not forum.get("is_active", True)}
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Forum action error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
|
||||
|
||||
class AdminThreadsView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
forum_uid = self.request.match_info.get("forum_uid")
|
||||
logger.info(f"Admin threads list accessed for forum {forum_uid} by {self.session.get('uid')}")
|
||||
forum = await self.services.forum.get(uid=forum_uid)
|
||||
if not forum:
|
||||
return web.HTTPNotFound(text="Forum not found")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
result = await self.services.admin.list_threads(forum_uid=forum_uid, page=page)
|
||||
return await self.render_template(
|
||||
"admin/forums/threads.html",
|
||||
{
|
||||
"forum": forum,
|
||||
"threads": result["threads"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
forum_uid = self.request.match_info.get("forum_uid")
|
||||
logger.info(f"Admin thread action POST for forum {forum_uid} by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
thread_uid = data.get("thread_uid")
|
||||
action = data.get("action")
|
||||
if thread_uid and action:
|
||||
try:
|
||||
if action == "delete":
|
||||
await self.services.admin.delete_thread(
|
||||
admin_uid=self.session.get("uid"),
|
||||
thread_uid=thread_uid
|
||||
)
|
||||
elif action == "toggle_pin":
|
||||
await self.services.admin.toggle_thread_pin(
|
||||
admin_uid=self.session.get("uid"),
|
||||
thread_uid=thread_uid
|
||||
)
|
||||
elif action == "toggle_lock":
|
||||
await self.services.admin.toggle_thread_lock(
|
||||
admin_uid=self.session.get("uid"),
|
||||
thread_uid=thread_uid
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Thread action error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
|
||||
|
||||
class AdminPostsView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
thread_uid = self.request.match_info.get("thread_uid")
|
||||
logger.info(f"Admin posts list accessed for thread {thread_uid} by {self.session.get('uid')}")
|
||||
thread = await self.services.thread.get(uid=thread_uid)
|
||||
if not thread:
|
||||
return web.HTTPNotFound(text="Thread not found")
|
||||
forum = await self.services.forum.get(uid=thread["forum_uid"])
|
||||
page = int(self.request.query.get("page", 1))
|
||||
result = await self.services.admin.list_posts(thread_uid=thread_uid, page=page)
|
||||
return await self.render_template(
|
||||
"admin/forums/posts.html",
|
||||
{
|
||||
"forum": forum,
|
||||
"thread": thread,
|
||||
"posts": result["posts"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
thread_uid = self.request.match_info.get("thread_uid")
|
||||
logger.info(f"Admin post action POST for thread {thread_uid} by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
post_uid = data.get("post_uid")
|
||||
action = data.get("action")
|
||||
if post_uid and action == "delete":
|
||||
try:
|
||||
await self.services.admin.delete_post(
|
||||
admin_uid=self.session.get("uid"),
|
||||
post_uid=post_uid
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Post delete error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
@@ -1,22 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin dashboard accessed by user {self.session.get('uid')}")
|
||||
stats = await self.services.admin.get_dashboard_stats()
|
||||
activity = await self.services.admin.get_recent_activity(limit=10)
|
||||
return await self.render_template(
|
||||
"admin/index.html",
|
||||
{
|
||||
"stats": stats,
|
||||
"activity": activity,
|
||||
}
|
||||
)
|
||||
@@ -1,55 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminMessagesIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin messages list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
query = self.request.query.get("query", "")
|
||||
channel_uid = self.request.query.get("channel_uid", "")
|
||||
user_uid = self.request.query.get("user_uid", "")
|
||||
result = await self.services.admin.search_messages(
|
||||
query=query if query else None,
|
||||
channel_uid=channel_uid if channel_uid else None,
|
||||
user_uid=user_uid if user_uid else None,
|
||||
page=page
|
||||
)
|
||||
channels_result = await self.services.admin.list_channels(per_page=100)
|
||||
users_result = await self.services.admin.list_users(per_page=100)
|
||||
return await self.render_template(
|
||||
"admin/messages/index.html",
|
||||
{
|
||||
"messages": result["messages"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
"query": query,
|
||||
"channel_uid": channel_uid,
|
||||
"user_uid": user_uid,
|
||||
"channels": channels_result["channels"],
|
||||
"users": users_result["users"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin message delete POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
message_uid = data.get("message_uid")
|
||||
if message_uid:
|
||||
try:
|
||||
await self.services.admin.delete_message(
|
||||
admin_uid=self.session.get("uid"),
|
||||
message_uid=message_uid
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Message delete error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
@@ -1,58 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminNotificationsIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin notifications list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
result = await self.services.admin.list_notifications(page=page)
|
||||
return await self.render_template(
|
||||
"admin/notifications/index.html",
|
||||
{
|
||||
"notifications": result["notifications"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdminNotificationCreateView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin notification create accessed by user {self.session.get('uid')}")
|
||||
users_result = await self.services.admin.list_users(per_page=200)
|
||||
return await self.render_template(
|
||||
"admin/notifications/create.html",
|
||||
{"users": users_result["users"]}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin notification create POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
message = data.get("message", "").strip()
|
||||
target = data.get("target", "all")
|
||||
user_uids = data.getall("user_uids", []) if target == "selected" else None
|
||||
try:
|
||||
result = await self.services.admin.mass_notify(
|
||||
admin_uid=self.session.get("uid"),
|
||||
message=message,
|
||||
user_uids=user_uids
|
||||
)
|
||||
return web.HTTPFound(f"/admin/notifications/index.html?sent={result['sent_count']}")
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Mass notify error: {ex}")
|
||||
users_result = await self.services.admin.list_users(per_page=200)
|
||||
return await self.render_template(
|
||||
"admin/notifications/create.html",
|
||||
{"users": users_result["users"], "error": str(ex), "message": message}
|
||||
)
|
||||
@@ -1,117 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminSystemIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin system index accessed by user {self.session.get('uid')}")
|
||||
stats = await self.services.admin.get_dashboard_stats()
|
||||
return await self.render_template(
|
||||
"admin/system/index.html",
|
||||
{
|
||||
"stats": stats,
|
||||
"uptime": self.app.uptime,
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin system action POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
action = data.get("action")
|
||||
message = None
|
||||
if action == "maintenance":
|
||||
try:
|
||||
result = await self.services.admin.run_maintenance(
|
||||
admin_uid=self.session.get("uid")
|
||||
)
|
||||
message = result.get("message", "Maintenance completed")
|
||||
except Exception as ex:
|
||||
logger.warning(f"Maintenance error: {ex}")
|
||||
message = f"Error: {ex}"
|
||||
stats = await self.services.admin.get_dashboard_stats()
|
||||
return await self.render_template(
|
||||
"admin/system/index.html",
|
||||
{
|
||||
"stats": stats,
|
||||
"uptime": self.app.uptime,
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdminKVView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin KV store accessed by user {self.session.get('uid')}")
|
||||
entries = await self.services.admin.get_kv_entries()
|
||||
return await self.render_template(
|
||||
"admin/system/kv.html",
|
||||
{"entries": entries}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin KV store POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
action = data.get("action")
|
||||
try:
|
||||
if action == "set":
|
||||
key = data.get("key", "").strip()
|
||||
value = data.get("value", "").strip()
|
||||
await self.services.admin.set_kv_entry(
|
||||
admin_uid=self.session.get("uid"),
|
||||
key=key,
|
||||
value=value
|
||||
)
|
||||
elif action == "delete":
|
||||
key = data.get("key", "").strip()
|
||||
await self.services.admin.delete_kv_entry(
|
||||
admin_uid=self.session.get("uid"),
|
||||
key=key
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"KV store error: {ex}")
|
||||
entries = await self.services.admin.get_kv_entries()
|
||||
return await self.render_template(
|
||||
"admin/system/kv.html",
|
||||
{"entries": entries, "error": str(ex)}
|
||||
)
|
||||
return web.HTTPFound("/admin/system/kv.html")
|
||||
|
||||
|
||||
class AdminPushView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin push registrations accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
result = await self.services.admin.list_push_registrations(page=page)
|
||||
return await self.render_template(
|
||||
"admin/system/push.html",
|
||||
{
|
||||
"registrations": result["registrations"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin push registration delete POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
registration_uid = data.get("registration_uid")
|
||||
if registration_uid:
|
||||
try:
|
||||
await self.services.admin.delete_push_registration(
|
||||
admin_uid=self.session.get("uid"),
|
||||
registration_uid=registration_uid
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Push registration delete error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
@@ -1,109 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminUsersIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin users list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
search = self.request.query.get("search", "")
|
||||
result = await self.services.admin.list_users(page=page, search=search if search else None)
|
||||
return await self.render_template(
|
||||
"admin/users/index.html",
|
||||
{
|
||||
"users": result["users"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
"search": search,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdminUserEditView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
user_uid = self.request.match_info.get("user_uid")
|
||||
logger.info(f"Admin user edit accessed for {user_uid} by {self.session.get('uid')}")
|
||||
target_user = await self.services.user.get(uid=user_uid)
|
||||
if not target_user:
|
||||
return web.HTTPNotFound(text="User not found")
|
||||
return await self.render_template(
|
||||
"admin/users/edit.html",
|
||||
{"target_user": target_user}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
user_uid = self.request.match_info.get("user_uid")
|
||||
logger.info(f"Admin user edit POST for {user_uid} by {self.session.get('uid')}")
|
||||
try:
|
||||
data = await self.request.post()
|
||||
updates = {}
|
||||
if "nick" in data:
|
||||
updates["nick"] = data["nick"]
|
||||
if "is_admin" in data:
|
||||
updates["is_admin"] = data["is_admin"] == "on"
|
||||
if "is_banned" in data:
|
||||
updates["is_banned"] = data["is_banned"] == "on"
|
||||
if "color" in data:
|
||||
updates["color"] = data["color"]
|
||||
await self.services.admin.update_user(
|
||||
admin_uid=self.session.get("uid"),
|
||||
target_uid=user_uid,
|
||||
updates=updates
|
||||
)
|
||||
return web.HTTPFound("/admin/users/index.html")
|
||||
except ValueError as ex:
|
||||
logger.warning(f"User update error: {ex}")
|
||||
target_user = await self.services.user.get(uid=user_uid)
|
||||
return await self.render_template(
|
||||
"admin/users/edit.html",
|
||||
{"target_user": target_user, "error": str(ex)}
|
||||
)
|
||||
|
||||
|
||||
class AdminUserBanView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
user_uid = self.request.match_info.get("user_uid")
|
||||
logger.info(f"Admin user ban accessed for {user_uid} by {self.session.get('uid')}")
|
||||
target_user = await self.services.user.get(uid=user_uid)
|
||||
if not target_user:
|
||||
return web.HTTPNotFound(text="User not found")
|
||||
return await self.render_template(
|
||||
"admin/users/ban.html",
|
||||
{"target_user": target_user}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
user_uid = self.request.match_info.get("user_uid")
|
||||
data = await self.request.post()
|
||||
action = data.get("action", "ban")
|
||||
logger.info(f"Admin user {action} POST for {user_uid} by {self.session.get('uid')}")
|
||||
try:
|
||||
if action == "unban":
|
||||
await self.services.admin.unban_user(
|
||||
admin_uid=self.session.get("uid"),
|
||||
target_uid=user_uid
|
||||
)
|
||||
else:
|
||||
await self.services.admin.ban_user(
|
||||
admin_uid=self.session.get("uid"),
|
||||
target_uid=user_uid
|
||||
)
|
||||
return web.HTTPFound("/admin/users/index.html")
|
||||
except ValueError as ex:
|
||||
logger.warning(f"User ban error: {ex}")
|
||||
target_user = await self.services.user.get(uid=user_uid)
|
||||
return await self.render_template(
|
||||
"admin/users/ban.html",
|
||||
{"target_user": target_user, "error": str(ex)}
|
||||
)
|
||||
@@ -203,12 +203,16 @@ class ChannelAttachmentUploadView(BaseView):
|
||||
pathlib.Path(attachment["path"]).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async with aiofiles.open(attachment["path"], "wb") as file:
|
||||
print("File openend.", filename)
|
||||
async for msg in ws:
|
||||
if msg.type == web.WSMsgType.BINARY:
|
||||
print("Binary",filename)
|
||||
if file is not None:
|
||||
await file.write(msg.data)
|
||||
await ws.send_json({"type": "progress", "filename": filename, "bytes": await file.tell()})
|
||||
elif msg.type == web.WSMsgType.TEXT:
|
||||
print("TExt",filename)
|
||||
print(msg.json())
|
||||
data = msg.json()
|
||||
if data.get('type') == 'end':
|
||||
relative_url = urllib.parse.quote(attachment["relative_url"])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,275 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.system.view import BaseView
|
||||
from snek.view.rpc.base import BaseAction
|
||||
from snek.view.rpc.utils import safe_get, safe_str
|
||||
from snek.view.rpc.actions import (
|
||||
AdminActions,
|
||||
AuthActions,
|
||||
UserActions,
|
||||
ChannelActions,
|
||||
MessageActions,
|
||||
MentionActions,
|
||||
ContainerActions,
|
||||
DatabaseActions,
|
||||
MiscActions,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RPCApi(
|
||||
BaseAction,
|
||||
AdminActions,
|
||||
AuthActions,
|
||||
UserActions,
|
||||
ChannelActions,
|
||||
MessageActions,
|
||||
MentionActions,
|
||||
ContainerActions,
|
||||
DatabaseActions,
|
||||
MiscActions,
|
||||
):
|
||||
def __init__(self, view, ws):
|
||||
self.view = view
|
||||
self.app = safe_get(view, "app")
|
||||
self.services = safe_get(self.app, "services") if self.app else None
|
||||
self.ws = ws
|
||||
self.user_session = {}
|
||||
self._scheduled_tasks = []
|
||||
self._finalize_task = None
|
||||
self._is_closed = False
|
||||
|
||||
async def __call__(self, data):
|
||||
call_id = None
|
||||
start_time = time.time()
|
||||
try:
|
||||
if self._is_closed:
|
||||
logger.debug("RPC call on closed connection")
|
||||
return
|
||||
if not data or not isinstance(data, dict):
|
||||
logger.warning("Invalid RPC data received")
|
||||
return
|
||||
call_id = data.get("callId")
|
||||
method_name = data.get("method")
|
||||
args = data.get("args", [])
|
||||
logger.debug(f"RPC call received: method={method_name}, callId={call_id}, args_count={len(args) if args else 0}, user_uid={self.user_uid}")
|
||||
if not method_name or not isinstance(method_name, str):
|
||||
logger.warning(f"Invalid method name: {method_name}")
|
||||
await self._send_json({"callId": call_id, "success": False, "data": "Invalid method"})
|
||||
return
|
||||
if method_name.startswith("_"):
|
||||
logger.warning(f"Attempted to call private method: {method_name}")
|
||||
await self._send_json({"callId": call_id, "success": False, "data": "Not allowed"})
|
||||
return
|
||||
if args is None:
|
||||
args = []
|
||||
elif not isinstance(args, (list, tuple)):
|
||||
args = [args]
|
||||
safe_method_name = method_name.replace(".", "_")
|
||||
if not hasattr(self, safe_method_name):
|
||||
logger.warning(f"Method not found: {method_name}")
|
||||
await self._send_json({"callId": call_id, "success": False, "data": "Method not found"})
|
||||
return
|
||||
method = getattr(self, safe_method_name, None)
|
||||
if not method or not callable(method):
|
||||
logger.warning(f"Method not callable: {method_name}")
|
||||
await self._send_json({"callId": call_id, "success": False, "data": "Method not callable"})
|
||||
return
|
||||
success = True
|
||||
result = None
|
||||
try:
|
||||
logger.debug(f"Executing RPC method: {method_name}")
|
||||
result = await method(*args)
|
||||
elapsed = (time.time() - start_time) * 1000
|
||||
logger.info(f"RPC method completed: method={method_name}, callId={call_id}, elapsed={elapsed:.2f}ms, success=True")
|
||||
except (PermissionError, ValueError) as ex:
|
||||
result = {"error": safe_str(ex)}
|
||||
success = False
|
||||
elapsed = (time.time() - start_time) * 1000
|
||||
logger.warning(f"RPC method permission/value error: method={method_name}, callId={call_id}, elapsed={elapsed:.2f}ms, error={safe_str(ex)}")
|
||||
except Exception as ex:
|
||||
result = {"error": "Internal error"}
|
||||
success = False
|
||||
elapsed = (time.time() - start_time) * 1000
|
||||
logger.exception(f"RPC method failed: method={method_name}, callId={call_id}, elapsed={elapsed:.2f}ms, error={safe_str(ex)}")
|
||||
if result != "noresponse":
|
||||
await self._send_json({"callId": call_id, "success": success, "data": result})
|
||||
except Exception as ex:
|
||||
logger.exception(f"RPC call failed: {safe_str(ex)}")
|
||||
try:
|
||||
await self._send_json({"callId": call_id, "success": False, "data": "Internal error"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _send_json(self, obj):
|
||||
if self._is_closed:
|
||||
return False
|
||||
if not self.ws:
|
||||
return False
|
||||
try:
|
||||
json_str = json.dumps(obj, default=str)
|
||||
await self.ws.send_str(json_str)
|
||||
return True
|
||||
except ConnectionResetError:
|
||||
self._is_closed = True
|
||||
logger.debug("Connection reset during send")
|
||||
except Exception as ex:
|
||||
self._is_closed = True
|
||||
logger.warning(f"Failed to send JSON: {safe_str(ex)}")
|
||||
try:
|
||||
if self.services and hasattr(self.services, "socket"):
|
||||
await self.services.socket.delete(self.ws)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
class RPCView(BaseView):
|
||||
RPCApi = RPCApi
|
||||
|
||||
async def get(self):
|
||||
ws = None
|
||||
rpc = None
|
||||
scheduled_tasks = []
|
||||
|
||||
async def schedule(uid, seconds, call):
|
||||
if not uid or not call:
|
||||
return
|
||||
try:
|
||||
seconds = max(0, min(float(seconds) if seconds else 0, 300))
|
||||
await asyncio.sleep(seconds)
|
||||
if self.services:
|
||||
await self.services.socket.send_to_user(uid, call)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as ex:
|
||||
logger.debug(f"Schedule failed: {safe_str(ex)}")
|
||||
|
||||
try:
|
||||
ws = web.WebSocketResponse(heartbeat=300.0, autoping=True)
|
||||
await ws.prepare(self.request)
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to prepare WebSocket: {safe_str(ex)}")
|
||||
return web.Response(status=500, text="WebSocket initialization failed")
|
||||
|
||||
user_uid = None
|
||||
try:
|
||||
session = getattr(self.request, "session", None)
|
||||
if session and session.get("logged_in"):
|
||||
user_uid = session.get("uid")
|
||||
if user_uid and self.services:
|
||||
await self.services.socket.add(ws, user_uid)
|
||||
# Load subscriptions in background to avoid blocking WebSocket handshake
|
||||
async def load_subscriptions():
|
||||
try:
|
||||
async for subscription in self.services.channel_member.find(
|
||||
user_uid=user_uid,
|
||||
deleted_at=None,
|
||||
is_banned=False,
|
||||
):
|
||||
if subscription:
|
||||
channel_uid = safe_get(subscription, "channel_uid")
|
||||
if channel_uid:
|
||||
await self.services.socket.subscribe(ws, channel_uid, user_uid)
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to subscribe to channels: {safe_str(ex)}")
|
||||
asyncio.create_task(load_subscriptions())
|
||||
except Exception as ex:
|
||||
logger.warning(f"Session initialization failed: {safe_str(ex)}")
|
||||
|
||||
try:
|
||||
app = getattr(self.request, "app", None)
|
||||
uptime_seconds = getattr(app, "uptime_seconds", 999) if app else 999
|
||||
if not scheduled_tasks and uptime_seconds < 15 and user_uid:
|
||||
jitter = random.uniform(0, 3)
|
||||
task1 = asyncio.create_task(schedule(
|
||||
user_uid, jitter,
|
||||
{"event": "refresh", "data": {"message": "Finishing deployment"}},
|
||||
))
|
||||
scheduled_tasks.append(task1)
|
||||
uptime = getattr(app, "uptime", "") if app else ""
|
||||
task2 = asyncio.create_task(schedule(
|
||||
user_uid, 15 + jitter,
|
||||
{"event": "deployed", "data": {"uptime": uptime}},
|
||||
))
|
||||
scheduled_tasks.append(task2)
|
||||
except Exception as ex:
|
||||
logger.debug(f"Deployment schedule failed: {safe_str(ex)}")
|
||||
|
||||
rpc = RPCApi(self, ws)
|
||||
try:
|
||||
async for msg in ws:
|
||||
if msg is None:
|
||||
continue
|
||||
try:
|
||||
if msg.type == web.WSMsgType.TEXT:
|
||||
try:
|
||||
data = msg.json()
|
||||
if data:
|
||||
await rpc(data)
|
||||
except json.JSONDecodeError as ex:
|
||||
logger.warning(f"Invalid JSON received: {safe_str(ex)}")
|
||||
except Exception as ex:
|
||||
logger.warning(f"RPC call failed: {safe_str(ex)}")
|
||||
elif msg.type == web.WSMsgType.BINARY:
|
||||
logger.debug("Binary message received, ignoring")
|
||||
elif msg.type == web.WSMsgType.ERROR:
|
||||
logger.debug(f"WebSocket error: {safe_str(ws.exception())}")
|
||||
break
|
||||
elif msg.type == web.WSMsgType.CLOSE:
|
||||
logger.debug("WebSocket close message received")
|
||||
break
|
||||
elif msg.type == web.WSMsgType.CLOSING:
|
||||
logger.debug("WebSocket closing")
|
||||
break
|
||||
elif msg.type == web.WSMsgType.CLOSED:
|
||||
logger.debug("WebSocket closed")
|
||||
break
|
||||
except Exception as ex:
|
||||
logger.warning(f"Message processing error: {safe_str(ex)}")
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("WebSocket handler cancelled")
|
||||
except ConnectionResetError:
|
||||
logger.debug("Connection reset by peer")
|
||||
except Exception as ex:
|
||||
logger.warning(f"WebSocket loop error: {safe_str(ex)}")
|
||||
finally:
|
||||
if rpc:
|
||||
rpc._is_closed = True
|
||||
if rpc._finalize_task:
|
||||
try:
|
||||
rpc._finalize_task.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
for task in rpc._scheduled_tasks:
|
||||
try:
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
for task in scheduled_tasks:
|
||||
try:
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if self.services and ws:
|
||||
await self.services.socket.delete(ws)
|
||||
except Exception as ex:
|
||||
logger.debug(f"Socket cleanup failed: {safe_str(ex)}")
|
||||
try:
|
||||
if ws and not ws.closed:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
return ws
|
||||
@@ -1,23 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.view.rpc.actions.admin import AdminActions
|
||||
from snek.view.rpc.actions.auth import AuthActions
|
||||
from snek.view.rpc.actions.user import UserActions
|
||||
from snek.view.rpc.actions.channel import ChannelActions
|
||||
from snek.view.rpc.actions.message import MessageActions
|
||||
from snek.view.rpc.actions.mention import MentionActions
|
||||
from snek.view.rpc.actions.container import ContainerActions
|
||||
from snek.view.rpc.actions.database import DatabaseActions
|
||||
from snek.view.rpc.actions.misc import MiscActions
|
||||
|
||||
__all__ = [
|
||||
"AdminActions",
|
||||
"AuthActions",
|
||||
"UserActions",
|
||||
"ChannelActions",
|
||||
"MessageActions",
|
||||
"MentionActions",
|
||||
"ContainerActions",
|
||||
"DatabaseActions",
|
||||
"MiscActions",
|
||||
]
|
||||
@@ -1,347 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from snek.view.rpc.utils import safe_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminActions:
|
||||
|
||||
async def _require_admin(self):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
user = await self.services.user.get(uid=self.user_uid)
|
||||
if not user or not user["is_admin"]:
|
||||
raise PermissionError("Admin access required")
|
||||
return True
|
||||
|
||||
async def admin_get_stats(self):
|
||||
try:
|
||||
await self._require_admin()
|
||||
stats = await self.services.admin.get_dashboard_stats()
|
||||
return {"success": True, "data": stats}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_get_stats failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to get stats"}
|
||||
|
||||
async def admin_get_recent_activity(self, limit=20):
|
||||
try:
|
||||
await self._require_admin()
|
||||
activity = await self.services.admin.get_recent_activity(limit=limit)
|
||||
return {"success": True, "data": activity}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_get_recent_activity failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to get activity"}
|
||||
|
||||
async def admin_list_users(self, page=1, per_page=20, search=None):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.list_users(page=page, per_page=per_page, search=search)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_list_users failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to list users"}
|
||||
|
||||
async def admin_update_user(self, target_uid, updates):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.update_user(
|
||||
admin_uid=self.user_uid,
|
||||
target_uid=target_uid,
|
||||
updates=updates
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_update_user failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to update user"}
|
||||
|
||||
async def admin_ban_user(self, target_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.ban_user(
|
||||
admin_uid=self.user_uid,
|
||||
target_uid=target_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_ban_user failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to ban user"}
|
||||
|
||||
async def admin_unban_user(self, target_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.unban_user(
|
||||
admin_uid=self.user_uid,
|
||||
target_uid=target_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_unban_user failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to unban user"}
|
||||
|
||||
async def admin_list_channels(self, page=1, per_page=20, search=None):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.list_channels(page=page, per_page=per_page, search=search)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_list_channels failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to list channels"}
|
||||
|
||||
async def admin_get_channel_members(self, channel_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.get_channel_members(channel_uid)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_get_channel_members failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to get channel members"}
|
||||
|
||||
async def admin_update_channel(self, channel_uid, updates):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.update_channel(
|
||||
admin_uid=self.user_uid,
|
||||
channel_uid=channel_uid,
|
||||
updates=updates
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_update_channel failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to update channel"}
|
||||
|
||||
async def admin_delete_channel(self, channel_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.delete_channel(
|
||||
admin_uid=self.user_uid,
|
||||
channel_uid=channel_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_delete_channel failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to delete channel"}
|
||||
|
||||
async def admin_clear_channel(self, channel_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.clear_channel_history(
|
||||
admin_uid=self.user_uid,
|
||||
channel_uid=channel_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_clear_channel failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to clear channel"}
|
||||
|
||||
async def admin_search_messages(self, query=None, channel_uid=None, user_uid=None, page=1):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.search_messages(
|
||||
query=query,
|
||||
channel_uid=channel_uid,
|
||||
user_uid=user_uid,
|
||||
page=page
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_search_messages failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to search messages"}
|
||||
|
||||
async def admin_delete_message(self, message_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.delete_message(
|
||||
admin_uid=self.user_uid,
|
||||
message_uid=message_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_delete_message failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to delete message"}
|
||||
|
||||
async def admin_list_forums(self, page=1):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.list_forums(page=page)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_list_forums failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to list forums"}
|
||||
|
||||
async def admin_delete_forum(self, forum_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.delete_forum(
|
||||
admin_uid=self.user_uid,
|
||||
forum_uid=forum_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_delete_forum failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to delete forum"}
|
||||
|
||||
async def admin_list_threads(self, forum_uid, page=1):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.list_threads(forum_uid=forum_uid, page=page)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_list_threads failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to list threads"}
|
||||
|
||||
async def admin_toggle_thread_pin(self, thread_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.toggle_thread_pin(
|
||||
admin_uid=self.user_uid,
|
||||
thread_uid=thread_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_toggle_thread_pin failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to toggle pin"}
|
||||
|
||||
async def admin_toggle_thread_lock(self, thread_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.toggle_thread_lock(
|
||||
admin_uid=self.user_uid,
|
||||
thread_uid=thread_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_toggle_thread_lock failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to toggle lock"}
|
||||
|
||||
async def admin_delete_thread(self, thread_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.delete_thread(
|
||||
admin_uid=self.user_uid,
|
||||
thread_uid=thread_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_delete_thread failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to delete thread"}
|
||||
|
||||
async def admin_delete_post(self, post_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.delete_post(
|
||||
admin_uid=self.user_uid,
|
||||
post_uid=post_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_delete_post failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to delete post"}
|
||||
|
||||
async def admin_mass_notify(self, message, user_uids=None):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.mass_notify(
|
||||
admin_uid=self.user_uid,
|
||||
message=message,
|
||||
user_uids=user_uids
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_mass_notify failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to send notifications"}
|
||||
|
||||
async def admin_get_kv_entries(self):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.get_kv_entries()
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_get_kv_entries failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to get KV entries"}
|
||||
|
||||
async def admin_set_kv_entry(self, key, value):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.set_kv_entry(
|
||||
admin_uid=self.user_uid,
|
||||
key=key,
|
||||
value=value
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_set_kv_entry failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to set KV entry"}
|
||||
|
||||
async def admin_delete_kv_entry(self, key):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.delete_kv_entry(
|
||||
admin_uid=self.user_uid,
|
||||
key=key
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_delete_kv_entry failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to delete KV entry"}
|
||||
|
||||
async def admin_run_maintenance(self):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.run_maintenance(admin_uid=self.user_uid)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_run_maintenance failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to run maintenance"}
|
||||
@@ -1,57 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from snek.view.rpc.utils import safe_get, safe_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthActions:
|
||||
async def login(self, username, password):
|
||||
self._require_services()
|
||||
if not username or not isinstance(username, str):
|
||||
raise ValueError("Invalid username")
|
||||
if not password or not isinstance(password, str):
|
||||
raise ValueError("Invalid password")
|
||||
try:
|
||||
success = await self.services.user.validate_login(username, password)
|
||||
if not success:
|
||||
raise PermissionError("Invalid username or password")
|
||||
user = await self.services.user.get(username=username)
|
||||
if not user:
|
||||
raise PermissionError("User not found")
|
||||
if not self.view or not hasattr(self.view, "session") or self.view.session is None:
|
||||
raise RuntimeError("Session not available")
|
||||
self.view.session["uid"] = safe_get(user, "uid")
|
||||
self.view.session["logged_in"] = True
|
||||
self.view.session["username"] = safe_get(user, "username", "")
|
||||
self.view.session["user_nick"] = safe_get(user, "nick", "")
|
||||
record = dict(user.record) if hasattr(user, "record") else {}
|
||||
record.pop("password", None)
|
||||
record.pop("deleted_at", None)
|
||||
user_uid = None
|
||||
if self.view and hasattr(self.view, "session") and self.view.session:
|
||||
user_uid = self.view.session.get("uid") if hasattr(self.view.session, "get") else None
|
||||
if user_uid:
|
||||
await self.services.socket.add(self.ws, user_uid)
|
||||
try:
|
||||
async for subscription in self.services.channel_member.find(
|
||||
user_uid=user_uid,
|
||||
deleted_at=None,
|
||||
is_banned=False,
|
||||
):
|
||||
if subscription and subscription["channel_uid"]:
|
||||
await self.services.socket.subscribe(
|
||||
self.ws,
|
||||
subscription["channel_uid"],
|
||||
user_uid,
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to subscribe to channels: {safe_str(ex)}")
|
||||
return record
|
||||
except (PermissionError, ValueError):
|
||||
raise
|
||||
except Exception as ex:
|
||||
logger.error(f"Login error: {safe_str(ex)}")
|
||||
raise PermissionError("Login failed")
|
||||
@@ -1,229 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from snek.system.model import now
|
||||
from snek.view.rpc.utils import safe_get, safe_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChannelActions:
|
||||
async def get_channels(self):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
channels = []
|
||||
try:
|
||||
async for subscription in self.services.channel_member.find(
|
||||
user_uid=self.user_uid, is_banned=False
|
||||
):
|
||||
if not subscription:
|
||||
continue
|
||||
try:
|
||||
channel_uid = subscription["channel_uid"]
|
||||
if not channel_uid:
|
||||
continue
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
continue
|
||||
color = None
|
||||
try:
|
||||
last_message = await channel.get_last_message()
|
||||
if last_message:
|
||||
last_message_user = await last_message.get_user()
|
||||
color = safe_get(last_message_user, "color") if last_message_user else None
|
||||
except Exception:
|
||||
pass
|
||||
other_user = await self.services.channel_member.get_other_dm_user(
|
||||
subscription["channel_uid"], self.user_uid
|
||||
)
|
||||
|
||||
name = other_user and other_user['nick'] or subscription['label']
|
||||
|
||||
channels.append({
|
||||
"name": name,
|
||||
"uid": channel_uid,
|
||||
"tag": channel["tag"] or "",
|
||||
"new_count": subscription["new_count"] or 0,
|
||||
"is_moderator": subscription["is_moderator"] or False,
|
||||
"is_read_only": subscription["is_read_only"] or False,
|
||||
"color": color,
|
||||
})
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to process channel subscription: {safe_str(ex)}")
|
||||
return channels
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to get channels: {safe_str(ex)}")
|
||||
return []
|
||||
|
||||
async def create_channel(self, name, description=None, is_private=False):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not name or not isinstance(name, str):
|
||||
return {"error": "Invalid channel name", "success": False}
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return {"error": "Channel name cannot be empty", "success": False}
|
||||
try:
|
||||
channel = await self.services.channel.create(
|
||||
label=name,
|
||||
created_by_uid=self.user_uid,
|
||||
description=description,
|
||||
is_private=is_private,
|
||||
is_listed=True
|
||||
)
|
||||
if not channel:
|
||||
return {"error": "Failed to create channel", "success": False}
|
||||
await self.services.channel_member.create(
|
||||
channel["uid"],
|
||||
self.user_uid,
|
||||
is_moderator=True
|
||||
)
|
||||
await self.services.socket.subscribe(self.ws, channel["uid"], self.user_uid)
|
||||
return {
|
||||
"success": True,
|
||||
"uid": channel["uid"],
|
||||
"name": channel["label"]
|
||||
}
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to create channel: {safe_str(ex)}")
|
||||
return {"error": safe_str(ex), "success": False}
|
||||
|
||||
async def update_channel(self, channel_uid, name, description=None, is_private=False):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
return {"error": "Invalid channel", "success": False}
|
||||
if not name or not isinstance(name, str):
|
||||
return {"error": "Invalid channel name", "success": False}
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return {"error": "Channel name cannot be empty", "success": False}
|
||||
try:
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
return {"error": "Channel not found", "success": False}
|
||||
if channel["tag"] == "public":
|
||||
return {"error": "Cannot modify the public channel", "success": False}
|
||||
is_creator = channel.get("created_by_uid") == self.user_uid
|
||||
user = await self.services.user.get(uid=self.user_uid)
|
||||
is_admin = user and user.get("is_admin", False)
|
||||
if not is_creator and not is_admin:
|
||||
return {"error": "Only the channel creator or admin can update channel settings", "success": False}
|
||||
if not name.startswith("#") and channel["is_listed"]:
|
||||
name = f"#{name}"
|
||||
channel["label"] = name
|
||||
channel["description"] = description
|
||||
channel["is_private"] = is_private
|
||||
await self.services.channel.save(channel)
|
||||
async for member in self.services.channel_member.find(
|
||||
channel_uid=channel_uid, deleted_at=None
|
||||
):
|
||||
member["label"] = name
|
||||
await self.services.channel_member.save(member)
|
||||
return {"success": True, "name": channel["label"]}
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to update channel: {safe_str(ex)}")
|
||||
return {"error": "Failed to update channel", "success": False}
|
||||
|
||||
async def delete_channel(self, channel_uid):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
return {"error": "Invalid channel", "success": False}
|
||||
try:
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
return {"error": "Channel not found", "success": False}
|
||||
if channel["tag"] == "public":
|
||||
return {"error": "Cannot delete the public channel", "success": False}
|
||||
is_creator = channel.get("created_by_uid") == self.user_uid
|
||||
user = await self.services.user.get(uid=self.user_uid)
|
||||
is_admin = user and user.get("is_admin", False)
|
||||
if not is_creator and not is_admin:
|
||||
return {"error": "Only the channel creator or admin can delete channels", "success": False}
|
||||
channel["deleted_at"] = now()
|
||||
await self.services.channel.save(channel)
|
||||
async for member in self.services.channel_member.find(
|
||||
channel_uid=channel_uid, deleted_at=None
|
||||
):
|
||||
member["deleted_at"] = now()
|
||||
await self.services.channel_member.save(member)
|
||||
return {"success": True}
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to delete channel: {safe_str(ex)}")
|
||||
return {"error": "Failed to delete channel", "success": False}
|
||||
|
||||
async def leave_channel(self, channel_uid):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
return {"error": "Invalid channel", "success": False}
|
||||
try:
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
return {"error": "Channel not found", "success": False}
|
||||
if channel["tag"] == "public":
|
||||
return {"error": "Cannot leave the public channel", "success": False}
|
||||
channel_member = await self.services.channel_member.get(
|
||||
channel_uid=channel_uid, user_uid=self.user_uid
|
||||
)
|
||||
if not channel_member:
|
||||
return {"error": "Not a member of this channel", "success": False}
|
||||
channel_member["deleted_at"] = now()
|
||||
await self.services.channel_member.save(channel_member)
|
||||
await self.services.socket.unsubscribe(self.ws, channel_uid, self.user_uid)
|
||||
return {"success": True}
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to leave channel: {safe_str(ex)}")
|
||||
return {"error": "Failed to leave channel", "success": False}
|
||||
|
||||
async def invite_user(self, channel_uid, username):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
return {"error": "Invalid channel", "success": False}
|
||||
if not username or not isinstance(username, str):
|
||||
return {"error": "Invalid username", "success": False}
|
||||
try:
|
||||
channel_member = await self.services.channel_member.get(
|
||||
channel_uid=channel_uid, user_uid=self.user_uid
|
||||
)
|
||||
if not channel_member:
|
||||
raise PermissionError("Not a member of this channel")
|
||||
user = await self.services.user.get(username=username.strip())
|
||||
if not user:
|
||||
return {"error": "User not found", "success": False}
|
||||
existing = await self.services.channel_member.get(
|
||||
channel_uid=channel_uid, user_uid=user["uid"]
|
||||
)
|
||||
if existing and not existing["deleted_at"]:
|
||||
return {"error": "User is already a member", "success": False}
|
||||
result = await self.services.channel_member.create(
|
||||
channel_uid,
|
||||
user["uid"],
|
||||
is_moderator=False
|
||||
)
|
||||
if not result:
|
||||
return {"error": "Failed to invite user", "success": False}
|
||||
return {"success": True, "username": user["username"]}
|
||||
except PermissionError as ex:
|
||||
return {"error": safe_str(ex), "success": False}
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to invite user: {safe_str(ex)}")
|
||||
return {"error": "Failed to invite user", "success": False}
|
||||
|
||||
async def clear_channel(self, channel_uid):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
raise ValueError("Invalid channel_uid")
|
||||
user = await self.services.user.get(uid=self.user_uid)
|
||||
if not user or not safe_get(user, "is_admin", False):
|
||||
raise PermissionError("Not allowed")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
raise ValueError("Channel not found")
|
||||
channel["history_start"] = now()
|
||||
await self.services.channel.save(channel)
|
||||
return await self.services.channel_message.clear(channel_uid)
|
||||
@@ -1,146 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from snek.view.rpc.utils import safe_get, safe_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContainerActions:
|
||||
async def get_container(self, channel_uid):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
return None
|
||||
try:
|
||||
channel_member = await self.services.channel_member.get(
|
||||
channel_uid=channel_uid, user_uid=self.user_uid
|
||||
)
|
||||
if not channel_member:
|
||||
raise PermissionError("Not allowed")
|
||||
container = await self.services.container.get(channel_uid)
|
||||
if not container:
|
||||
return None
|
||||
deploy = safe_get(container, "deploy", {})
|
||||
resources = safe_get(deploy, "resources", {})
|
||||
limits = safe_get(resources, "limits", {})
|
||||
return {
|
||||
"name": await self.services.container.get_container_name(channel_uid),
|
||||
"cpus": safe_get(limits, "cpus", "0.5"),
|
||||
"memory": safe_get(limits, "memory", "256M"),
|
||||
"image": "ubuntu:latest",
|
||||
"volumes": [],
|
||||
"status": safe_get(container, "status", "unknown"),
|
||||
}
|
||||
except PermissionError:
|
||||
raise
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to get container: {safe_str(ex)}")
|
||||
return None
|
||||
|
||||
async def write_container(self, channel_uid, content, timeout=3):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
raise ValueError("Invalid channel_uid")
|
||||
try:
|
||||
channel_member = await self.services.channel_member.get(
|
||||
channel_uid=channel_uid, user_uid=self.user_uid
|
||||
)
|
||||
if not channel_member:
|
||||
raise PermissionError("Not allowed")
|
||||
container_name = await self.services.container.get_container_name(channel_uid)
|
||||
if not container_name:
|
||||
raise RuntimeError("Container not found")
|
||||
timeout = max(1, min(int(timeout) if timeout else 3, 30))
|
||||
|
||||
class SessionCall:
|
||||
def __init__(self, app, channel_uid, container_name):
|
||||
self.app = app
|
||||
self.channel_uid = channel_uid
|
||||
self.container_name = container_name
|
||||
self.time_last_output = time.time()
|
||||
self.output = b""
|
||||
|
||||
async def stdout_event_handler(self, data):
|
||||
try:
|
||||
self.time_last_output = time.time()
|
||||
if data:
|
||||
self.output += data if isinstance(data, bytes) else data.encode("utf-8", "ignore")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def communicate(self, content, timeout=3):
|
||||
try:
|
||||
if self.app and hasattr(self.app, "services") and self.app.services:
|
||||
await self.app.services.container.add_event_listener(
|
||||
self.container_name, "stdout", self.stdout_event_handler
|
||||
)
|
||||
await self.app.services.container.write_stdin(self.channel_uid, content)
|
||||
start_time = time.time()
|
||||
while time.time() - self.time_last_output < timeout:
|
||||
if time.time() - start_time > timeout * 2:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
await self.app.services.container.remove_event_listener(
|
||||
self.container_name, "stdout", self.stdout_event_handler
|
||||
)
|
||||
return self.output
|
||||
except Exception as ex:
|
||||
logger.warning(f"Container communication error: {safe_str(ex)}")
|
||||
return self.output
|
||||
|
||||
sc = SessionCall(self.app, channel_uid, container_name)
|
||||
result = await sc.communicate(content, timeout)
|
||||
return result.decode("utf-8", "ignore") if result else ""
|
||||
except (PermissionError, ValueError):
|
||||
raise
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to write container: {safe_str(ex)}")
|
||||
return ""
|
||||
|
||||
async def start_container(self, channel_uid):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
raise ValueError("Invalid channel_uid")
|
||||
channel_member = await self.services.channel_member.get(
|
||||
channel_uid=channel_uid, user_uid=self.user_uid
|
||||
)
|
||||
if not channel_member:
|
||||
raise PermissionError("Not allowed")
|
||||
return await self.services.container.start(channel_uid)
|
||||
|
||||
async def stop_container(self, channel_uid):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
raise ValueError("Invalid channel_uid")
|
||||
channel_member = await self.services.channel_member.get(
|
||||
channel_uid=channel_uid, user_uid=self.user_uid
|
||||
)
|
||||
if not channel_member:
|
||||
raise PermissionError("Not allowed")
|
||||
return await self.services.container.stop(channel_uid)
|
||||
|
||||
async def get_container_status(self, channel_uid):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
return None
|
||||
try:
|
||||
channel_member = await self.services.channel_member.get(
|
||||
channel_uid=channel_uid, user_uid=self.user_uid
|
||||
)
|
||||
if not channel_member:
|
||||
raise PermissionError("Not allowed")
|
||||
return await self.services.container.get_status(channel_uid)
|
||||
except PermissionError:
|
||||
raise
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to get container status: {safe_str(ex)}")
|
||||
return None
|
||||
@@ -1,95 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from snek.view.rpc.utils import safe_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DatabaseActions:
|
||||
async def db_insert(self, table_name, record):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not table_name or not isinstance(table_name, str):
|
||||
raise ValueError("Invalid table name")
|
||||
if not record or not isinstance(record, dict):
|
||||
raise ValueError("Invalid record")
|
||||
return await self.services.db.insert(self.user_uid, table_name, record)
|
||||
|
||||
async def db_update(self, table_name, record):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not table_name or not isinstance(table_name, str):
|
||||
raise ValueError("Invalid table name")
|
||||
if not record or not isinstance(record, dict):
|
||||
raise ValueError("Invalid record")
|
||||
return await self.services.db.update(self.user_uid, table_name, record)
|
||||
|
||||
async def db_delete(self, table_name, record):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not table_name or not isinstance(table_name, str):
|
||||
raise ValueError("Invalid table name")
|
||||
if not record or not isinstance(record, dict):
|
||||
raise ValueError("Invalid record")
|
||||
return await self.services.db.delete(self.user_uid, table_name, record)
|
||||
|
||||
async def db_get(self, table_name, record):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not table_name or not isinstance(table_name, str):
|
||||
raise ValueError("Invalid table name")
|
||||
return await self.services.db.get(self.user_uid, table_name, record)
|
||||
|
||||
async def db_find(self, table_name, record):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not table_name or not isinstance(table_name, str):
|
||||
raise ValueError("Invalid table name")
|
||||
return await self.services.db.find(self.user_uid, table_name, record)
|
||||
|
||||
async def db_upsert(self, table_name, record, keys):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not table_name or not isinstance(table_name, str):
|
||||
raise ValueError("Invalid table name")
|
||||
if not record or not isinstance(record, dict):
|
||||
raise ValueError("Invalid record")
|
||||
return await self.services.db.upsert(
|
||||
self.user_uid, table_name, record, keys
|
||||
)
|
||||
|
||||
async def db_query(self, sql, args):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not sql or not isinstance(sql, str):
|
||||
raise ValueError("Invalid SQL query")
|
||||
return await self.services.db.query(self.user_uid, sql, args)
|
||||
|
||||
async def query(self, *args):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not args or len(args) == 0:
|
||||
return []
|
||||
query_str = args[0]
|
||||
if not query_str or not isinstance(query_str, str):
|
||||
return []
|
||||
lowercase = query_str.lower()
|
||||
forbidden_keywords = ["drop", "alter", "update", "delete", "replace", "insert", "truncate"]
|
||||
if any(keyword in lowercase for keyword in forbidden_keywords) and "select" not in lowercase:
|
||||
raise PermissionError("Not allowed")
|
||||
try:
|
||||
results = []
|
||||
async for record in self.services.channel.query(query_str):
|
||||
if record:
|
||||
record_dict = dict(record)
|
||||
record_dict.pop("email", None)
|
||||
record_dict.pop("password", None)
|
||||
record_dict.pop("message", None)
|
||||
record_dict.pop("html", None)
|
||||
results.append(record_dict)
|
||||
return results
|
||||
except Exception as ex:
|
||||
logger.warning(f"Query failed: {safe_str(ex)}")
|
||||
return []
|
||||
@@ -1,78 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from snek.view.rpc.utils import safe_get, safe_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MentionActions:
|
||||
async def mark_as_read(self, channel_uid):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
return False
|
||||
try:
|
||||
await self.services.channel_member.mark_as_read(channel_uid, self.user_uid)
|
||||
return True
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to mark as read: {safe_str(ex)}")
|
||||
return False
|
||||
|
||||
async def get_unread_mention_count(self, channel_uid=None):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
try:
|
||||
if channel_uid and not isinstance(channel_uid, str):
|
||||
return 0
|
||||
return await self.services.mention.get_unread_count(
|
||||
self.user_uid, channel_uid
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to get unread mention count: {safe_str(ex)}")
|
||||
return 0
|
||||
|
||||
async def get_next_unread_mention(self, channel_uid):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
return None
|
||||
try:
|
||||
mention = await self.services.mention.get_oldest_unread_mention(
|
||||
self.user_uid, channel_uid
|
||||
)
|
||||
if mention:
|
||||
return {
|
||||
"mention_uid": safe_get(mention, "uid"),
|
||||
"message_uid": safe_get(mention, "message_uid"),
|
||||
"channel_uid": safe_get(mention, "channel_uid")
|
||||
}
|
||||
return None
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to get next unread mention: {safe_str(ex)}")
|
||||
return None
|
||||
|
||||
async def mark_mention_read(self, mention_uid):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not mention_uid or not isinstance(mention_uid, str):
|
||||
return False
|
||||
try:
|
||||
return await self.services.mention.mark_read(mention_uid, self.user_uid)
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to mark mention read: {safe_str(ex)}")
|
||||
return False
|
||||
|
||||
async def mark_all_mentions_read(self, channel_uid):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
return 0
|
||||
try:
|
||||
return await self.services.mention.mark_all_read_in_channel(
|
||||
self.user_uid, channel_uid
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to mark all mentions read: {safe_str(ex)}")
|
||||
return 0
|
||||
@@ -1,268 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from snek.system.model import now
|
||||
from snek.view.rpc.utils import safe_get, safe_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MessageActions:
|
||||
async def set_typing(self, channel_uid, color=None):
|
||||
logger.debug(f"set_typing: called channel_uid={channel_uid}, user_uid={self.user_uid}, color={color}")
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
logger.warning(f"set_typing: invalid channel_uid: {channel_uid}")
|
||||
return False
|
||||
try:
|
||||
user = await self.services.user.get(uid=self.user_uid)
|
||||
if not user:
|
||||
logger.warning(f"set_typing: user not found: {self.user_uid}")
|
||||
return False
|
||||
if not color:
|
||||
color = safe_get(user, "color", "#000000")
|
||||
logger.debug(f"set_typing: broadcasting typing indicator to channel={channel_uid}, user={safe_get(user, 'username')}")
|
||||
result = await self.services.socket.broadcast(
|
||||
channel_uid,
|
||||
{
|
||||
"channel_uid": channel_uid,
|
||||
"event": "set_typing",
|
||||
"data": {
|
||||
"event": "set_typing",
|
||||
"user_uid": safe_get(user, "uid", ""),
|
||||
"username": safe_get(user, "username", ""),
|
||||
"nick": safe_get(user, "nick", ""),
|
||||
"channel_uid": channel_uid,
|
||||
"color": color,
|
||||
},
|
||||
},
|
||||
)
|
||||
logger.debug(f"set_typing: broadcast result={result}")
|
||||
return result
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to set typing: {safe_str(ex)}")
|
||||
return False
|
||||
|
||||
async def get_messages(self, channel_uid, offset=0, timestamp=None):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
return []
|
||||
try:
|
||||
offset = int(offset) if offset else 0
|
||||
if offset < 0:
|
||||
offset = 0
|
||||
messages = []
|
||||
result = await self.services.channel_message.offset(
|
||||
channel_uid, offset, timestamp or None
|
||||
)
|
||||
for message in (result or []):
|
||||
if message:
|
||||
try:
|
||||
extended_dict = await self.services.channel_message.to_extended_dict(message)
|
||||
if extended_dict:
|
||||
messages.append(extended_dict)
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to extend message: {safe_str(ex)}")
|
||||
return messages
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to get messages: {safe_str(ex)}")
|
||||
return []
|
||||
|
||||
async def get_first_unread_message_uid(self, channel_uid):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
return None
|
||||
try:
|
||||
channel_member = await self.services.channel_member.get(
|
||||
channel_uid=channel_uid, user_uid=self.user_uid
|
||||
)
|
||||
if not channel_member:
|
||||
return None
|
||||
last_read_at = safe_get(channel_member, "last_read_at")
|
||||
if not last_read_at:
|
||||
return None
|
||||
async for message in self.services.channel_message.query(
|
||||
"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid AND created_at > :last_read_at AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1",
|
||||
{"channel_uid": channel_uid, "last_read_at": last_read_at}
|
||||
):
|
||||
return safe_get(message, "uid")
|
||||
return None
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to get first unread message: {safe_str(ex)}")
|
||||
return None
|
||||
|
||||
async def _finalize_message_task(self, message_uid):
|
||||
if not message_uid:
|
||||
return False
|
||||
try:
|
||||
for _ in range(7):
|
||||
await asyncio.sleep(1)
|
||||
if not self._finalize_task:
|
||||
return False
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Finalization cancelled")
|
||||
return False
|
||||
except Exception as ex:
|
||||
logger.warning(f"Finalization wait error: {safe_str(ex)}")
|
||||
return False
|
||||
self._finalize_task = None
|
||||
try:
|
||||
if not self.services:
|
||||
return False
|
||||
message = await self.services.channel_message.get(message_uid, is_final=False)
|
||||
if not message:
|
||||
return False
|
||||
if safe_get(message, "user_uid") != self.user_uid:
|
||||
return False
|
||||
if safe_get(message, "is_final", False):
|
||||
return True
|
||||
await self.services.chat.finalize(safe_get(message, "uid"))
|
||||
return True
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to finalize message: {safe_str(ex)}")
|
||||
return False
|
||||
|
||||
async def _queue_finalize_message(self, message_uid):
|
||||
if not message_uid:
|
||||
return
|
||||
try:
|
||||
if self._finalize_task:
|
||||
self._finalize_task.cancel()
|
||||
try:
|
||||
await self._finalize_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
self._finalize_task = asyncio.create_task(
|
||||
self._finalize_message_task(message_uid)
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to queue finalize: {safe_str(ex)}")
|
||||
|
||||
async def send_message(self, channel_uid, message, is_final=True):
|
||||
logger.debug(f"send_message called: channel_uid={channel_uid}, message_len={len(str(message)) if message else 0}, is_final={is_final}, user_uid={self.user_uid}")
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
logger.warning(f"send_message: invalid channel_uid: {channel_uid}")
|
||||
return None
|
||||
if message is None:
|
||||
logger.debug("send_message: message is None")
|
||||
return None
|
||||
try:
|
||||
message_text = safe_str(message).strip()
|
||||
logger.debug(f"send_message: processing message text, length={len(message_text)}, is_final={is_final}")
|
||||
if not is_final:
|
||||
logger.debug(f"send_message: checking for existing non-final message in channel={channel_uid}")
|
||||
check_message = await self.services.channel_message.get(
|
||||
channel_uid=channel_uid, user_uid=self.user_uid, is_final=False, deleted_at=None
|
||||
)
|
||||
if check_message:
|
||||
msg_uid = safe_get(check_message, "uid")
|
||||
logger.debug(f"send_message: found existing non-final message uid={msg_uid}, updating text")
|
||||
await self._queue_finalize_message(msg_uid)
|
||||
return await self.update_message_text(msg_uid, message_text)
|
||||
if not message_text:
|
||||
logger.debug("send_message: empty message text after strip")
|
||||
return None
|
||||
logger.info(f"send_message: sending new message to channel={channel_uid}, is_final={is_final}")
|
||||
result = await self.services.chat.send(
|
||||
self.user_uid, channel_uid, message_text, is_final
|
||||
)
|
||||
if not result:
|
||||
logger.warning(f"send_message: chat.send returned None for channel={channel_uid}")
|
||||
return None
|
||||
result_uid = safe_get(result, "uid")
|
||||
result_is_final = safe_get(result, "is_final", True)
|
||||
logger.info(f"send_message: message sent successfully, uid={result_uid}, is_final={result_is_final}")
|
||||
if not result_is_final:
|
||||
await self._queue_finalize_message(result_uid)
|
||||
elif self._finalize_task:
|
||||
self._finalize_task.cancel()
|
||||
return result_uid
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to send message: {safe_str(ex)}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def finalize_message(self, message_uid):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not message_uid or not isinstance(message_uid, str):
|
||||
return False
|
||||
try:
|
||||
message = await self.services.channel_message.get(message_uid)
|
||||
if not message:
|
||||
return False
|
||||
if safe_get(message, "user_uid") != self.user_uid:
|
||||
raise PermissionError("Not allowed")
|
||||
if not safe_get(message, "is_final", False):
|
||||
await self.services.chat.finalize(safe_get(message, "uid"))
|
||||
return True
|
||||
except PermissionError:
|
||||
raise
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to finalize message: {safe_str(ex)}")
|
||||
return False
|
||||
|
||||
async def update_message_text(self, message_uid, text):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not message_uid or not isinstance(message_uid, str):
|
||||
return {"error": "Invalid message_uid", "success": False}
|
||||
try:
|
||||
message = await self.services.channel_message.get(message_uid)
|
||||
if not message:
|
||||
return {"error": "Message not found", "success": False}
|
||||
if safe_get(message, "is_final", False):
|
||||
return {"error": "Message is final", "success": False}
|
||||
if safe_get(message, "deleted_at"):
|
||||
return {"error": "Message is deleted", "success": False}
|
||||
if safe_get(message, "user_uid") != self.user_uid:
|
||||
raise PermissionError("Not allowed")
|
||||
seconds_since_update = 0
|
||||
try:
|
||||
if hasattr(message, "get_seconds_since_last_update"):
|
||||
seconds_since_update = message.get_seconds_since_last_update()
|
||||
except Exception:
|
||||
pass
|
||||
if seconds_since_update > 8:
|
||||
return {
|
||||
"error": "Message too old",
|
||||
"seconds_since_last_update": seconds_since_update,
|
||||
"success": False,
|
||||
}
|
||||
text_str = safe_str(text) if text else ""
|
||||
if not text_str:
|
||||
message["deleted_at"] = now()
|
||||
else:
|
||||
message["deleted_at"] = None
|
||||
message["message"] = text_str
|
||||
await self.services.channel_message.save(message)
|
||||
record = dict(message.record) if hasattr(message, "record") else {}
|
||||
record["text"] = safe_get(message, "message", "")
|
||||
record["message_uid"] = message_uid
|
||||
channel_uid = safe_get(message, "channel_uid")
|
||||
if channel_uid:
|
||||
try:
|
||||
await self.services.socket.broadcast(
|
||||
channel_uid,
|
||||
{
|
||||
"channel_uid": channel_uid,
|
||||
"event": "update_message_text",
|
||||
"data": record,
|
||||
},
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to broadcast message update: {safe_str(ex)}")
|
||||
return {"success": True}
|
||||
except PermissionError:
|
||||
raise
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to update message text: {safe_str(ex)}")
|
||||
return {"error": "Update failed", "success": False}
|
||||
@@ -1,78 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from snek.system.model import now
|
||||
from snek.view.rpc.utils import safe_get, safe_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MiscActions:
|
||||
async def echo(self, *args):
|
||||
return args
|
||||
|
||||
async def echo_raw(self, obj):
|
||||
if not self.ws:
|
||||
return "noresponse"
|
||||
try:
|
||||
await self.ws.send_json(obj if obj else {})
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to echo: {safe_str(ex)}")
|
||||
return "noresponse"
|
||||
|
||||
async def ping(self, call_id=None, *args):
|
||||
try:
|
||||
if self.user_uid and self.services:
|
||||
user = await self.services.user.get(uid=self.user_uid)
|
||||
if user:
|
||||
user["last_ping"] = now()
|
||||
await self.services.user.save(user)
|
||||
return {"pong": args if args else []}
|
||||
except Exception as ex:
|
||||
logger.warning(f"Ping failed: {safe_str(ex)}")
|
||||
return {"pong": []}
|
||||
|
||||
async def _schedule(self, seconds, call):
|
||||
if not call:
|
||||
return
|
||||
try:
|
||||
seconds = max(0, min(float(seconds) if seconds else 0, 300))
|
||||
await asyncio.sleep(seconds)
|
||||
if self.services and self.user_uid and not self._is_closed:
|
||||
await self.services.socket.send_to_user(self.user_uid, call)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as ex:
|
||||
logger.warning(f"Schedule failed: {safe_str(ex)}")
|
||||
|
||||
async def stars_render(self, channel_uid, message):
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
return False
|
||||
try:
|
||||
users = await self.get_online_users(channel_uid)
|
||||
for user in (users or []):
|
||||
if not user:
|
||||
continue
|
||||
user_uid = safe_get(user, "uid")
|
||||
if not user_uid:
|
||||
continue
|
||||
try:
|
||||
await self.services.socket.send_to_user(
|
||||
user_uid,
|
||||
{
|
||||
"event": "stars_render",
|
||||
"data": {
|
||||
"channel_uid": channel_uid,
|
||||
"message": safe_str(message) if message else "",
|
||||
},
|
||||
},
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.debug(f"Failed to send stars_render to user: {safe_str(ex)}")
|
||||
return True
|
||||
except Exception as ex:
|
||||
logger.warning(f"stars_render failed: {safe_str(ex)}")
|
||||
return False
|
||||
@@ -1,97 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from snek.view.rpc.utils import safe_get, safe_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserActions:
|
||||
async def get_user(self, user_uid=None):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
try:
|
||||
if not user_uid:
|
||||
user_uid = self.user_uid
|
||||
if not user_uid:
|
||||
return None
|
||||
user = await self.services.user.get(uid=user_uid)
|
||||
if not user:
|
||||
return None
|
||||
record = dict(user.record) if hasattr(user, "record") else {}
|
||||
record.pop("password", None)
|
||||
record.pop("deleted_at", None)
|
||||
if user_uid != self.user_uid:
|
||||
record.pop("email", None)
|
||||
return record
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to get user: {safe_str(ex)}")
|
||||
return None
|
||||
|
||||
async def search_user(self, query):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not query or not isinstance(query, str):
|
||||
return []
|
||||
try:
|
||||
results = await self.services.user.search(query)
|
||||
return [safe_get(user, "username", "") for user in (results or []) if user]
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to search user: {safe_str(ex)}")
|
||||
return []
|
||||
|
||||
async def get_online_users(self, channel_uid):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
return []
|
||||
try:
|
||||
results = []
|
||||
async for record in self.services.channel.get_recent_users(channel_uid):
|
||||
if record:
|
||||
results.append(record)
|
||||
return sorted(results, key=lambda x: safe_get(x, "nick", ""))
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to get online users: {safe_str(ex)}")
|
||||
return []
|
||||
|
||||
async def get_recent_users(self, channel_uid):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
return []
|
||||
try:
|
||||
results = []
|
||||
async for record in self.services.channel.get_recent_users(channel_uid):
|
||||
if record:
|
||||
results.append({
|
||||
"uid": safe_get(record, "uid", ""),
|
||||
"username": safe_get(record, "username", ""),
|
||||
"nick": safe_get(record, "nick", ""),
|
||||
"last_ping": safe_get(record, "last_ping"),
|
||||
})
|
||||
return results
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to get recent users: {safe_str(ex)}")
|
||||
return []
|
||||
|
||||
async def get_users(self, channel_uid):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
if not channel_uid or not isinstance(channel_uid, str):
|
||||
return []
|
||||
try:
|
||||
results = []
|
||||
async for record in self.services.channel.get_users(channel_uid):
|
||||
if record:
|
||||
results.append({
|
||||
"uid": safe_get(record, "uid", ""),
|
||||
"username": safe_get(record, "username", ""),
|
||||
"nick": safe_get(record, "nick", ""),
|
||||
"last_ping": safe_get(record, "last_ping"),
|
||||
})
|
||||
return results
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to get users: {safe_str(ex)}")
|
||||
return []
|
||||
@@ -1,80 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from snek.view.rpc.utils import safe_get, safe_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseAction:
|
||||
@property
|
||||
def user_uid(self):
|
||||
try:
|
||||
if not self.view or not hasattr(self.view, "session"):
|
||||
return None
|
||||
session = self.view.session
|
||||
if session is None:
|
||||
return None
|
||||
if hasattr(session, "get"):
|
||||
return session.get("uid")
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@property
|
||||
def request(self):
|
||||
return safe_get(self.view, "request")
|
||||
|
||||
@property
|
||||
def is_logged_in(self):
|
||||
try:
|
||||
if not self.view or not hasattr(self.view, "session"):
|
||||
return False
|
||||
session = self.view.session
|
||||
if session is None:
|
||||
return False
|
||||
return bool(session.get("logged_in", False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _require_login(self):
|
||||
if not self.is_logged_in:
|
||||
raise PermissionError("Not logged in")
|
||||
|
||||
def _require_services(self):
|
||||
if not self.services:
|
||||
raise RuntimeError("Services not available")
|
||||
|
||||
async def _session_ensure(self):
|
||||
try:
|
||||
uid = await self.view.session_get("uid") if self.view else None
|
||||
if uid and uid not in self.user_session:
|
||||
self.user_session[uid] = {"said_hello": False}
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to ensure session: {safe_str(ex)}")
|
||||
|
||||
async def session_get(self, key, default=None):
|
||||
try:
|
||||
await self._session_ensure()
|
||||
uid = self.user_uid
|
||||
if not uid or uid not in self.user_session:
|
||||
return default
|
||||
return self.user_session[uid].get(key, default)
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to get session key {key}: {safe_str(ex)}")
|
||||
return default
|
||||
|
||||
async def session_set(self, key, value):
|
||||
try:
|
||||
await self._session_ensure()
|
||||
uid = self.user_uid
|
||||
if not uid:
|
||||
return False
|
||||
if uid not in self.user_session:
|
||||
self.user_session[uid] = {}
|
||||
self.user_session[uid][key] = value
|
||||
return True
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to set session key {key}: {safe_str(ex)}")
|
||||
return False
|
||||
@@ -1,24 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
|
||||
def safe_get(obj, key, default=None):
|
||||
if obj is None:
|
||||
return default
|
||||
try:
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
if hasattr(obj, "fields") and hasattr(obj, "__getitem__"):
|
||||
val = obj[key]
|
||||
return val if val is not None else default
|
||||
return getattr(obj, key, default)
|
||||
except (KeyError, TypeError, AttributeError):
|
||||
return default
|
||||
|
||||
|
||||
def safe_str(obj):
|
||||
if obj is None:
|
||||
return ""
|
||||
try:
|
||||
return str(obj)
|
||||
except Exception:
|
||||
return ""
|
||||
@@ -1,28 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.system.view import BaseView
|
||||
|
||||
|
||||
class ArchitectureView(BaseView):
|
||||
async def get(self):
|
||||
return await self.render_template("site/architecture.html")
|
||||
|
||||
|
||||
class DesignView(BaseView):
|
||||
async def get(self):
|
||||
return await self.render_template("site/design.html")
|
||||
|
||||
|
||||
class ApiView(BaseView):
|
||||
async def get(self):
|
||||
return await self.render_template("site/api.html")
|
||||
|
||||
|
||||
class BotsView(BaseView):
|
||||
async def get(self):
|
||||
return await self.render_template("site/bots.html")
|
||||
|
||||
|
||||
class ContributeView(BaseView):
|
||||
async def get(self):
|
||||
return await self.render_template("site/contribute.html")
|
||||
Reference in New Issue
Block a user