Compare commits
917
Commits
main
..
1eb9da64e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1eb9da64e2 | ||
|
|
89ca4716ee | ||
|
|
0ec40a7ccc | ||
|
|
7f2a5615b3 | ||
|
|
5b241038f1 | ||
|
|
8837e26b13 | ||
|
|
d713a4697c | ||
|
|
af62d24868 | ||
|
|
82ea496f0d | ||
|
|
70544cf488 | ||
|
|
1d0c835087 | ||
|
|
8f803b86d9 | ||
|
|
d5bff8b855 | ||
|
|
f129574b62 | ||
|
|
bfec6f9d69 | ||
|
|
20543245f8 | ||
|
|
38f027a9fb | ||
|
|
1bc7bbe81e | ||
|
|
da30590080 | ||
|
|
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 |
@@ -31,46 +31,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
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "Snek"
|
||||
version = "1.36.0"
|
||||
version = "1.31.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()
|
||||
|
||||
@@ -79,29 +79,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
|
||||
@@ -393,27 +370,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%;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -860,41 +835,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;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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");
|
||||
|
||||
@@ -401,19 +400,7 @@ textToLeetAdvanced(text) {
|
||||
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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -24,7 +24,7 @@ export class Socket extends EventHandler {
|
||||
_maxReconnectAttempts = 50;
|
||||
_reconnectDelay = 4000;
|
||||
_pendingCalls = new Map();
|
||||
_callTimeout = 300000;
|
||||
_callTimeout = 30000;
|
||||
_isDestroyed = false;
|
||||
|
||||
get isConnected() {
|
||||
|
||||
@@ -7,7 +7,6 @@ from app.cache import time_cache_async, time_cache
|
||||
from mistune import HTMLRenderer, Markdown
|
||||
from mistune.plugins.formatting import strikethrough
|
||||
from mistune.plugins.spoiler import spoiler
|
||||
from mistune.plugins.table import table
|
||||
from mistune.plugins.url import url
|
||||
from pygments import highlight
|
||||
from pygments.formatters import html
|
||||
@@ -81,7 +80,7 @@ class MarkdownRenderer(HTMLRenderer):
|
||||
|
||||
def render_markdown_sync(app, markdown_string):
|
||||
renderer = MarkdownRenderer(app, None)
|
||||
markdown = Markdown(renderer=renderer, plugins=[url, strikethrough, spoiler, table])
|
||||
markdown = Markdown(renderer=renderer, plugins=[url, strikethrough, spoiler])
|
||||
return markdown(markdown_string)
|
||||
|
||||
|
||||
|
||||
@@ -1,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 %}
|
||||
@@ -37,12 +37,10 @@
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
{% extends "app.html" %}
|
||||
|
||||
{% block body_class %}profile-page{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
<aside class="sidebar" id="channelSidebar">
|
||||
<h2>Navigation</h2>
|
||||
|
||||
@@ -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,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)}
|
||||
)
|
||||
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 ""
|
||||
Reference in New Issue
Block a user