Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9393962510 | ||
|
|
2c32e81638 | ||
|
|
0032c1804b | ||
|
|
a98ea65ef4 | ||
|
|
5218ede0f1 | ||
|
|
5b19ba2c2f | ||
|
|
687fc03e8c | ||
|
|
74272039c5 | ||
|
|
eadb86faf2 | ||
|
|
4fa18a1262 | ||
|
|
7d3178bd48 | ||
|
|
88d99fb578 | ||
|
|
8366a73575 | ||
|
|
dcdddeb1c5 | ||
|
|
d7840d3a86 | ||
|
|
6a40d0c9ce | ||
|
|
3d579b12d4 | ||
|
|
0d932b0915 | ||
|
|
2b763efef9 | ||
|
|
a0ea2261f4 | ||
|
|
5bb1400917 | ||
|
|
63ec3a6152 | ||
|
|
3896c455b8 | ||
|
|
d97afbec18 | ||
|
|
21079f78dc | ||
|
|
3e922d8edb | ||
|
|
391487811b | ||
|
|
939bc37995 | ||
|
|
c2344824dc | ||
|
|
7cf1d577f3 | ||
|
|
ecf779763c | ||
|
|
1578a9da36 | ||
|
|
80e5f0c80d | ||
|
|
df8f2636d2 | ||
|
|
45543983c2 | ||
|
|
b531d4de3b | ||
|
|
eda5d3e633 | ||
|
|
e4ef52df8b | ||
|
|
3e1f1cb787 | ||
|
|
74da8ab98e | ||
|
|
19f7daff6c | ||
|
|
cf3af17243 | ||
|
|
93353db511 | ||
|
|
e7f93a5f47 | ||
|
|
c2ab881be2 | ||
|
|
4a84bba697 | ||
|
|
cda3b741a1 | ||
|
|
2e82febdcc | ||
|
|
bd7710d03c | ||
|
|
1a66d67e44 | ||
|
|
c1e2fe2c95 | ||
|
|
ea4ee4c4a7 | ||
|
|
fbc3e193bf | ||
|
|
3541f2f78b | ||
|
|
acc222da41 | ||
|
|
8c0e778388 | ||
|
|
c6c5352813 | ||
|
|
067d6fb59f | ||
|
|
c3e55bc853 | ||
|
|
09be82aad5 | ||
|
|
49781af921 | ||
|
|
604ba873dc | ||
|
|
923ff9b208 | ||
|
|
7e7a2e58a2 | ||
|
|
e27886e15d | ||
|
|
7fe8e676cd | ||
|
|
6803c7801f | ||
|
|
cab963dcdc | ||
|
|
001e54cfe2 | ||
|
|
5e257c0d3a | ||
|
|
19f0f27a3b | ||
|
|
6fbb8eebec | ||
|
|
32616c80ab | ||
|
|
3186be1e3e | ||
|
|
1329eb4fe2 | ||
|
|
913b2d428e | ||
|
|
5892a72575 | ||
|
|
d299a3c524 | ||
|
|
1c1ef06e29 | ||
|
|
bea62d75a9 | ||
|
|
e998b9ddd1 | ||
|
|
2f192447ce | ||
|
|
e89ed870dc | ||
|
|
6a722bfa17 | ||
|
|
a96aaa763f | ||
|
|
bbe05d7f57 | ||
|
|
da28b69461 | ||
|
|
cd469ae2bb | ||
|
|
9af49453f8 | ||
|
|
e543665704 | ||
|
|
39c0696e53 | ||
|
|
c6cc4ab963 | ||
|
|
4dfe71f392 | ||
|
|
cf05c6580d | ||
|
|
d544ebd879 | ||
|
|
7e0e8ff319 | ||
|
|
613a9860a0 | ||
|
|
92f0277dad | ||
|
|
749693078b | ||
|
|
b566a34725 | ||
|
|
3ab6c18a76 | ||
|
|
52458eb223 | ||
|
|
790946d89e | ||
|
|
30c5d9ae11 | ||
|
|
12e60629fe | ||
|
|
6c0d2932c3 | ||
|
|
ce19c7e2d1 | ||
|
|
93c9dde572 | ||
|
|
d46324cb6a | ||
|
|
f153ca5bf4 | ||
|
|
62e950f5c4 | ||
|
|
2b8bcd10b8 | ||
|
|
4c2ee19957 | ||
|
|
8217d13cd6 | ||
|
|
a2a887b898 | ||
|
|
1620de9a01 | ||
|
|
b6c6887842 | ||
|
|
ef4b493e65 | ||
|
|
50dd1951c4 | ||
|
|
b3894e53e4 | ||
|
|
ded044b4c0 | ||
|
|
bce78c4f28 | ||
|
|
75586d8883 | ||
|
|
f2fc335221 | ||
|
|
1c9e55d797 | ||
|
|
25056602d8 | ||
|
|
bd2e67059f | ||
|
|
fe34edfe73 | ||
|
|
4706be53ab | ||
|
|
4bd547f06a | ||
|
|
0250264fa0 | ||
|
|
3aede8fe95 | ||
|
|
e90988ea5c | ||
|
|
69d3400310 | ||
|
|
6a44da1f31 | ||
|
|
77c8785ec0 | ||
|
|
f2c0a9f6d5 | ||
|
|
f77a6ea46d | ||
|
|
a92ad32b23 | ||
|
|
a6af8f8415 | ||
|
|
da9db7b7f8 | ||
|
|
11048a6ed8 | ||
|
|
c3381bdd31 | ||
|
|
53f1cec401 | ||
|
|
bf46a15446 | ||
|
|
0df52d0235 | ||
|
|
0c167c8f09 | ||
|
|
804ccdabec | ||
|
|
5833b18052 | ||
|
|
f30e729f1f | ||
|
|
1e63431ae3 | ||
|
|
072e7273c4 | ||
|
|
df079138ee | ||
|
|
87f32114d7 | ||
|
|
b330e604f6 | ||
|
|
827d69231c | ||
|
|
0623850c3f | ||
|
|
4c77d129e7 | ||
|
|
c68cb97964 | ||
|
|
59fa301344 | ||
|
|
23784d00fe | ||
|
|
17cff74b7c | ||
|
|
2a3a3c21ba | ||
|
|
ba7811621e | ||
|
|
340afe1b80 | ||
|
|
11b7823188 | ||
|
|
de1763cfe1 | ||
|
|
d30b069876 | ||
|
|
0d68da9624 | ||
|
|
4d857b386b | ||
|
|
be7a3bc153 | ||
|
|
5c71fc0de2 | ||
|
|
82cc33587c | ||
|
|
121f3c6ddf | ||
|
|
3410eeaec1 | ||
|
|
85ecd644b8 | ||
|
|
7cffb4667f | ||
|
|
0449ded505 | ||
|
|
e15cbffdb1 | ||
|
|
9674c674a2 | ||
|
|
bafc5f4709 | ||
|
|
a60de46173 | ||
|
|
1165dc3fa6 | ||
|
|
f5ab18a67b | ||
|
|
65db4aaff6 | ||
|
|
d9a23235e5 | ||
|
|
00b54e1f15 | ||
|
|
3c4ff66150 | ||
|
|
2b3078fcce | ||
|
|
6f4074e8d6 | ||
|
|
235ee25d9f | ||
|
|
d1b9990bac | ||
|
|
60d1ba4efc | ||
|
|
29b6e6eb24 | ||
|
|
aa65972483 | ||
|
|
ad275a7c83 | ||
|
|
02b57ead00 | ||
|
|
7f484fa7b3 | ||
|
|
3ea906a74d | ||
|
|
939c40ccbf | ||
|
|
f0eb291f56 | ||
|
|
46391b10e2 | ||
|
|
5918a6cc71 | ||
|
|
0eeabc7b3a | ||
|
|
31eb5c06ad | ||
|
|
60d8c297f9 | ||
|
|
dab70d5a97 | ||
|
|
520a6289a8 | ||
|
|
7adca53116 | ||
|
|
fbc3e6c084 | ||
|
|
19ba5c8f11 | ||
|
|
ce0d4dccf7 | ||
|
|
7c874cd4aa | ||
|
|
17224b4f74 | ||
|
|
40414264be | ||
|
|
d9a4a007e2 | ||
|
|
d3925cad22 | ||
|
|
443e2e62db | ||
|
|
4b7c58a6c9 | ||
|
|
19aa2ec9c9 | ||
|
|
c4c5b8e3a0 | ||
|
|
33a7dbc0e6 | ||
|
|
72921b9a8f | ||
|
|
835436fa1a | ||
|
|
648f4ba4bc | ||
|
|
f5023dc46b | ||
|
|
4c0a798050 | ||
|
|
cfe7c4ec5f | ||
|
|
c82779e15b | ||
|
|
9e56715e77 | ||
|
|
2254b9ad19 | ||
|
|
81707d9c7c | ||
|
|
9e61a3d8a8 | ||
|
|
8a38524774 | ||
|
|
87d3f1b410 | ||
|
|
1094a65852 | ||
|
|
0ab4b11f97 | ||
|
|
6d4fb5888f | ||
|
|
e7301e2c48 | ||
|
|
f69f6546f0 | ||
|
|
7fe53993aa | ||
|
|
028f0680c6 | ||
|
|
b3f67a5398 | ||
|
|
db3611487e | ||
|
|
f2c722bdfc | ||
|
|
ccb125ab9d | ||
|
|
a300a4a9c0 | ||
|
|
044cb4de7e | ||
|
|
3405d12696 | ||
|
|
11cd35373e | ||
|
|
5001185bf8 | ||
|
|
a7f298743d | ||
|
|
8192eaf55b | ||
|
|
5a0e0033ec | ||
|
|
7521f89ffd | ||
|
|
d8a1da1f13 | ||
|
|
3b846f0d44 | ||
|
|
f5006af6c0 | ||
|
|
3152631911 | ||
|
|
1d9f069f45 | ||
|
|
707f569f62 | ||
|
|
e3643ee9a0 | ||
|
|
49cf5ebad8 | ||
|
|
b79f5f4d7d | ||
|
|
d870915dc0 | ||
|
|
fcd494f815 | ||
|
|
a7728f80d0 | ||
|
|
9eb6387973 | ||
|
|
7be5510208 | ||
|
|
e0269bbf86 | ||
|
|
e76558d68b | ||
|
|
cceba2e98e | ||
|
|
1e93d25a40 | ||
|
|
74ef9d31b4 | ||
|
|
15b3f9c084 | ||
|
|
87efa6c77b | ||
|
|
07b35036e7 | ||
|
|
c9c12b9ed6 | ||
|
|
960754c54e | ||
|
|
51c9c8b4f0 | ||
|
|
fff7e90e14 | ||
|
|
11429e887e | ||
|
|
820548cb2d | ||
|
|
e2038085ca | ||
|
|
3676bf1ae7 | ||
|
|
57f766dda2 | ||
|
|
da8f2d08df | ||
|
|
9579f019d4 | ||
|
|
7c27337e0a | ||
|
|
2dffce4244 | ||
|
|
27a9f0aff8 | ||
|
|
67920040ea | ||
|
|
fd1b910236 | ||
|
|
09ba53d185 | ||
|
|
91e79388d1 | ||
|
|
cb5d59571e | ||
|
|
365c6c7858 | ||
|
|
6886a90ee4 | ||
|
|
7d1c9ee373 | ||
|
|
dc05595b9b | ||
|
|
d2581b1c52 | ||
|
|
eb37ed4c74 | ||
|
|
6800734136 | ||
|
|
d4b7da3a54 | ||
|
|
8382fa9232 | ||
|
|
071b9f2b25 | ||
|
|
1e8ffa15d0 | ||
|
|
fc7f97c136 | ||
|
|
99732211b7 | ||
|
|
aaa8e957f6 | ||
|
|
30fb774d93 | ||
|
|
e0b5218037 | ||
|
|
387bdb6abc | ||
|
|
3298dc3e77 | ||
|
|
55dce99d34 | ||
|
|
87f9e23bae | ||
|
|
1e97dd9fe5 | ||
|
|
a05c54fc66 | ||
|
|
aeb51b9ccc | ||
|
|
b72a23f1e1 | ||
|
|
4d96a3e646 | ||
|
|
fc8e573243 | ||
|
|
f80e336e1f | ||
|
|
2a1ff8e45e | ||
|
|
e9ad259957 | ||
|
|
86ddceb646 | ||
|
|
a1725a6872 | ||
|
|
af7cee5014 | ||
|
|
b6ee5e8790 | ||
|
|
46520fb37c | ||
|
|
18ba00c98a | ||
|
|
e065c85a3e | ||
|
|
7401649841 | ||
|
|
a96007d48a | ||
|
|
2169fd8b50 | ||
|
|
2b7d1f0be8 | ||
|
|
eec5635541 | ||
|
|
e21a0758c3 | ||
|
|
defbac43d7 | ||
|
|
94612e11f2 | ||
|
|
11187487b4 | ||
|
|
f31540afdb | ||
|
|
6afbb72a34 | ||
|
|
af66103111 | ||
|
|
92277b2e27 | ||
|
|
475a1953d1 | ||
|
|
8af59750c6 | ||
|
|
00a0792c35 | ||
|
|
b9c0dfb9a1 | ||
|
|
9679190709 | ||
|
|
c8d4b3565c | ||
|
|
fe976022ec | ||
|
|
6a367c1dec | ||
|
|
9f338c3416 | ||
|
|
a575cb057b | ||
|
|
116b0a5c08 | ||
|
|
ed6b214b29 | ||
|
|
7d8bbbc904 | ||
|
|
763c4ad1bb | ||
|
|
5243cc4284 | ||
|
|
5a015fe8b4 | ||
|
|
b759ddb0f5 | ||
|
|
57821ba2a9 | ||
|
|
410cb89f3e | ||
|
|
e01f56bc34 | ||
|
|
ff59eda3d7 | ||
|
|
b7e497e241 | ||
|
|
7a81a2f20b | ||
|
|
990eba1e1e | ||
|
|
6f9a87cf98 | ||
|
|
0a9d929181 | ||
|
|
7dc4806be7 | ||
|
|
8783129c16 | ||
|
|
50d78c8746 | ||
|
|
63784bc036 | ||
|
|
aa5a9496ed | ||
|
|
00f8e5fb20 | ||
|
|
ca8df10eab | ||
|
|
b461838c0d | ||
|
|
50857d5283 | ||
|
|
449bc0c780 | ||
|
|
e1223f8c57 | ||
|
|
7822ec1b09 | ||
|
|
81f5168726 | ||
|
|
77f2420ae3 | ||
|
|
4de730bf5a | ||
|
|
77da0d97c0 | ||
|
|
e8b395cf2f | ||
|
|
d15b1be3b9 | ||
|
|
8a6b2704d4 | ||
|
|
ce610b4804 | ||
|
|
4d0241a0ea | ||
|
|
2c1e86863d | ||
|
|
16b11327ee | ||
|
|
3d5998b8fb | ||
|
|
2df4d2d770 | ||
|
|
4ce78f277e | ||
|
|
d7d5c2b1ef | ||
|
|
4958e1fdfd | ||
|
|
258b2f4dec | ||
|
|
338008bbd0 | ||
|
|
829758dc44 | ||
|
|
9deb98bbe7 | ||
|
|
26af92b5ac | ||
|
|
87b2443a06 | ||
|
|
aabc0e312d | ||
|
|
d15d58df36 | ||
|
|
21f2ac4534 | ||
|
|
dd0e6e75af | ||
|
|
0251f939d7 | ||
|
|
a00bc0f345 | ||
|
|
ae4ce43e1c | ||
|
|
19ed8916f4 | ||
|
|
f7de08bfd7 | ||
|
|
6e42f67931 | ||
|
|
b87b6cd378 | ||
|
|
b39fb73c30 | ||
|
|
3120884ed5 | ||
|
|
c927244b1a | ||
|
|
e049f38c7c | ||
|
|
ddc6ee0024 | ||
|
|
fa5f1460ab | ||
|
|
9f556f3e04 | ||
|
|
70b618a94b | ||
|
|
0610156f80 | ||
|
|
587b39de1d | ||
|
|
e35885b54b | ||
|
|
c1b7ace876 | ||
|
|
f416b0aed1 | ||
|
|
c8192e4427 | ||
|
|
dae1a6889b | ||
|
|
4740666d63 | ||
|
|
d6b703debb | ||
|
|
f1113eb80a | ||
|
|
604f0d9656 | ||
|
|
8ce5a92625 | ||
|
|
72b6e93c94 | ||
|
|
d60952718e | ||
|
|
53dcc59770 | ||
|
|
5ec179b6d6 | ||
|
|
250df445f0 | ||
|
|
12376e5070 | ||
|
|
c2afc6107a | ||
|
|
77370eb963 | ||
|
|
94b0787cad | ||
|
|
9c499fe3c2 | ||
|
|
7e26f9da9b | ||
|
|
778615a97f | ||
|
|
651572f15f | ||
|
|
5ccb8ddabe | ||
|
|
86f944e544 | ||
|
|
bc29f5ebd6 | ||
|
|
9a84be85c6 | ||
|
|
c08ac854b2 | ||
|
|
807a8933b9 | ||
|
|
b9cb1fec06 | ||
|
|
eab803652b | ||
|
|
259d0646bc | ||
|
|
4f1b7d0832 | ||
|
|
1aa529ab23 | ||
|
|
a46502380c | ||
|
|
85c276bcf5 | ||
|
|
55f8db11ce | ||
|
|
79af61a48e | ||
|
|
69d2e2a899 | ||
|
|
069ad967b5 | ||
|
|
18a6b3e18d | ||
|
|
3b9043332c | ||
|
|
d94b57bd0b | ||
|
|
5247092ca2 | ||
|
|
049789c9c5 | ||
|
|
6b74ae4a8e | ||
|
|
c13c01ea43 | ||
|
|
48e7d3569f | ||
|
|
4f3b45cbd8 | ||
|
|
a7ec55d741 | ||
|
|
0701af0f35 | ||
|
|
c0851c5339 | ||
|
|
785b6c2d41 | ||
|
|
cce3fd4f58 | ||
|
|
0dd6beebb2 | ||
|
|
8fb2a76698 | ||
|
|
3d6713caa9 | ||
|
|
8d8fdce519 | ||
|
|
c385a1466c | ||
|
|
8e1fdb33d2 | ||
|
|
29222be1fa | ||
|
|
08aca3b28d | ||
|
|
658139d607 | ||
|
|
1d4abf7977 | ||
|
|
90ab186a7c | ||
|
|
62e8bc08d5 | ||
|
|
dc725ccd68 | ||
|
|
d88aee03bb | ||
|
|
475a30caf4 | ||
|
|
c1593d7868 | ||
|
|
0dbcfe3770 | ||
|
|
e3ffb87cfc | ||
|
|
b4218ec6a7 | ||
|
|
48f8656ef8 | ||
|
|
cee8491b3d | ||
|
|
4bc3f3b4b3 | ||
|
|
adb1105621 | ||
|
|
c8fd79e4ba | ||
|
|
52bc9f71cc | ||
|
|
a1d3db0e16 | ||
|
|
d0e34126e1 | ||
|
|
c96ebea077 | ||
|
|
f00a72ad15 | ||
|
|
22171eb66f | ||
|
|
061fdeb72b | ||
|
|
8eba23574b | ||
|
|
7b650a74a6 | ||
|
|
805bd4cf60 | ||
|
|
56a32d493f | ||
|
|
b24a685066 | ||
|
|
fb06fbd0e1 | ||
|
|
719369057f | ||
|
|
cea9d98155 | ||
|
|
6cf15e0698 | ||
|
|
2971b9ad2e | ||
|
|
3e86fc76b6 | ||
|
|
829a18715a | ||
|
|
844c8e12a7 | ||
|
|
ef75f905f4 | ||
|
|
cd816cbe5c | ||
|
|
2b0fa3a314 | ||
|
|
ead990e61c | ||
|
|
eb6e343e4c | ||
|
|
caf5b88eef |
@@ -1,10 +1,26 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.gitattributes
|
||||
LICENSE
|
||||
README.md
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
scratch_*.py
|
||||
scratch/
|
||||
.claude/
|
||||
*.local.*
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
docs-site/node_modules/
|
||||
docs-site/.astro/
|
||||
docs-site/dist/
|
||||
docs-site/src/content/docs/
|
||||
deploy/compose/docker-compose-dev.yml
|
||||
data/*
|
||||
!data/tariffs.example.json
|
||||
!data/locales-overrides.example.json
|
||||
|
||||
|
||||
# CI
|
||||
@@ -14,9 +30,43 @@ README.md
|
||||
|
||||
# Docker
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
.docker
|
||||
deploy/compose/*.yml
|
||||
.dockerignore
|
||||
tmp/
|
||||
|
||||
# WebApp build artifacts (regenerated inside Docker)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
bot/app/web/templates/subscription_webapp.*.css
|
||||
bot/app/web/templates/subscription_webapp.min.*.js.br
|
||||
bot/app/web/templates/subscription_webapp.min.*.js.gz
|
||||
bot/app/web/templates/subscription_webapp.*.css.br
|
||||
bot/app/web/templates/subscription_webapp.*.css.gz
|
||||
bot/app/web/templates/subscription_webapp_admin.css
|
||||
bot/app/web/templates/subscription_webapp_admin.js
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp.css
|
||||
backend/bot/app/web/templates/subscription_webapp.js
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js.br
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js.gz
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.css
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.js
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
**/__pycache__/
|
||||
|
||||
@@ -1,147 +1,87 @@
|
||||
# Telegram Bot Token and Admin IDs
|
||||
BOT_TOKEN=your_bot_token_here # Telegram bot token
|
||||
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
|
||||
# Minimal bootstrap env.
|
||||
# Most product settings are configured later in Web App admin:
|
||||
# Admin -> System -> Settings, Admin -> System -> Tariffs, Admin -> Appearance.
|
||||
# Full reference: docs/env-vars.md
|
||||
|
||||
# PostgreSQL Database Connection Settings
|
||||
POSTGRES_USER=postgres # Database user name
|
||||
POSTGRES_PASSWORD=postgres # Database password
|
||||
POSTGRES_HOST=remnawave-tg-shop-db # Database container name
|
||||
POSTGRES_PORT=5432 # Port
|
||||
POSTGRES_DB=postgres # Database name
|
||||
# Telegram bot token from @BotFather.
|
||||
# Example: 1234567890:AA...
|
||||
BOT_TOKEN=your_bot_token_here
|
||||
|
||||
# Localization and Display
|
||||
DEFAULT_LANGUAGE="ru" # or "en"
|
||||
DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
|
||||
# Telegram numeric user IDs allowed to open the admin panel.
|
||||
# Use commas for several admins, for example: 123456789,987654321
|
||||
ADMIN_IDS=123456789
|
||||
|
||||
# External Links
|
||||
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
|
||||
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
|
||||
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
|
||||
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
|
||||
START_COMMAND_DESCRIPTION= # Description of the /start command
|
||||
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
|
||||
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu
|
||||
USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited)
|
||||
|
||||
# Required channel subscription
|
||||
REQUIRED_CHANNEL_ID= # Telegram channel ID (e.g. -1001234567890) the user must join
|
||||
REQUIRED_CHANNEL_LINK=https://t.me/your_channel # Optional: public link/invite button text opens
|
||||
|
||||
# Webhook Base URL (used for Telegram and payment providers)
|
||||
# Public HTTPS base URL of the backend webhook server.
|
||||
# Telegram, payment providers and Remnawave call webhook endpoints under this domain.
|
||||
# This is usually the backend/API domain, not the Mini App frontend domain.
|
||||
# Example: https://bot.yourdomain.tld
|
||||
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
|
||||
# Payment Method Toggles
|
||||
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
||||
FREEKASSA_ENABLED=True # Turn on FreeKassa
|
||||
STARS_ENABLED=True # Turn on STARS
|
||||
TRIBUTE_ENABLED=True # Turn on TRIBUTE
|
||||
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
||||
# PostgreSQL user created by Docker Compose and used by the backend.
|
||||
POSTGRES_USER=remnawave_minishop
|
||||
|
||||
# YooKassa Payment Gateway Configuration
|
||||
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
||||
YOOKASSA_SECRET_KEY=your_secret_key # Your secret key for YooKassa
|
||||
YOOKASSA_RETURN_URL=https://t.me/your_bot # URL to which the user will be returned after payment
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts
|
||||
YOOKASSA_VAT_CODE=1 # VAT code
|
||||
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
|
||||
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True # Force automatic card binding when autopay is enabled (set to False to show the save-card checkbox)
|
||||
# PostgreSQL password. Change it before production deploy.
|
||||
POSTGRES_PASSWORD=change_me
|
||||
|
||||
# FreeKassa Payment Gateway Configuration
|
||||
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
||||
FREEKASSA_API_KEY=your_api_key # API key for REST requests
|
||||
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
|
||||
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
|
||||
FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
|
||||
# PostgreSQL database name created by Docker Compose.
|
||||
POSTGRES_DB=remnawave_minishop
|
||||
|
||||
# CryptoBot Payment Gateway Configuration
|
||||
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
||||
CRYPTOPAY_NETWORK=mainnet # Network (mainnet or testnet)
|
||||
CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto)
|
||||
CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT
|
||||
# Enables the Web App and the Web App admin panel.
|
||||
# Keep True for the first setup. If set to False, the admin UI is unavailable
|
||||
# until you change it back to True in .env and restart the app.
|
||||
WEBAPP_ENABLED=True
|
||||
|
||||
# Tribute Payment Gateway Configuration
|
||||
TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures
|
||||
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
|
||||
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments
|
||||
# Stable secret for Web App sessions.
|
||||
# Generate with: openssl rand -hex 32
|
||||
# If empty, sessions are invalidated on every restart.
|
||||
WEBAPP_SESSION_SECRET=
|
||||
|
||||
# Subscription Options. Specify cost parameters or payment links here.
|
||||
1_MONTH_ENABLED=True
|
||||
RUB_PRICE_1_MONTH=150
|
||||
STARS_PRICE_1_MONTH=0
|
||||
TRIBUTE_LINK_1_MONTH=
|
||||
# Stable Telegram webhook secret_token.
|
||||
# Generate with: openssl rand -hex 32
|
||||
# If empty, a new token can be generated on process start.
|
||||
WEBHOOK_SECRET_TOKEN=
|
||||
|
||||
3_MONTHS_ENABLED=True
|
||||
RUB_PRICE_3_MONTHS=300
|
||||
STARS_PRICE_3_MONTHS=0
|
||||
TRIBUTE_LINK_3_MONTHS=
|
||||
# Public HTTPS URL of the Mini App frontend, with trailing slash.
|
||||
# This URL is opened by Telegram buttons and BotFather Mini App settings.
|
||||
# Do not put /api or webhook paths here.
|
||||
# Example: https://app.yourdomain.tld/
|
||||
SUBSCRIPTION_MINI_APP_URL=https://app.yourdomain.tld/
|
||||
|
||||
6_MONTHS_ENABLED=True
|
||||
RUB_PRICE_6_MONTHS=500
|
||||
STARS_PRICE_6_MONTHS=0
|
||||
TRIBUTE_LINK_6_MONTHS=
|
||||
# Remnawave panel API URL. Usually the panel domain plus /api.
|
||||
# Example: https://panel.yourdomain.tld/api
|
||||
PANEL_API_URL=https://panel.yourdomain.tld/api
|
||||
|
||||
12_MONTHS_ENABLED=True
|
||||
RUB_PRICE_12_MONTHS=900
|
||||
STARS_PRICE_12_MONTHS=0
|
||||
TRIBUTE_LINK_12_MONTHS=
|
||||
# Remnawave API key with permissions to manage users, subscriptions and squads.
|
||||
# Keep this secret. It can be overridden later in the admin panel if needed.
|
||||
PANEL_API_KEY=
|
||||
|
||||
# Subscription Notifications
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after
|
||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
|
||||
# Shared secret for validating incoming Remnawave webhooks.
|
||||
# Create or set this secret in Remnawave Panel, then paste the same value here
|
||||
# or into Admin -> System -> Settings -> Remnawave Panel.
|
||||
# In Remnawave Panel, set WEBHOOK_URL to WEBHOOK_BASE_URL + /webhook/panel,
|
||||
# for example: https://app.example.com/webhook/panel
|
||||
PANEL_WEBHOOK_SECRET=
|
||||
|
||||
|
||||
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
|
||||
# Referral Bonus Days
|
||||
# Bonus for the inviting user
|
||||
REFERRAL_BONUS_DAYS_1_MONTH=3
|
||||
REFERRAL_BONUS_DAYS_3_MONTHS=7
|
||||
REFERRAL_BONUS_DAYS_6_MONTHS=15
|
||||
REFERRAL_BONUS_DAYS_12_MONTHS=30
|
||||
# Invited User Bonus
|
||||
REFEREE_BONUS_DAYS_1_MONTH=1
|
||||
REFEREE_BONUS_DAYS_3_MONTHS=3
|
||||
REFEREE_BONUS_DAYS_6_MONTHS=7
|
||||
REFEREE_BONUS_DAYS_12_MONTHS=15
|
||||
|
||||
# Panel API Configuration
|
||||
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API
|
||||
PANEL_API_KEY=your_panel_api_key # Panel API key
|
||||
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
|
||||
|
||||
# User traffic limits (applied for all users)
|
||||
# 0 means unlimited
|
||||
USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited)
|
||||
USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH)
|
||||
|
||||
# Default Internal Squads for Users (Optional, comma-separated UUIDs)
|
||||
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
|
||||
|
||||
# Trial Settings
|
||||
TRIAL_ENABLED=True # Enable the trial period
|
||||
TRIAL_DURATION_DAYS=5 # Duration of the trial period in days
|
||||
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
|
||||
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
|
||||
|
||||
# Web Server Settings (for handling webhooks)
|
||||
WEB_SERVER_HOST="0.0.0.0"
|
||||
# Host port that publishes the backend webhook server from Docker Compose.
|
||||
# Your reverse proxy should route WEBHOOK_BASE_URL traffic to this port.
|
||||
WEB_SERVER_PORT=8080
|
||||
|
||||
# Admin Panel Log Pagination
|
||||
LOGS_PAGE_SIZE=10 # Number of events in the log
|
||||
# Host port that publishes the frontend nginx from Docker Compose.
|
||||
# Your reverse proxy should route SUBSCRIPTION_MINI_APP_URL traffic to this port.
|
||||
FRONTEND_PORT=8082
|
||||
|
||||
# Admin Logging Configuration
|
||||
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
||||
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
|
||||
LOG_NEW_USERS=True # Log new user registrations
|
||||
LOG_PAYMENTS=True # Log payments
|
||||
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
|
||||
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
||||
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
|
||||
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
|
||||
# Keep loopback for local proxy; add your proxy network if needed.
|
||||
TRUSTED_PROXIES=127.0.0.1,::1
|
||||
|
||||
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
|
||||
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
|
||||
INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png
|
||||
INLINE_FINANCIAL_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2769/2769339.png
|
||||
INLINE_SYSTEM_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2920/2920277.png
|
||||
# ─── Anonymous install telemetry (opt-out) ──────────────────────────────
|
||||
# Once a day the worker sends a single anonymous "heartbeat" so the project
|
||||
# maintainer can see how many installs are active and which versions/OSes are
|
||||
# used. It contains an opaque random install id and coarse facts only:
|
||||
# version, OS/arch, Python version, language, enabled payment providers and a
|
||||
# user-count RANGE (e.g. "51-200"). No bot token, domain, user data or any
|
||||
# personal information is ever sent. Full details: docs/configuration/telemetry.md
|
||||
#
|
||||
# Set to False to disable, or toggle it any time in Admin -> System ->
|
||||
# "Anonymous install analytics" (applies without a restart).
|
||||
TELEMETRY_ENABLED=True
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.gitattributes text eol=lf
|
||||
*.sh text eol=lf
|
||||
.github/workflows/*.yml text eol=lf
|
||||
deploy/docker/frontend/*.sh text eol=lf
|
||||
frontend/src/*.js text eol=lf
|
||||
frontend/src/**/*.js text eol=lf
|
||||
frontend/src/**/*.svelte text eol=lf
|
||||
frontend/scripts/*.mjs text eol=lf
|
||||
frontend/scripts/**/*.mjs text eol=lf
|
||||
@@ -0,0 +1,116 @@
|
||||
name: Docker build & push (reusable)
|
||||
|
||||
# Reusable workflow that builds the three image targets defined in
|
||||
# deploy/docker/Dockerfile (backend, worker, frontend) and optionally pushes
|
||||
# them to both ghcr.io and Docker Hub under the 3252a8/ namespace.
|
||||
#
|
||||
# Called by:
|
||||
# - docker-dev.yml (tag_mode: dev, push: true) on pushes to dev
|
||||
# - docker-release.yml (tag_mode: release, push: true) on pushes to main
|
||||
# - ci.yml (tag_mode: dev, push: false) on pull requests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
push:
|
||||
description: "Push the built images to the registries"
|
||||
type: boolean
|
||||
default: true
|
||||
tag_mode:
|
||||
description: "Tagging strategy: 'dev' or 'release'"
|
||||
type: string
|
||||
required: true
|
||||
|
||||
# No permissions block here on purpose: a reusable workflow cannot request more
|
||||
# than its caller grants, so the token scope is set by each caller
|
||||
# (docker-dev.yml / docker-release.yml grant packages: write to push; ci.yml
|
||||
# only needs contents: read for a no-push build).
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: backend
|
||||
image: remnawave-minishop-backend
|
||||
- target: worker
|
||||
image: remnawave-minishop-worker
|
||||
- target: frontend
|
||||
image: remnawave-minishop-frontend
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history + tags: the Dockerfile's version-builder stage runs
|
||||
# `git describe --tags` against the copied .git tree.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve release version
|
||||
id: version
|
||||
if: inputs.tag_mode == 'release'
|
||||
run: |
|
||||
# On a tag push github.ref_name is the tag (e.g. v3.4.5); for a
|
||||
# manual workflow_dispatch on a branch, fall back to the latest tag.
|
||||
if [ "${{ github.ref_type }}" = "tag" ]; then
|
||||
raw="${{ github.ref_name }}"
|
||||
else
|
||||
raw="$(git describe --tags --abbrev=0 2>/dev/null)"
|
||||
fi
|
||||
version="${raw#v}"
|
||||
if [ -z "$version" ]; then
|
||||
echo "::error::No git tag found to derive the release version from"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "Release version: ${version}"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: inputs.push
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: inputs.push
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
3252a8/${{ matrix.image }}
|
||||
ghcr.io/3252a8/${{ matrix.image }}
|
||||
tags: |
|
||||
type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }}
|
||||
type=raw,value=latest,enable=${{ inputs.tag_mode == 'release' }}
|
||||
type=raw,value=${{ steps.version.outputs.version }},enable=${{ inputs.tag_mode == 'release' }}
|
||||
|
||||
- name: Build${{ inputs.push && ' & push' || '' }} ${{ matrix.image }}
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: deploy/docker/Dockerfile
|
||||
target: ${{ matrix.target }}
|
||||
platforms: linux/amd64
|
||||
push: ${{ inputs.push }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# The Dockerfile's version-builder appends a "-<branch>" suffix to the
|
||||
# internal version string for non-main builds. Force "main" on release
|
||||
# (the ref is the tag, not a branch) so release images stay un-suffixed.
|
||||
build-args: |
|
||||
GITHUB_REF_NAME=${{ inputs.tag_mode == 'release' && 'main' || github.ref_name }}
|
||||
cache-from: type=gha,scope=${{ matrix.target }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.target }}
|
||||
provenance: false
|
||||
@@ -0,0 +1,64 @@
|
||||
name: PR checks
|
||||
|
||||
# Runs on pull requests into main (typically from dev) and into dev (typically
|
||||
# from feature/* branches): lint + format checks and a no-push image build to
|
||||
# prove the Docker images still build.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint & format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install ruff
|
||||
run: pip install "ruff>=0.8.0"
|
||||
|
||||
- name: Ruff lint (Python)
|
||||
run: ruff check .
|
||||
|
||||
- name: Ruff format check (Python)
|
||||
run: ruff format --check .
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend deps
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: ESLint (frontend)
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
|
||||
- name: Prettier check (frontend)
|
||||
run: npm run format:check
|
||||
working-directory: frontend
|
||||
|
||||
build:
|
||||
name: Docker build
|
||||
uses: ./.github/workflows/_docker-build-push.yml
|
||||
with:
|
||||
push: false
|
||||
tag_mode: dev
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Dependency review
|
||||
|
||||
# On PRs into main/dev, flag any newly added dependency that has a known
|
||||
# vulnerability or an incompatible license before it gets merged.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@v4
|
||||
with:
|
||||
fail-on-severity: high
|
||||
comment-summary-in-pr: on-failure
|
||||
@@ -1,68 +0,0 @@
|
||||
name: Build and Push Dev Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
env:
|
||||
GHCR_REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
${{ env.DOCKERHUB_IMAGE }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha,prefix={{branch}}-
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.meta.outputs.digest }}
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Dev images
|
||||
|
||||
# On every push to the dev branch, build all three images and push them to
|
||||
# ghcr.io and Docker Hub tagged `dev` and `dev-<short-sha>`.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: docker-dev-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
uses: ./.github/workflows/_docker-build-push.yml
|
||||
with:
|
||||
push: true
|
||||
tag_mode: dev
|
||||
secrets: inherit
|
||||
@@ -1,56 +0,0 @@
|
||||
name: Build and Publish multi-arch Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
|
||||
env:
|
||||
GHCR_IMAGE: ghcr.io/${{ github.repository }}
|
||||
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU (для эмуляции arm64 на x86)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push multi-arch Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.GHCR_IMAGE }}:latest
|
||||
${{ env.GHCR_IMAGE }}:${{ github.ref_name }}
|
||||
${{ env.DOCKERHUB_IMAGE }}:latest
|
||||
${{ env.DOCKERHUB_IMAGE }}:${{ github.ref_name }}
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Release images
|
||||
|
||||
# Build all three images and push them to ghcr.io and Docker Hub tagged
|
||||
# `latest` and the release version (the pushed tag with its leading `v`
|
||||
# stripped, e.g. v3.4.5 -> 3.4.5). Triggered only when a new v* tag is pushed,
|
||||
# so images are built once per release rather than on every commit to main.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: docker-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
uses: ./.github/workflows/_docker-build-push.yml
|
||||
with:
|
||||
push: true
|
||||
tag_mode: release
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,96 @@
|
||||
name: Security
|
||||
|
||||
# Audits the full dependency set (pip-audit, npm audit) and runs a Trivy
|
||||
# filesystem scan (dependencies + Dockerfile/IaC misconfig). Trivy results are
|
||||
# uploaded to the Security -> Code scanning tab.
|
||||
#
|
||||
# pip-audit / npm audit are informational (continue-on-error): they surface
|
||||
# upstream/transitive advisories that aren't necessarily fixable in a given PR,
|
||||
# so they report in the logs without blocking merges. The PR gate for newly
|
||||
# introduced vulnerable deps is dependency-review.yml.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
push:
|
||||
branches: [main, dev]
|
||||
schedule:
|
||||
- cron: "27 4 * * 1" # weekly, Monday 04:27 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: security-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
python-audit:
|
||||
name: pip-audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install pip-audit
|
||||
run: pip install pip-audit
|
||||
|
||||
- name: Audit Python dependencies
|
||||
continue-on-error: true
|
||||
run: pip-audit -r backend/requirements.txt
|
||||
|
||||
npm-audit:
|
||||
name: npm audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend deps
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Audit npm dependencies
|
||||
continue-on-error: true
|
||||
run: npm audit --audit-level=high
|
||||
working-directory: frontend
|
||||
|
||||
trivy:
|
||||
name: Trivy filesystem scan
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run Trivy
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
with:
|
||||
scan-type: fs
|
||||
scan-ref: .
|
||||
format: sarif
|
||||
output: trivy-results.sarif
|
||||
severity: CRITICAL,HIGH
|
||||
ignore-unfixed: true
|
||||
|
||||
- name: Upload Trivy results
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-fs
|
||||
@@ -3,6 +3,66 @@ bot_database.sqlite3
|
||||
|
||||
# Игнорировать файлы окружения
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
docker-compose-dev.yml
|
||||
scratch_*.py
|
||||
scratch/
|
||||
*.local.*
|
||||
node_modules/
|
||||
.git/
|
||||
|
||||
# Documentation site build artifacts
|
||||
docs-site/.astro/
|
||||
docs-site/dist/
|
||||
docs-site/public/demo/runtime/
|
||||
docs-site/src/content/docs/
|
||||
|
||||
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
bot/app/web/templates/subscription_webapp.*.css
|
||||
bot/app/web/templates/subscription_webapp.min.*.js.br
|
||||
bot/app/web/templates/subscription_webapp.min.*.js.gz
|
||||
bot/app/web/templates/subscription_webapp.*.css.br
|
||||
bot/app/web/templates/subscription_webapp.*.css.gz
|
||||
bot/app/web/templates/subscription_webapp_admin.css
|
||||
bot/app/web/templates/subscription_webapp_admin.js
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.css
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.js
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp.css
|
||||
backend/bot/app/web/templates/subscription_webapp.js
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js.br
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js.gz
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.css
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.js
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.css
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.js
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
|
||||
tmp
|
||||
.claude
|
||||
|
||||
# Игнорировать кэш Python
|
||||
__pycache__/
|
||||
@@ -17,3 +77,6 @@ __pycache__/
|
||||
locales/ru_backup.json
|
||||
locales/en_backup.json
|
||||
db/models_old.py
|
||||
data/*
|
||||
!data/tariffs.example.json
|
||||
!data/locales-overrides.example.json
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN rm -rf /root/.cache
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
@@ -1,7 +1,21 @@
|
||||
Copyright 2025 machka-pasla
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
Copyright (c) 2025-2026 machka-pasla, 3252a8 and other contributors
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -1,203 +1,150 @@
|
||||
# Telegram-бот для продажи подписок Remnawave
|
||||
# Remnawave Minishop
|
||||
|
||||
Этот Telegram-бот предназначен для автоматизации продажи и управления подписками для панели **Remnawave**. Он интегрируется с API Remnawave для управления пользователями и подписками, а также использует различные платежные системы для приема платежей.
|
||||

|
||||
|
||||
## ✨ Ключевые возможности
|
||||
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками панели [Remnawave](https://docs.rw/). Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
|
||||
|
||||
### Для пользователей:
|
||||
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
|
||||
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
|
||||
- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`).
|
||||
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
||||
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
||||
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
||||
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), CryptoPay, Telegram Stars и Tribute.
|
||||
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека и других ботов используйте [раздел миграций](docs/migrations/index.md).
|
||||
|
||||
### Для администраторов:
|
||||
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
|
||||
- **Статистика:** Просмотр статистики использования бота (общее количество пользователей, забаненные, активные подписки), недавние платежи и статус синхронизации с панелью.
|
||||
- **Управление пользователями:** Блокировка/разблокировка пользователей, просмотр списка забаненных и детальной информации о пользователе.
|
||||
- **Рассылка:** Отправка сообщений всем пользователям, пользователям с активной или истекшей подпиской.
|
||||
- **Управление промокодами:** Создание и просмотр промокодов.
|
||||
- **Синхронизация с панелью:** Ручной запуск синхронизации пользователей и подписок с панелью Remnawave.
|
||||
- **Логи действий:** Просмотр логов всех действий пользователей.
|
||||
## Возможности
|
||||
|
||||
## 🚀 Технологии
|
||||
Для пользователей:
|
||||
|
||||
- **Python 3.11**
|
||||
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
|
||||
- **aiohttp:** Для запуска веб-сервера (вебхуки).
|
||||
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
|
||||
- **YooKassa, FreeKassa API, aiocryptopay:** Интеграции с платежными системами.
|
||||
- **Pydantic:** Для управления настройками из `.env` файла.
|
||||
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
|
||||
- регистрация с выбором русского или английского языка;
|
||||
- просмотр статуса подписки, даты окончания, ссылки подключения и трафика;
|
||||
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
|
||||
- Web App / Mini App с входом через Telegram или email;
|
||||
- встроенные инструкции установки в Mini App: личный экран `/install` и публичная ссылка `/s/<token>` для передачи инструкции;
|
||||
- пробный период, промокоды и реферальная программа;
|
||||
- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Telegram Stars;
|
||||
- тикеты поддержки в Web App и внешняя ссылка на поддержку;
|
||||
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
|
||||
|
||||
## ⚙️ Установка и запуск
|
||||
Для администраторов:
|
||||
|
||||
### Предварительные требования
|
||||
- админ-панель для пользователей из `ADMIN_IDS` (только при входе через Telegram, не для аккаунтов только с email);
|
||||
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
|
||||
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
|
||||
- блокировка пользователей, поддержка через тикеты, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
|
||||
- редактор JSON-каталога тарифов с моделями на срок/по трафику, Internal Squads, premium-сквадами и HWID-пакетами;
|
||||
- настройки инструкций подключения: чтение конфига Subscription Page из Remnawave Panel, опциональное JSON-переопределение и переключатель поведения кнопок бота;
|
||||
- ручная синхронизация пользователей и подписок с панелью.
|
||||
|
||||
- Установленные Docker и Docker Compose.
|
||||
- Рабочая панель Remnawave.
|
||||
- Токен Telegram-бота.
|
||||
- Данные для подключения к платежным системам (YooKassa, CryptoPay и т.д.).
|
||||
## Документация
|
||||
|
||||
### Шаги установки
|
||||
- [Входная страница документации](docs/index.md) - маршрут по установке, настройке, платежам, админке и диагностике.
|
||||
- [Развертывание](docs/getting-started/deployment.md) - Docker Compose, Caddy, Nginx, Pangolin/Newt и запуск без обратного прокси.
|
||||
- [Настройка окружения](docs/getting-started/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
|
||||
- [Переменные `.env`](docs/configuration/env-vars.md) - полный справочник всех env-ключей по разделам.
|
||||
- [Бэкапы и восстановление](docs/features/backups.md) - автоматические архивы, Telegram-отправка и restore через админку.
|
||||
- [Тарифы](docs/features/tariffs.md) - каталог тарифов, модели на срок и по трафику, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
||||
- [Админ-панель](docs/features/admin-panel.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
|
||||
- [Веб-приложение / Mini App](docs/features/web-app.md) - отдельный порт, домен, инструкции установки и реферальные ссылки.
|
||||
- [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина.
|
||||
- [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
|
||||
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
||||
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с других ботов; сейчас описан `remnawave-tg-shop`.
|
||||
- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) - готовый сценарий для legacy-стека.
|
||||
|
||||
1. **Клонируйте репозиторий:**
|
||||
```bash
|
||||
git clone https://github.com/machka-pasla/remnawave-tg-shop
|
||||
cd remnawave-tg-shop
|
||||
```
|
||||
## Совместимость
|
||||
|
||||
2. **Создайте и настройте файл `.env`:**
|
||||
Скопируйте `env.example` в `.env` и заполните своими данными.
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
Ниже перечислены ключевые переменные.
|
||||
Интеграция с API панели Remnawave (вебхуки, пользователи, подписки, статистика в админке и т.д.) **протестирована** на панели Remnawave версии **`> 2.7.0`**. Более старые версии могут работать частично или не работать из‑за изменений в API.
|
||||
|
||||
<details>
|
||||
<summary><b>Основные настройки</b></summary>
|
||||
## Стек
|
||||
|
||||
| Переменная | Описание | Пример |
|
||||
| --- | --- | --- |
|
||||
| `BOT_TOKEN` | **Обязательно.** Токен вашего Telegram-бота. | `1234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11` |
|
||||
| `ADMIN_IDS` | **Обязательно.** ID администраторов в Telegram через запятую. | `12345678,98765432` |
|
||||
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
|
||||
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
|
||||
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
|
||||
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
|
||||
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
|
||||
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
|
||||
</details>
|
||||
Сборка и runtime задаются **deploy/docker/Dockerfile** и **docker-compose.yml**; точные версии пакетов — в **backend/requirements.txt** и **frontend/package.json**.
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки платежей и вебхуков</b></summary>
|
||||
| Слой | Технологии |
|
||||
| --- | --- |
|
||||
| Backend | Python **3.12**, [aiogram](https://docs.aiogram.dev/) 3.x (Telegram), **aiohttp** (HTTP и Web App), **SQLAlchemy** 2 async, **asyncpg**, **Pydantic** / pydantic-settings, **httpx**, платёжные SDK (в т.ч. YooKassa, aiocryptopay), **PyJWT** |
|
||||
| Данные | **PostgreSQL** **17** (сервис `postgres` в Compose) и **Redis** **7** (сервис `redis`) |
|
||||
| Сборка Web App | **Node.js** **22**, **Svelte** **5**, **Vite**, **Tailwind CSS** 4; артефакты попадают в шаблоны `backend/bot/app/web/templates/` |
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
|
||||
| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` |
|
||||
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
|
||||
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
|
||||
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
|
||||
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
|
||||
| `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). |
|
||||
| `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». |
|
||||
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
|
||||
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
||||
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
|
||||
| `FREEKASSA_MERCHANT_ID` | ID вашего магазина в FreeKassa. |
|
||||
| `FREEKASSA_API_KEY` | API-ключ для запросов к FreeKassa REST API. |
|
||||
| `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. |
|
||||
| `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. |
|
||||
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
|
||||
| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. |
|
||||
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
|
||||
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
|
||||
</details>
|
||||
Локальная разработка без Docker возможна при установленных Python 3.12, PostgreSQL и (для пересборки фронта) Node 22; типичный сценарий — всё через Compose.
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки подписок</b></summary>
|
||||
## Быстрый старт
|
||||
|
||||
Для каждого периода (1, 3, 6, 12 месяцев) можно настроить доступность и цены:
|
||||
- `1_MONTH_ENABLED`: `true` или `false`
|
||||
- `RUB_PRICE_1_MONTH`: Цена в рублях
|
||||
- `STARS_PRICE_1_MONTH`: Цена в Telegram Stars
|
||||
- `TRIBUTE_LINK_1_MONTH`: Ссылка для оплаты через Tribute
|
||||
Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`.
|
||||
</details>
|
||||
Требования:
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки панели Remnawave</b></summary>
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `PANEL_API_URL` | URL API вашей панели Remnawave. |
|
||||
| `PANEL_API_KEY` | API ключ для доступа к панели. |
|
||||
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
|
||||
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
|
||||
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
|
||||
| `USER_HWID_DEVICE_LIMIT`| Лимит устройств (HWID) для новых пользователей (0 - безлимит). |
|
||||
- Docker и Docker Compose;
|
||||
- рабочая панель Remnawave версии **`> 2.7.0`** (см. раздел «Совместимость»);
|
||||
- токен Telegram-бота;
|
||||
- публичные домены для webhook и Mini App.
|
||||
|
||||
> Раздел "Мои устройства" становится доступен пользователям только при включении `MY_DEVICES_SECTION_ENABLED`. Значение лимита устройств при создании записей в панели берётся из `USER_HWID_DEVICE_LIMIT`.
|
||||
</gidetails>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки пробного периода</b></summary>
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `TRIAL_ENABLED` | Включить/выключить пробный период (`true`/`false`). |
|
||||
| `TRIAL_DURATION_DAYS`| Длительность пробного периода в днях. |
|
||||
| `TRIAL_TRAFFIC_LIMIT_GB`| Лимит трафика для пробного периода в ГБ. |
|
||||
</details>
|
||||
|
||||
3. **Запустите контейнеры:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
Эта команда скачает образ и запустит сервис в фоновом режиме.
|
||||
|
||||
4. **Настройка вебхуков (Обязательно):**
|
||||
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Tribute) и панели Remnawave.
|
||||
|
||||
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
|
||||
|
||||
**Пути для перенаправления:**
|
||||
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
|
||||
- `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/freekassa`
|
||||
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
|
||||
- `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/tribute`
|
||||
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
|
||||
- **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/<BOT_TOKEN>`.
|
||||
|
||||
Где `remnawave-tg-shop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
|
||||
|
||||
5. **Просмотр логов:**
|
||||
```bash
|
||||
docker compose logs -f remnawave-tg-shop
|
||||
```
|
||||
|
||||
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
|
||||
|
||||
Для автоматической публикации образов настроены GitHub Actions (`.github/workflows`). По умолчанию образы пушатся в GitHub Container Registry и Docker Hub. Добавьте в Secrets репозитория значения `DOCKERHUB_USERNAME` и `DOCKERHUB_TOKEN` (персональный access token или пароль для Docker Hub), чтобы загрузка в Docker Hub работала корректно.
|
||||
|
||||
## 📁 Структура проекта
|
||||
|
||||
```
|
||||
.
|
||||
├── bot/
|
||||
│ ├── filters/ # Пользовательские фильтры Aiogram
|
||||
│ ├── handlers/ # Обработчики сообщений и колбэков
|
||||
│ ├── keyboards/ # Клавиатуры
|
||||
│ ├── middlewares/ # Промежуточные слои (i18n, проверка бана)
|
||||
│ ├── services/ # Бизнес-логика (платежи, API панели)
|
||||
│ ├── states/ # Состояния FSM
|
||||
│ └── main_bot.py # Основная логика бота
|
||||
├── config/
|
||||
│ └── settings.py # Настройки Pydantic
|
||||
├── db/
|
||||
│ ├── dal/ # Слой доступа к данным (DAL)
|
||||
│ ├── database_setup.py # Настройка БД
|
||||
│ └── models.py # Модели SQLAlchemy
|
||||
├── locales/ # Файлы локализации (ru, en)
|
||||
├── .env.example # Пример файла с переменными окружения
|
||||
├── Dockerfile # Инструкции для сборки Docker-образа
|
||||
├── docker-compose.yml # Файл для оркестрации контейнеров
|
||||
├── requirements.txt # Зависимости Python
|
||||
└── main.py # Точка входа в приложение
|
||||
```bash
|
||||
git clone https://github.com/3252a8/remnawave-minishop
|
||||
cd remnawave-minishop
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
docker compose up -d --build
|
||||
docker compose logs -f backend worker frontend
|
||||
```
|
||||
|
||||
## 🔮 Планы на будущее
|
||||
Минимально заполните в `.env`:
|
||||
|
||||
- Расширенные типы промокодов (например, скидки в процентах).
|
||||
- `BOT_TOKEN` - токен Telegram-бота;
|
||||
- `ADMIN_IDS` - Telegram ID администраторов через запятую;
|
||||
- `WEBHOOK_BASE_URL` - публичный URL вебхуков;
|
||||
- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` - доступы PostgreSQL;
|
||||
- `WEBAPP_ENABLED=True` - включает Web App и админку для первого входа;
|
||||
- `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN` - стабильные секреты;
|
||||
- `SUBSCRIPTION_MINI_APP_URL` - публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`;
|
||||
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
|
||||
- остальные настройки удобнее задать в Web App админке.
|
||||
|
||||
## ❤️ Поддержка
|
||||
- Карты РФ и зарубежные: [Tribute](https://t.me/tribute/app?startapp=dqdg)
|
||||
- Crypto: `USDT TRC-20 TT3SqBbfU4vYm6SUwUVNZsy278m2xbM4GE`
|
||||
В Remnawave Panel укажите `WEBHOOK_URL` как публичный адрес Minishop с путем `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет вебхука задается в самой Remnawave Panel; это же значение вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или в **Система -> Настройки -> Remnawave Panel** в админке.
|
||||
|
||||
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/configuration/env-vars.md](docs/configuration/env-vars.md).
|
||||
|
||||
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md).
|
||||
|
||||
В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
|
||||
|
||||
```bash
|
||||
mkdir -p data/themes data/webapp-logo data/webapp-emoji
|
||||
touch data/locales-overrides.json
|
||||
chown -R 10001:10001 data
|
||||
chmod -R u+rwX data
|
||||
```
|
||||
|
||||
## Полезные команды
|
||||
|
||||
```bash
|
||||
# Локальная сборка и запуск
|
||||
docker compose up -d --build
|
||||
|
||||
# Логи приложения
|
||||
docker compose logs -f backend worker frontend
|
||||
|
||||
# Рекомендуемый продакшен-вариант с Caddy
|
||||
cd deploy/examples/caddy # или nginx, newt, no-proxy
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
docker compose up -d
|
||||
|
||||
# Запуск из готового образа с конкретным тегом
|
||||
IMAGE_TAG=3.1.0 docker compose up -d
|
||||
```
|
||||
|
||||
Для продакшен-запуска удобнее брать готовые папки из [`deploy/examples`](deploy/examples), а читать каноничные инструкции в [docs/getting-started/deployment.md](docs/getting-started/deployment.md). Предпочтительный вариант для обычного публичного сервера - Caddy: он сам выпускает и продлевает HTTPS-сертификаты. В папках рядом с compose лежат только конфиги и короткие ссылки на документацию.
|
||||
|
||||
Имена образов для релизов:
|
||||
|
||||
- `ghcr.io/3252a8/remnawave-minishop-backend`
|
||||
- `ghcr.io/3252a8/remnawave-minishop-worker`
|
||||
- `ghcr.io/3252a8/remnawave-minishop-frontend`
|
||||
- `docker.io/3252a8/remnawave-minishop-backend`
|
||||
- `docker.io/3252a8/remnawave-minishop-worker`
|
||||
- `docker.io/3252a8/remnawave-minishop-frontend`
|
||||
|
||||
Сборка и публикация сразу в GHCR и Docker Hub:
|
||||
|
||||
```bash
|
||||
docker login ghcr.io
|
||||
docker login docker.io
|
||||
IMAGE_TAG=v3.4.3 bash scripts/docker-build-push-images.sh
|
||||
```
|
||||
|
||||
## Поддержать проект
|
||||
|
||||
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)`
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
level = getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO)
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
stream=sys.stdout,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
@@ -1,23 +1,33 @@
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.db_session import DBSessionMiddleware
|
||||
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n
|
||||
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
||||
try:
|
||||
from aiogram.fsm.storage.redis import RedisStorage
|
||||
except ModuleNotFoundError: # pragma: no cover - dependency is installed in Docker image
|
||||
RedisStorage = None # type: ignore[assignment]
|
||||
|
||||
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
|
||||
from bot.middlewares.profile_sync import ProfileSyncMiddleware
|
||||
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
||||
from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
|
||||
from bot.middlewares.db_session import DBSessionMiddleware
|
||||
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance
|
||||
from bot.middlewares.profile_sync import ProfileSyncMiddleware
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) -> tuple[Dispatcher, Bot, Dict]:
|
||||
storage = MemoryStorage()
|
||||
def build_dispatcher(
|
||||
settings: Settings, async_session_factory: sessionmaker
|
||||
) -> tuple[Dispatcher, Bot, Dict]:
|
||||
storage = (
|
||||
RedisStorage.from_url(settings.REDIS_URL)
|
||||
if settings.REDIS_URL and RedisStorage is not None
|
||||
else MemoryStorage()
|
||||
)
|
||||
default_props = DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=default_props)
|
||||
|
||||
@@ -32,8 +42,9 @@ def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) ->
|
||||
dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings))
|
||||
dp.update.outer_middleware(ProfileSyncMiddleware())
|
||||
dp.update.outer_middleware(BanCheckMiddleware(settings=settings, i18n_instance=i18n_instance))
|
||||
dp.update.outer_middleware(ChannelSubscriptionMiddleware(settings=settings, i18n_instance=i18n_instance))
|
||||
dp.update.outer_middleware(
|
||||
ChannelSubscriptionMiddleware(settings=settings, i18n_instance=i18n_instance)
|
||||
)
|
||||
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings))
|
||||
|
||||
return dp, bot, {"i18n_instance": i18n_instance}
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.payment_providers import (
|
||||
ServiceFactoryContext,
|
||||
build_provider_configs,
|
||||
build_provider_services,
|
||||
)
|
||||
from bot.services.email_auth_service import EmailAuthService
|
||||
from bot.services.lknpd_service import LknpdService
|
||||
from bot.services.notification_service import NotificationService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.panel_dry_run_api_service import PanelDryRunApiService
|
||||
from bot.services.panel_webhook_service import PanelWebhookService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.support_service import SupportService
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
def build_core_services(
|
||||
settings: Settings,
|
||||
bot: Bot,
|
||||
async_session_factory: sessionmaker,
|
||||
i18n: JsonI18n,
|
||||
bot_username_for_default_return: str,
|
||||
):
|
||||
panel_service = (
|
||||
PanelDryRunApiService(settings)
|
||||
if bool(getattr(settings, "panel_dry_run_enabled", False))
|
||||
else PanelApiService(settings)
|
||||
)
|
||||
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
|
||||
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
|
||||
email_auth_service = EmailAuthService(settings, i18n)
|
||||
notification_service = NotificationService(
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
session_factory=async_session_factory,
|
||||
email_auth_service=email_auth_service,
|
||||
bot_username=bot_username_for_default_return,
|
||||
)
|
||||
support_service = SupportService(
|
||||
async_session_factory,
|
||||
settings,
|
||||
bot,
|
||||
i18n,
|
||||
notification_service,
|
||||
email_auth_service,
|
||||
)
|
||||
panel_webhook_service = PanelWebhookService(
|
||||
bot, settings, i18n, async_session_factory, panel_service
|
||||
)
|
||||
provider_configs = build_provider_configs()
|
||||
payment_services = build_provider_services(
|
||||
ServiceFactoryContext(
|
||||
settings=settings,
|
||||
bot=bot,
|
||||
async_session_factory=async_session_factory,
|
||||
i18n=i18n,
|
||||
bot_username_for_default_return=bot_username_for_default_return,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
provider_configs=provider_configs,
|
||||
)
|
||||
)
|
||||
lknpd_service = LknpdService(
|
||||
settings.LKNPD_INN,
|
||||
settings.LKNPD_PASSWORD,
|
||||
api_url=settings.LKNPD_API_URL,
|
||||
)
|
||||
|
||||
# These attachments are critical for auto-renew and panel pre-expiry hooks.
|
||||
subscription_service.yookassa_service = payment_services.get("yookassa_service")
|
||||
panel_webhook_service.subscription_service = subscription_service
|
||||
|
||||
services = {
|
||||
"panel_service": panel_service,
|
||||
"subscription_service": subscription_service,
|
||||
"referral_service": referral_service,
|
||||
"promo_code_service": promo_code_service,
|
||||
"notification_service": notification_service,
|
||||
"email_auth_service": email_auth_service,
|
||||
"support_service": support_service,
|
||||
"panel_webhook_service": panel_webhook_service,
|
||||
"lknpd_service": lknpd_service,
|
||||
}
|
||||
services.update(payment_services)
|
||||
return services
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Compatibility facade for the admin Mini App API."""
|
||||
|
||||
# ruff: noqa: I001
|
||||
|
||||
from bot.app.web.admin_api_impl import (
|
||||
_runtime as _runtime,
|
||||
ads as _ads,
|
||||
auth as _auth,
|
||||
backups as _backups,
|
||||
broadcast as _broadcast,
|
||||
common as _common,
|
||||
logs as _logs,
|
||||
panel as _panel,
|
||||
payments as _payments,
|
||||
promos as _promos,
|
||||
routes as _routes,
|
||||
settings as _settings,
|
||||
stats as _stats,
|
||||
support as _support,
|
||||
sync as _sync,
|
||||
tariffs as _tariffs,
|
||||
themes as _themes,
|
||||
translations as _translations,
|
||||
users as _users,
|
||||
)
|
||||
|
||||
_MODULES = (
|
||||
_runtime,
|
||||
_auth,
|
||||
_common,
|
||||
_stats,
|
||||
_users,
|
||||
_payments,
|
||||
_promos,
|
||||
_logs,
|
||||
_support,
|
||||
_broadcast,
|
||||
_sync,
|
||||
_ads,
|
||||
_backups,
|
||||
_settings,
|
||||
_tariffs,
|
||||
_themes,
|
||||
_translations,
|
||||
_panel,
|
||||
_routes,
|
||||
)
|
||||
|
||||
_NAMESPACE = {}
|
||||
for _module in _MODULES:
|
||||
_NAMESPACE.update(
|
||||
{
|
||||
_name: _value
|
||||
for _name, _value in vars(_module).items()
|
||||
if not _name.startswith("__") and _name != "annotations"
|
||||
}
|
||||
)
|
||||
|
||||
for _module in _MODULES:
|
||||
vars(_module).update(_NAMESPACE)
|
||||
|
||||
globals().update(_NAMESPACE)
|
||||
|
||||
__all__ = sorted(_name for _name in _NAMESPACE if not _name.startswith("__"))
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain modules for the admin Mini App API."""
|
||||
@@ -0,0 +1,67 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
"""HTTP API powering the admin section of the subscription Mini App.
|
||||
|
||||
All routes require an authenticated webapp session (cookie or Bearer
|
||||
token) AND the resolved Telegram user id must appear in
|
||||
``settings.ADMIN_IDS``. Authorization is enforced via the
|
||||
``_require_admin_user_id`` helper, never trusted from the client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import parse_qsl, urlsplit, urlunsplit
|
||||
|
||||
from aiohttp import web
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import Float, and_, case, cast, or_, select
|
||||
from sqlalchemy import func as sa_func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.app.web.admin_settings_manifest import (
|
||||
manifest_payload,
|
||||
)
|
||||
from bot.infra.webhook_queue import enqueue_webhook_event
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.settings_override_service import (
|
||||
current_value,
|
||||
update_overrides,
|
||||
)
|
||||
from bot.utils import MessageContent, send_message_via_queue
|
||||
from bot.utils.message_queue import get_queue_manager
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import TariffsConfig, default_payment_currency_code_for_settings
|
||||
from db.dal import (
|
||||
ad_dal,
|
||||
app_settings_dal,
|
||||
locale_overrides_dal,
|
||||
message_log_dal,
|
||||
panel_sync_dal,
|
||||
payment_dal,
|
||||
promo_code_dal,
|
||||
subscription_dal,
|
||||
user_dal,
|
||||
)
|
||||
from db.models import (
|
||||
AdCampaign,
|
||||
MessageLog,
|
||||
Payment,
|
||||
PromoCode,
|
||||
Subscription,
|
||||
User,
|
||||
UserTelegramAvatar,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─── Auth ──────────────────────────────────────────────────────────
|
||||
|
||||
__all__ = [name for name in globals() if not name.startswith("__")]
|
||||
@@ -0,0 +1,69 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def admin_ads_list_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
campaigns = await ad_dal.list_campaigns(session)
|
||||
totals = await ad_dal.get_totals(session)
|
||||
results = []
|
||||
for campaign in campaigns:
|
||||
try:
|
||||
stats = await ad_dal.get_campaign_stats(session, campaign.ad_campaign_id)
|
||||
except Exception:
|
||||
stats = {}
|
||||
results.append(_serialize_ad(campaign, stats))
|
||||
return _ok({"campaigns": results, "totals": totals})
|
||||
|
||||
|
||||
async def admin_ad_create_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
payload = await _read_json(request)
|
||||
source = str(payload.get("source") or "").strip()
|
||||
start_param = str(payload.get("start_param") or "").strip()
|
||||
cost = float(payload.get("cost") or 0.0)
|
||||
if not source or not start_param:
|
||||
return _error(400, "invalid_payload")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
existing = await ad_dal.get_campaign_by_start_param(session, start_param)
|
||||
if existing:
|
||||
return _error(409, "duplicate_start_param")
|
||||
campaign = await ad_dal.create_campaign(
|
||||
session,
|
||||
source=source,
|
||||
start_param=start_param,
|
||||
cost=cost,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(campaign)
|
||||
return _ok({"campaign": _serialize_ad(campaign)})
|
||||
|
||||
|
||||
async def admin_ad_toggle_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
campaign_id = int(request.match_info["campaign_id"])
|
||||
payload = await _read_json(request)
|
||||
is_active = bool(payload.get("is_active", True))
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
ok = await ad_dal.toggle_campaign_active(session, campaign_id, is_active)
|
||||
if not ok:
|
||||
return _error(404, "not_found")
|
||||
await session.commit()
|
||||
return _ok({})
|
||||
|
||||
|
||||
async def admin_ad_delete_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
campaign_id = int(request.match_info["campaign_id"])
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
ok = await ad_dal.delete_campaign(session, campaign_id)
|
||||
if not ok:
|
||||
return _error(404, "not_found")
|
||||
await session.commit()
|
||||
return _ok({})
|
||||
@@ -0,0 +1,58 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
def _require_admin_user_id(request: web.Request) -> int:
|
||||
"""Return the authenticated user id, or raise 401/403 for non-admins."""
|
||||
|
||||
from bot.app.web.session import extract_authenticated_user_id
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
user_id = extract_authenticated_user_id(request)
|
||||
if not user_id:
|
||||
raise web.HTTPUnauthorized(
|
||||
text=json.dumps({"ok": False, "error": "unauthorized"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
admin_ids = settings.ADMIN_IDS or []
|
||||
db_user_telegram_id = request.get("admin_telegram_id")
|
||||
if db_user_telegram_id is None:
|
||||
raise web.HTTPForbidden(
|
||||
text=json.dumps({"ok": False, "error": "forbidden"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
if int(db_user_telegram_id) not in {int(x) for x in admin_ids}:
|
||||
raise web.HTTPForbidden(
|
||||
text=json.dumps({"ok": False, "error": "forbidden"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
return int(user_id)
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def admin_auth_middleware(request: web.Request, handler):
|
||||
"""Resolve the Telegram id of the current user and stash it on the request.
|
||||
|
||||
Doing this once per request lets every admin route call
|
||||
``_require_admin_user_id`` without re-querying the DB.
|
||||
"""
|
||||
|
||||
if not request.path.startswith("/api/admin"):
|
||||
return await handler(request)
|
||||
|
||||
from bot.app.web.session import extract_authenticated_user_id
|
||||
|
||||
user_id = extract_authenticated_user_id(request)
|
||||
if user_id:
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if db_user and db_user.telegram_id:
|
||||
request["admin_telegram_id"] = int(db_user.telegram_id)
|
||||
elif db_user:
|
||||
# No telegram_id yet (email-only user) — can't be an admin
|
||||
request["admin_telegram_id"] = None
|
||||
|
||||
return await handler(request)
|
||||
@@ -0,0 +1,181 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
import secrets
|
||||
import subprocess
|
||||
|
||||
from bot.infra.redis import redis_lock
|
||||
from bot.services.backup_restore_service import (
|
||||
BACKUP_UPLOAD_MAX_BYTES,
|
||||
BackupArchiveError,
|
||||
BackupArchiveInfo,
|
||||
BackupRestoreError,
|
||||
BackupRestoreService,
|
||||
)
|
||||
from bot.services.backup_worker import BackupWorker
|
||||
|
||||
|
||||
def _backup_archive_payload(archive) -> Dict[str, Any]:
|
||||
return archive.to_payload()
|
||||
|
||||
|
||||
async def _read_uploaded_backup_file(request: web.Request) -> BackupArchiveInfo:
|
||||
settings: Settings = request.app["settings"]
|
||||
service = BackupRestoreService(settings)
|
||||
backup_dir = service.backup_dir()
|
||||
temp_path: Optional[Path] = None
|
||||
|
||||
reader = await request.multipart()
|
||||
try:
|
||||
async for part in reader:
|
||||
if part.name != "file":
|
||||
continue
|
||||
|
||||
original_filename = part.filename or "backup.zip"
|
||||
temp_path = backup_dir / f".upload-{secrets.token_urlsafe(12)}.zip.tmp"
|
||||
size = 0
|
||||
with temp_path.open("wb") as handle:
|
||||
while True:
|
||||
chunk = await part.read_chunk(size=1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
if size > BACKUP_UPLOAD_MAX_BYTES:
|
||||
raise BackupArchiveError("Backup archive is too large")
|
||||
handle.write(chunk)
|
||||
if size <= 0:
|
||||
raise BackupArchiveError("Uploaded archive is empty")
|
||||
archive = service.import_uploaded_archive(temp_path, original_filename)
|
||||
temp_path = None
|
||||
return archive
|
||||
finally:
|
||||
if temp_path is not None and temp_path.exists():
|
||||
try:
|
||||
temp_path.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to remove temporary backup upload %s", temp_path)
|
||||
|
||||
raise BackupArchiveError("file field is required")
|
||||
|
||||
|
||||
async def admin_backups_list_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
try:
|
||||
service = BackupRestoreService(settings)
|
||||
archives = service.list_archives()
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to list backup archives")
|
||||
return _error(500, "backup_list_failed", str(exc))
|
||||
return _ok(
|
||||
{
|
||||
"backup_dir": str(service.backup_dir()),
|
||||
"archives": [_backup_archive_payload(archive) for archive in archives],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_backups_upload_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
content_type = (request.headers.get("Content-Type") or "").lower()
|
||||
if not content_type.startswith("multipart/form-data"):
|
||||
return _error(400, "invalid_backup_archive", "multipart file upload is required")
|
||||
try:
|
||||
archive = await _read_uploaded_backup_file(request)
|
||||
except BackupArchiveError as exc:
|
||||
return _error(400, "invalid_backup_archive", str(exc))
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to save uploaded backup archive")
|
||||
return _error(500, "backup_upload_failed", str(exc))
|
||||
return _ok({"archive": _backup_archive_payload(archive)})
|
||||
|
||||
|
||||
async def admin_backups_create_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
bot = request.app["bot"]
|
||||
session_factory = request.app.get("async_session_factory")
|
||||
worker = BackupWorker(settings, bot, session_factory=session_factory)
|
||||
|
||||
ttl_seconds = max(
|
||||
60,
|
||||
int(
|
||||
max(
|
||||
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
|
||||
getattr(settings, "BACKUP_PG_DUMP_TIMEOUT_SECONDS", 1800) or 1800,
|
||||
)
|
||||
),
|
||||
)
|
||||
try:
|
||||
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
|
||||
if not acquired:
|
||||
return _error(409, "backup_create_busy", "Backup or restore is already running")
|
||||
await worker.refresh_settings()
|
||||
result = await worker.create_and_send_backup(backup_type="manual")
|
||||
archive = BackupRestoreService(settings).inspect_archive(result.archive_path)
|
||||
except BackupArchiveError as exc:
|
||||
return _error(400, "invalid_backup_archive", str(exc))
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError, TimeoutError) as exc:
|
||||
logger.exception("Manual backup creation failed")
|
||||
return _error(500, "backup_create_failed", str(exc))
|
||||
except Exception as exc:
|
||||
logger.exception("Manual backup creation failed")
|
||||
return _error(500, "backup_create_failed", str(exc))
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"result": result.to_payload(),
|
||||
"archive": _backup_archive_payload(archive),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_backups_restore_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
|
||||
archive_name = str(payload.get("archive_name") or "").strip()
|
||||
restore_database = bool(payload.get("restore_database"))
|
||||
restore_compose = bool(payload.get("restore_compose"))
|
||||
confirm = bool(payload.get("confirm"))
|
||||
if not confirm:
|
||||
return _error(400, "restore_confirmation_required")
|
||||
|
||||
service = BackupRestoreService(settings)
|
||||
ttl_seconds = max(
|
||||
60,
|
||||
int(
|
||||
max(
|
||||
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
|
||||
getattr(settings, "BACKUP_PG_RESTORE_TIMEOUT_SECONDS", 1800) or 1800,
|
||||
)
|
||||
),
|
||||
)
|
||||
try:
|
||||
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
|
||||
if not acquired:
|
||||
return _error(409, "backup_restore_busy", "Backup or restore is already running")
|
||||
result = await service.restore_archive(
|
||||
archive_name,
|
||||
restore_database=restore_database,
|
||||
restore_compose=restore_compose,
|
||||
)
|
||||
except BackupArchiveError as exc:
|
||||
return _error(400, "invalid_backup_archive", str(exc))
|
||||
except BackupRestoreError as exc:
|
||||
logger.exception("Backup restore failed")
|
||||
return _error(500, "backup_restore_failed", str(exc))
|
||||
except (OSError, subprocess.SubprocessError, TimeoutError) as exc:
|
||||
logger.exception("Backup restore failed")
|
||||
return _error(500, "backup_restore_failed", str(exc))
|
||||
finally:
|
||||
try:
|
||||
from db import database_setup
|
||||
|
||||
if restore_database and database_setup.async_engine is not None:
|
||||
await database_setup.async_engine.dispose()
|
||||
except Exception:
|
||||
logger.exception("Failed to dispose DB engine after backup restore")
|
||||
|
||||
return _ok({"result": result.to_payload()})
|
||||
@@ -0,0 +1,56 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def admin_broadcast_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
payload = await _read_json(request)
|
||||
text = str(payload.get("text") or "").strip()
|
||||
target = str(payload.get("target") or "all").strip().lower()
|
||||
if not text:
|
||||
return _error(400, "empty_text")
|
||||
if target not in {"all", "active", "inactive", "expired"}:
|
||||
target = "all"
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
if not queue_manager:
|
||||
return _error(503, "queue_unavailable")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
if target == "active":
|
||||
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
|
||||
elif target == "inactive":
|
||||
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
|
||||
elif target == "expired":
|
||||
user_ids = await user_dal.get_user_ids_with_expired_subscription(session)
|
||||
else:
|
||||
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
|
||||
|
||||
sent = 0
|
||||
failed = 0
|
||||
for uid in user_ids:
|
||||
try:
|
||||
await send_message_via_queue(
|
||||
queue_manager,
|
||||
int(uid),
|
||||
MessageContent(content_type="text", text=text),
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
sent += 1
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
logger.debug("Broadcast queue failed for %s: %s", uid, exc)
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_broadcast_webapp",
|
||||
"content": f"target={target} sent={sent} failed={failed} text={text[:120]}",
|
||||
"is_admin_event": True,
|
||||
},
|
||||
)
|
||||
|
||||
return _ok({"queued": sent, "failed": failed, "target": target})
|
||||
@@ -0,0 +1,409 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
def _ok(payload: Dict[str, Any], **extra) -> web.Response:
|
||||
body = {"ok": True, **payload, **extra}
|
||||
return web.json_response(body)
|
||||
|
||||
|
||||
def _error(status: int, code: str, message: str = "") -> web.Response:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": code, "message": message or code},
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
async def _read_json(request: web.Request) -> Dict[str, Any]:
|
||||
try:
|
||||
data = await request.json()
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _serialize_user(user: User) -> Dict[str, Any]:
|
||||
return {
|
||||
"user_id": int(user.user_id),
|
||||
"telegram_id": int(user.telegram_id) if user.telegram_id else None,
|
||||
"telegram_photo_url": user.telegram_photo_url,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"email": user.email,
|
||||
"language_code": user.language_code,
|
||||
"is_banned": bool(user.is_banned),
|
||||
"registration_date": user.registration_date.isoformat() if user.registration_date else None,
|
||||
"panel_user_uuid": user.panel_user_uuid,
|
||||
"referral_code": user.referral_code,
|
||||
"referred_by_id": int(user.referred_by_id) if user.referred_by_id else None,
|
||||
}
|
||||
|
||||
|
||||
def _premium_limit_bytes_from_subscription(sub: Subscription) -> int:
|
||||
premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0)
|
||||
return (
|
||||
int(sub.premium_baseline_bytes or 0)
|
||||
+ int(sub.premium_topup_balance_bytes or 0)
|
||||
+ int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
|
||||
+ premium_bonus_bytes
|
||||
)
|
||||
|
||||
|
||||
def _premium_traffic_list_payload(sub: Optional[Subscription]) -> Dict[str, Any]:
|
||||
"""Premium traffic column when subscription has a finite premium quota (bytes > 0).
|
||||
|
||||
Note: ``Subscription.premium_is_limited`` in the DB means *quota exhausted* for panel
|
||||
routing, not 'tariff includes premium traffic' — do not use it here.
|
||||
"""
|
||||
|
||||
if sub is None:
|
||||
return {"state": "none"}
|
||||
if bool(getattr(sub, "premium_unlimited_override", False)):
|
||||
return {
|
||||
"state": "unlimited",
|
||||
"unlimited": True,
|
||||
"used_bytes": int(sub.premium_used_bytes or 0),
|
||||
"limit_bytes": None,
|
||||
"percent": None,
|
||||
}
|
||||
limit_bytes = _premium_limit_bytes_from_subscription(sub)
|
||||
if limit_bytes <= 0:
|
||||
return {"state": "none"}
|
||||
used_bytes = int(sub.premium_used_bytes or 0)
|
||||
ratio = float(used_bytes) / float(limit_bytes) if limit_bytes else 0.0
|
||||
pct = int(max(0, min(100, round(ratio * 100))))
|
||||
if ratio >= 1.0:
|
||||
state = "critical"
|
||||
elif ratio >= 0.85:
|
||||
state = "warn"
|
||||
else:
|
||||
state = "good"
|
||||
return {
|
||||
"state": state,
|
||||
"unlimited": False,
|
||||
"used_bytes": used_bytes,
|
||||
"limit_bytes": limit_bytes,
|
||||
"percent": pct,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
|
||||
premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0)
|
||||
regular_bonus_bytes = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
|
||||
regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False))
|
||||
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
|
||||
premium_limit_bytes = _premium_limit_bytes_from_subscription(sub)
|
||||
return {
|
||||
"subscription_id": int(sub.subscription_id),
|
||||
"panel_user_uuid": sub.panel_user_uuid,
|
||||
"panel_subscription_uuid": sub.panel_subscription_uuid,
|
||||
"start_date": sub.start_date.isoformat() if sub.start_date else None,
|
||||
"end_date": sub.end_date.isoformat() if sub.end_date else None,
|
||||
"duration_months": sub.duration_months,
|
||||
"is_active": bool(sub.is_active),
|
||||
"status_from_panel": sub.status_from_panel,
|
||||
"traffic_limit_bytes": sub.traffic_limit_bytes,
|
||||
"traffic_used_bytes": sub.traffic_used_bytes,
|
||||
"tier_baseline_bytes": sub.tier_baseline_bytes,
|
||||
"topup_balance_bytes": sub.topup_balance_bytes,
|
||||
"premium_used_bytes": sub.premium_used_bytes,
|
||||
"premium_limit_bytes": premium_limit_bytes,
|
||||
"premium_baseline_bytes": sub.premium_baseline_bytes,
|
||||
"premium_topup_balance_bytes": sub.premium_topup_balance_bytes,
|
||||
"premium_topup_used_bytes": getattr(sub, "premium_topup_used_bytes", 0),
|
||||
"premium_bonus_bytes": premium_bonus_bytes,
|
||||
"regular_bonus_bytes": regular_bonus_bytes,
|
||||
"regular_unlimited_override": regular_unlimited_override,
|
||||
"premium_unlimited_override": premium_unlimited_override,
|
||||
"premium_is_limited": bool(sub.premium_is_limited),
|
||||
"tariff_key": sub.tariff_key,
|
||||
"auto_renew_enabled": bool(sub.auto_renew_enabled),
|
||||
"provider": sub.provider,
|
||||
"is_throttled": bool(sub.is_throttled),
|
||||
}
|
||||
|
||||
|
||||
def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Optional[float]]:
|
||||
"""For traffic purchases: ``(regular_gb, premium_gb)``. Other payments → (None, None)."""
|
||||
if payment.purchased_gb is None:
|
||||
return None, None
|
||||
try:
|
||||
gb = float(payment.purchased_gb)
|
||||
except (TypeError, ValueError):
|
||||
return None, None
|
||||
sm = (payment.sale_mode or "").strip()
|
||||
if not sm:
|
||||
return None, None
|
||||
base = sm.split("@", 1)[0].split("|", 1)[0].lower()
|
||||
if base == "premium_topup":
|
||||
return None, gb
|
||||
if base in {"traffic", "traffic_package", "topup"}:
|
||||
return gb, None
|
||||
return None, None
|
||||
|
||||
|
||||
def _user_display_label(
|
||||
loaded_user: Any,
|
||||
fallback_user_id: Optional[int],
|
||||
*,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Human-facing name: TG profile name, else email, else user id."""
|
||||
tid = getattr(loaded_user, "telegram_id", None)
|
||||
if loaded_user is not None and tid is not None:
|
||||
fn = (getattr(loaded_user, "first_name", None) or "").strip()
|
||||
ln = (getattr(loaded_user, "last_name", None) or "").strip()
|
||||
full = f"{fn} {ln}".strip()
|
||||
if full:
|
||||
return full
|
||||
un = (getattr(loaded_user, "username", None) or "").strip()
|
||||
if un:
|
||||
return un if un.startswith("@") else f"@{un}"
|
||||
elif loaded_user is not None:
|
||||
email = (getattr(loaded_user, "email", None) or "").strip()
|
||||
if email:
|
||||
return email
|
||||
fn = (first_name or "").strip()
|
||||
ln = (last_name or "").strip()
|
||||
full = f"{fn} {ln}".strip()
|
||||
if full:
|
||||
return full
|
||||
un = (username or "").strip()
|
||||
if un:
|
||||
return un if un.startswith("@") else f"@{un}"
|
||||
email_value = (email or "").strip()
|
||||
if email_value:
|
||||
return email_value
|
||||
if fallback_user_id is None:
|
||||
return None
|
||||
return str(fallback_user_id)
|
||||
|
||||
|
||||
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
|
||||
label = _user_display_label(loaded_user, payment_user_id)
|
||||
if label:
|
||||
return label
|
||||
return str(payment_user_id)
|
||||
|
||||
|
||||
def _serialize_payment(payment: Payment) -> Dict[str, Any]:
|
||||
# Avoid lazy-loading `payment.user` outside an active SQLAlchemy session.
|
||||
# Some admin routes serialize payments after the session scope is closed.
|
||||
telegram_id = None
|
||||
loaded_user = payment.__dict__.get("user")
|
||||
user_label = _payment_user_display_label(loaded_user, int(payment.user_id))
|
||||
if loaded_user is not None:
|
||||
tid = getattr(loaded_user, "telegram_id", None)
|
||||
if tid is not None:
|
||||
try:
|
||||
telegram_id = int(tid)
|
||||
except (TypeError, ValueError):
|
||||
telegram_id = None
|
||||
reg_gb, prem_gb = _payment_traffic_gb_split(payment)
|
||||
return {
|
||||
"payment_id": int(payment.payment_id),
|
||||
"user_id": int(payment.user_id),
|
||||
"user_label": user_label,
|
||||
"telegram_id": telegram_id,
|
||||
"traffic_regular_gb": reg_gb,
|
||||
"traffic_premium_gb": prem_gb,
|
||||
"provider": payment.provider,
|
||||
"provider_payment_id": payment.provider_payment_id,
|
||||
"amount": float(payment.amount),
|
||||
"currency": payment.currency,
|
||||
"status": payment.status,
|
||||
"description": payment.description,
|
||||
"subscription_duration_months": payment.subscription_duration_months,
|
||||
"sale_mode": payment.sale_mode,
|
||||
"tariff_key": payment.tariff_key,
|
||||
"purchased_gb": payment.purchased_gb,
|
||||
"purchased_hwid_devices": payment.purchased_hwid_devices,
|
||||
"created_at": payment.created_at.isoformat() if payment.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_promo(promo: PromoCode) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": int(promo.promo_code_id),
|
||||
"code": promo.code,
|
||||
"bonus_days": int(promo.bonus_days),
|
||||
"max_activations": int(promo.max_activations),
|
||||
"current_activations": int(promo.current_activations or 0),
|
||||
"is_active": bool(promo.is_active),
|
||||
"valid_until": promo.valid_until.isoformat() if promo.valid_until else None,
|
||||
"created_at": promo.created_at.isoformat() if promo.created_at else None,
|
||||
"created_by_admin_id": int(promo.created_by_admin_id)
|
||||
if promo.created_by_admin_id
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_ad(campaign: AdCampaign, totals: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": int(campaign.ad_campaign_id),
|
||||
"source": campaign.source,
|
||||
"start_param": campaign.start_param,
|
||||
"cost": float(campaign.cost or 0),
|
||||
"is_active": bool(campaign.is_active),
|
||||
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
|
||||
"stats": totals or {},
|
||||
}
|
||||
|
||||
|
||||
def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
|
||||
author_user = entry.__dict__.get("author_user")
|
||||
target_user = entry.__dict__.get("target_user")
|
||||
user_id = int(entry.user_id) if entry.user_id is not None else None
|
||||
target_user_id = int(entry.target_user_id) if entry.target_user_id is not None else None
|
||||
return {
|
||||
"log_id": int(entry.log_id),
|
||||
"user_id": user_id,
|
||||
"user_label": _user_display_label(
|
||||
author_user,
|
||||
user_id,
|
||||
first_name=entry.telegram_first_name,
|
||||
username=entry.telegram_username,
|
||||
),
|
||||
"telegram_username": entry.telegram_username,
|
||||
"telegram_first_name": entry.telegram_first_name,
|
||||
"email": getattr(author_user, "email", None),
|
||||
"event_type": entry.event_type,
|
||||
"content": entry.content,
|
||||
"is_admin_event": bool(entry.is_admin_event),
|
||||
"target_user_id": target_user_id,
|
||||
"target_user_label": _user_display_label(target_user, target_user_id),
|
||||
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
|
||||
}
|
||||
|
||||
|
||||
def _tariffs_config_path(settings: Settings) -> Path:
|
||||
return Path(settings.TARIFFS_CONFIG_PATH).expanduser()
|
||||
|
||||
|
||||
def _tariffs_config_payload(config: TariffsConfig) -> Dict[str, Any]:
|
||||
return config.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
|
||||
def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None:
|
||||
data = _tariffs_config_payload(config)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
|
||||
payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
||||
try:
|
||||
tmp_path.write_text(payload, encoding="utf-8")
|
||||
tmp_path.replace(path)
|
||||
except PermissionError:
|
||||
# A docker-compose single-file bind mount can make /app/config
|
||||
# unwritable while the mounted tariffs.json itself is writable.
|
||||
# Fall back to updating the existing file in-place.
|
||||
if tmp_path.exists():
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
path.write_text(payload, encoding="utf-8")
|
||||
|
||||
|
||||
def _webapp_themes_catalog_payload(config: Any) -> Dict[str, Any]:
|
||||
return config.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
|
||||
def _panel_node_uuid_key(node: Dict[str, Any]) -> str:
|
||||
uid = node.get("nodeUuid") or node.get("node_uuid") or node.get("uuid") or node.get("id")
|
||||
return str(uid).strip().lower() if uid else ""
|
||||
|
||||
|
||||
def _panel_node_users_online(node: Dict[str, Any]) -> Optional[int]:
|
||||
uo = node.get("usersOnline")
|
||||
if uo is None:
|
||||
uo = node.get("users_online")
|
||||
if uo is None:
|
||||
uo = node.get("onlineUsers") or node.get("online_users")
|
||||
if uo is None:
|
||||
mg = node.get("metricGroups")
|
||||
if isinstance(mg, dict):
|
||||
uo = mg.get("onlineUsers") or mg.get("online_users")
|
||||
if uo is None:
|
||||
return None
|
||||
try:
|
||||
return int(uo)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _panel_nodes_online_by_uuid(nodes_payload: Any) -> Dict[str, int]:
|
||||
"""Build node_uuid(lower) -> usersOnline from GET /system/stats/nodes payload."""
|
||||
out: Dict[str, int] = {}
|
||||
raw_list: Optional[List[Any]] = None
|
||||
if isinstance(nodes_payload, list):
|
||||
raw_list = nodes_payload
|
||||
elif isinstance(nodes_payload, dict):
|
||||
raw_list = nodes_payload.get("nodes")
|
||||
if raw_list is None:
|
||||
raw_list = nodes_payload.get("items") or nodes_payload.get("data")
|
||||
if not isinstance(raw_list, list):
|
||||
return out
|
||||
for n in raw_list:
|
||||
if not isinstance(n, dict):
|
||||
continue
|
||||
key = _panel_node_uuid_key(n)
|
||||
if not key:
|
||||
continue
|
||||
online = _panel_node_users_online(n)
|
||||
if online is not None:
|
||||
out[key] = online
|
||||
return out
|
||||
|
||||
|
||||
def _enrich_bandwidth_nodes_with_online(
|
||||
bw: Any,
|
||||
online_by_uuid: Dict[str, int],
|
||||
online_by_name: Optional[Dict[str, int]] = None,
|
||||
) -> None:
|
||||
"""Attach usersOnline to topNodes/series (UUID and optional node name)."""
|
||||
if not isinstance(bw, dict):
|
||||
return
|
||||
if not online_by_uuid and not online_by_name:
|
||||
return
|
||||
for key in ("topNodes", "series"):
|
||||
arr = bw.get(key)
|
||||
if not isinstance(arr, list):
|
||||
continue
|
||||
for item in arr:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("usersOnline") is not None:
|
||||
continue
|
||||
uid = item.get("uuid") or item.get("nodeUuid") or item.get("node_uuid")
|
||||
if uid and online_by_uuid:
|
||||
hit = online_by_uuid.get(str(uid).strip().lower())
|
||||
if hit is not None:
|
||||
item["usersOnline"] = hit
|
||||
continue
|
||||
if online_by_name:
|
||||
nm = item.get("name")
|
||||
if nm and isinstance(nm, str):
|
||||
hitn = online_by_name.get(nm.strip().lower())
|
||||
if hitn is not None:
|
||||
item["usersOnline"] = hitn
|
||||
|
||||
|
||||
def _build_admin_webapp_referral_link(
|
||||
base_url: Optional[str], referral_code: Optional[str]
|
||||
) -> Optional[str]:
|
||||
"""Mirror of ``subscription_webapp._build_webapp_referral_link``.
|
||||
|
||||
Kept local to avoid a cross-module import cycle (subscription_webapp
|
||||
imports admin_api).
|
||||
"""
|
||||
if not base_url or not referral_code:
|
||||
return None
|
||||
parts = urlsplit(base_url)
|
||||
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||
query["ref"] = f"u{referral_code}"
|
||||
new_query = "&".join(f"{k}={v}" for k, v in query.items())
|
||||
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
|
||||
@@ -0,0 +1,36 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def admin_logs_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
page = max(0, int(request.query.get("page", 0) or 0))
|
||||
page_size = min(200, max(1, int(request.query.get("page_size", 50) or 50)))
|
||||
user_filter = request.query.get("user_id")
|
||||
|
||||
async with async_session_factory() as session:
|
||||
if user_filter:
|
||||
try:
|
||||
user_id = int(user_filter)
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_user_id")
|
||||
entries = await message_log_dal.get_user_message_logs(
|
||||
session, user_id, page_size, page * page_size
|
||||
)
|
||||
total = await message_log_dal.count_user_message_logs(session, user_id)
|
||||
else:
|
||||
entries = await message_log_dal.get_all_message_logs(
|
||||
session, page_size, page * page_size
|
||||
)
|
||||
total = await message_log_dal.count_all_message_logs(session)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"logs": [_serialize_log(entry) for entry in entries],
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": int(total or 0),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def admin_panel_internal_squads_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
panel_service = request.app.get("panel_service")
|
||||
if panel_service is None:
|
||||
return _error(503, "panel_unavailable", "Panel service unavailable")
|
||||
try:
|
||||
squads = await panel_service.get_internal_squads()
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to load internal squads from panel")
|
||||
return _error(502, "panel_request_failed", str(exc))
|
||||
if squads is None:
|
||||
return _error(502, "panel_request_failed", "Unable to load internal squads")
|
||||
items = []
|
||||
for squad in squads:
|
||||
if not isinstance(squad, dict):
|
||||
continue
|
||||
uuid = squad.get("uuid") or squad.get("id")
|
||||
if not uuid:
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"uuid": str(uuid),
|
||||
"name": squad.get("name") or squad.get("title") or str(uuid),
|
||||
"members_count": squad.get("membersCount")
|
||||
or squad.get("usersCount")
|
||||
or squad.get("members_count"),
|
||||
"active_inbounds_count": squad.get("activeInboundsCount")
|
||||
or squad.get("active_inbounds_count"),
|
||||
}
|
||||
)
|
||||
return _ok({"squads": items})
|
||||
@@ -0,0 +1,124 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def admin_payments_list_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
page = max(0, int(request.query.get("page", 0) or 0))
|
||||
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
|
||||
|
||||
async with async_session_factory() as session:
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
stmt = (
|
||||
select(Payment)
|
||||
.options(selectinload(Payment.user))
|
||||
.order_by(Payment.created_at.desc())
|
||||
.offset(page * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
total = await payment_dal.get_payments_count(session)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"payments": [_serialize_payment(p) for p in rows],
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": int(total or 0),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_payment_detail_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
try:
|
||||
payment_id = int(request.match_info["payment_id"])
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_payment", "Invalid payment id")
|
||||
|
||||
async with async_session_factory() as session:
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_id)
|
||||
if not payment:
|
||||
return _error(404, "not_found", "Payment not found")
|
||||
|
||||
payload = _serialize_payment(payment)
|
||||
payload.update(
|
||||
{
|
||||
"yookassa_payment_id": payment.yookassa_payment_id,
|
||||
"idempotence_key": payment.idempotence_key,
|
||||
"promo_code": (
|
||||
payment.promo_code_used.code if payment.promo_code_used is not None else None
|
||||
),
|
||||
"updated_at": payment.updated_at.isoformat() if payment.updated_at else None,
|
||||
}
|
||||
)
|
||||
|
||||
return _ok({"payment": payload})
|
||||
|
||||
|
||||
async def admin_payments_export_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
stmt = (
|
||||
select(Payment)
|
||||
.options(selectinload(Payment.user))
|
||||
.order_by(Payment.created_at.desc())
|
||||
.limit(10000)
|
||||
)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
|
||||
buffer = io.StringIO()
|
||||
writer = csv.writer(buffer)
|
||||
writer.writerow(
|
||||
[
|
||||
"payment_id",
|
||||
"user_id",
|
||||
"user_label",
|
||||
"provider",
|
||||
"provider_payment_id",
|
||||
"amount",
|
||||
"currency",
|
||||
"status",
|
||||
"description",
|
||||
"duration_months",
|
||||
"sale_mode",
|
||||
"tariff_key",
|
||||
"created_at",
|
||||
]
|
||||
)
|
||||
for p in rows:
|
||||
label = _payment_user_display_label(p.user, int(p.user_id)) if p.user else str(p.user_id)
|
||||
writer.writerow(
|
||||
[
|
||||
p.payment_id,
|
||||
p.user_id,
|
||||
label,
|
||||
p.provider,
|
||||
p.provider_payment_id or "",
|
||||
p.amount,
|
||||
p.currency,
|
||||
p.status,
|
||||
p.description or "",
|
||||
p.subscription_duration_months or "",
|
||||
p.sale_mode or "",
|
||||
p.tariff_key or "",
|
||||
p.created_at.isoformat() if p.created_at else "",
|
||||
]
|
||||
)
|
||||
|
||||
response = web.Response(
|
||||
body=buffer.getvalue().encode("utf-8-sig"),
|
||||
content_type="text/csv",
|
||||
charset="utf-8",
|
||||
)
|
||||
response.headers["Content-Disposition"] = 'attachment; filename="payments.csv"'
|
||||
return response
|
||||
@@ -0,0 +1,96 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def admin_promos_list_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
page = max(0, int(request.query.get("page", 0) or 0))
|
||||
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
|
||||
async with async_session_factory() as session:
|
||||
promos = await promo_code_dal.get_all_promo_codes_with_details(
|
||||
session, limit=page_size, offset=page * page_size
|
||||
)
|
||||
total = await promo_code_dal.get_promo_codes_count(session)
|
||||
return _ok(
|
||||
{
|
||||
"promos": [_serialize_promo(p) for p in promos],
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": int(total or 0),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_promo_create_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
payload = await _read_json(request)
|
||||
code = str(payload.get("code") or "").strip().upper()
|
||||
bonus_days = int(payload.get("bonus_days") or 0)
|
||||
max_activations = int(payload.get("max_activations") or 0)
|
||||
valid_days = payload.get("valid_days")
|
||||
if not code or bonus_days <= 0 or max_activations <= 0:
|
||||
return _error(400, "invalid_payload")
|
||||
|
||||
valid_until = None
|
||||
if valid_days:
|
||||
try:
|
||||
valid_until = datetime.now(timezone.utc) + timedelta(days=int(valid_days))
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_valid_days")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
existing = await promo_code_dal.get_promo_code_by_code(session, code)
|
||||
if existing:
|
||||
return _error(409, "duplicate_code")
|
||||
promo = await promo_code_dal.create_promo_code(
|
||||
session,
|
||||
{
|
||||
"code": code,
|
||||
"bonus_days": bonus_days,
|
||||
"max_activations": max_activations,
|
||||
"valid_until": valid_until,
|
||||
"created_by_admin_id": actor_id,
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
return _ok({"promo": _serialize_promo(promo)})
|
||||
|
||||
|
||||
async def admin_promo_update_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
promo_id = int(request.match_info["promo_id"])
|
||||
payload = await _read_json(request)
|
||||
update_data: Dict[str, Any] = {}
|
||||
if "is_active" in payload:
|
||||
update_data["is_active"] = bool(payload["is_active"])
|
||||
if "bonus_days" in payload and payload["bonus_days"] is not None:
|
||||
update_data["bonus_days"] = int(payload["bonus_days"])
|
||||
if "max_activations" in payload and payload["max_activations"] is not None:
|
||||
update_data["max_activations"] = int(payload["max_activations"])
|
||||
|
||||
if not update_data:
|
||||
return _error(400, "no_changes")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
promo = await promo_code_dal.update_promo_code(session, promo_id, update_data)
|
||||
if not promo:
|
||||
return _error(404, "not_found")
|
||||
await session.commit()
|
||||
await session.refresh(promo)
|
||||
return _ok({"promo": _serialize_promo(promo)})
|
||||
|
||||
|
||||
async def admin_promo_delete_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
promo_id = int(request.match_info["promo_id"])
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
promo = await promo_code_dal.delete_promo_code(session, promo_id)
|
||||
if not promo:
|
||||
return _error(404, "not_found")
|
||||
await session.commit()
|
||||
return _ok({})
|
||||
@@ -0,0 +1,83 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
def setup_admin_routes(app: web.Application) -> None:
|
||||
router = app.router
|
||||
router.add_get("/api/admin/me", admin_me_route)
|
||||
router.add_get("/api/admin/stats", admin_stats_route)
|
||||
|
||||
router.add_get("/api/admin/users", admin_users_list_route)
|
||||
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
|
||||
router.add_get("/api/admin/users/{user_id:-?\\d+}/referrals", admin_user_referrals_route)
|
||||
router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route
|
||||
)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/telegram-profile-link",
|
||||
admin_user_telegram_profile_link_route,
|
||||
)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/premium-override",
|
||||
admin_user_premium_override_route,
|
||||
)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/regular-traffic-override",
|
||||
admin_user_regular_traffic_override_route,
|
||||
)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/traffic-grant",
|
||||
admin_user_traffic_grant_route,
|
||||
)
|
||||
router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route)
|
||||
|
||||
router.add_get("/api/admin/payments", admin_payments_list_route)
|
||||
router.add_get("/api/admin/payments/{payment_id:\\d+}", admin_payment_detail_route)
|
||||
router.add_get("/api/admin/payments/export.csv", admin_payments_export_route)
|
||||
|
||||
router.add_get("/api/admin/promos", admin_promos_list_route)
|
||||
router.add_post("/api/admin/promos", admin_promo_create_route)
|
||||
router.add_patch("/api/admin/promos/{promo_id:\\d+}", admin_promo_update_route)
|
||||
router.add_delete("/api/admin/promos/{promo_id:\\d+}", admin_promo_delete_route)
|
||||
|
||||
router.add_get("/api/admin/logs", admin_logs_route)
|
||||
|
||||
router.add_get("/api/admin/support/tickets", admin_support_tickets_route)
|
||||
router.add_get("/api/admin/support/tickets/{id:\\d+}", admin_support_ticket_detail_route)
|
||||
router.add_post(
|
||||
"/api/admin/support/tickets/{id:\\d+}/messages",
|
||||
admin_support_ticket_reply_route,
|
||||
)
|
||||
router.add_patch("/api/admin/support/tickets/{id:\\d+}", admin_support_ticket_patch_route)
|
||||
router.add_post("/api/admin/support/tickets/{id:\\d+}/read", admin_support_ticket_read_route)
|
||||
router.add_get("/api/admin/support/stats", admin_support_stats_route)
|
||||
|
||||
router.add_post("/api/admin/broadcast", admin_broadcast_route)
|
||||
router.add_post("/api/admin/sync", admin_sync_route)
|
||||
|
||||
router.add_get("/api/admin/ads", admin_ads_list_route)
|
||||
router.add_post("/api/admin/ads", admin_ad_create_route)
|
||||
router.add_post("/api/admin/ads/{campaign_id:\\d+}/toggle", admin_ad_toggle_route)
|
||||
router.add_delete("/api/admin/ads/{campaign_id:\\d+}", admin_ad_delete_route)
|
||||
|
||||
router.add_get("/api/admin/settings", admin_settings_get_route)
|
||||
router.add_patch("/api/admin/settings", admin_settings_patch_route)
|
||||
router.add_get("/api/admin/translations", admin_translations_get_route)
|
||||
router.add_patch("/api/admin/translations", admin_translations_patch_route)
|
||||
|
||||
router.add_get("/api/admin/tariffs", admin_tariffs_get_route)
|
||||
router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
|
||||
router.add_get("/api/admin/themes", admin_themes_get_route)
|
||||
router.add_put("/api/admin/themes", admin_themes_save_route)
|
||||
router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route)
|
||||
router.add_post("/api/admin/appearance/favicon", admin_appearance_favicon_upload_route)
|
||||
router.add_get("/api/admin/backups", admin_backups_list_route)
|
||||
router.add_post("/api/admin/backups/create", admin_backups_create_route)
|
||||
router.add_post("/api/admin/backups/upload", admin_backups_upload_route)
|
||||
router.add_post("/api/admin/backups/restore", admin_backups_restore_route)
|
||||
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
|
||||
@@ -0,0 +1,105 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
|
||||
|
||||
from config.subscription_guides_config import (
|
||||
SubscriptionGuidesConfigError,
|
||||
subscription_guides_admin_config_json,
|
||||
)
|
||||
|
||||
|
||||
async def admin_settings_get_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
overrides = await app_settings_dal.get_overrides_with_meta(session)
|
||||
|
||||
overrides_by_key = {entry["key"]: entry for entry in overrides}
|
||||
|
||||
fields = manifest_payload()
|
||||
webhook_base_url = str(settings.WEBHOOK_BASE_URL or "").strip().rstrip("/")
|
||||
sections: Dict[str, Dict[str, Any]] = {}
|
||||
for field in fields:
|
||||
key = field["key"]
|
||||
section_id = field["section"]
|
||||
if section_id not in sections:
|
||||
sections[section_id] = {
|
||||
"id": section_id,
|
||||
"order": field["section_order"],
|
||||
"fields": [],
|
||||
}
|
||||
override = overrides_by_key.get(key)
|
||||
value = current_value(settings, key)
|
||||
is_secret = bool(field.get("secret"))
|
||||
overridden = bool(override)
|
||||
source = None
|
||||
read_error = None
|
||||
if key == "SUBSCRIPTION_PAGE_CONFIG_JSON":
|
||||
try:
|
||||
value, source = subscription_guides_admin_config_json(settings)
|
||||
overridden = source == "admin_json"
|
||||
except SubscriptionGuidesConfigError as exc:
|
||||
read_error = str(exc)
|
||||
response_field = {
|
||||
**field,
|
||||
"value": "" if is_secret else value,
|
||||
"overridden": overridden,
|
||||
"updated_at": override.get("updated_at") if override else None,
|
||||
}
|
||||
if source:
|
||||
response_field["source"] = source
|
||||
if read_error:
|
||||
response_field["read_error"] = read_error
|
||||
if is_secret:
|
||||
response_field["has_value"] = bool(value)
|
||||
webhook_path = str(response_field.get("webhook_path") or "").strip()
|
||||
if webhook_path:
|
||||
if not webhook_path.startswith("/"):
|
||||
webhook_path = f"/{webhook_path}"
|
||||
response_field["webhook_path"] = webhook_path
|
||||
response_field["webhook_base_url_configured"] = bool(webhook_base_url)
|
||||
if webhook_base_url:
|
||||
response_field["webhook_url"] = f"{webhook_base_url}{webhook_path}"
|
||||
sections[section_id]["fields"].append(response_field)
|
||||
|
||||
ordered_sections = sorted(sections.values(), key=lambda s: s["order"])
|
||||
return _ok({"sections": ordered_sections})
|
||||
|
||||
|
||||
async def admin_settings_patch_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
payload = await _read_json(request)
|
||||
updates = payload.get("updates") or {}
|
||||
deletes = payload.get("deletes") or []
|
||||
if not isinstance(updates, dict):
|
||||
return _error(400, "invalid_updates")
|
||||
if not isinstance(deletes, list):
|
||||
return _error(400, "invalid_deletes")
|
||||
if (
|
||||
"SUBSCRIPTION_PAGE_CONFIG_JSON" in updates
|
||||
and not str(updates.get("SUBSCRIPTION_PAGE_CONFIG_JSON") or "").strip()
|
||||
):
|
||||
updates = dict(updates)
|
||||
updates.pop("SUBSCRIPTION_PAGE_CONFIG_JSON", None)
|
||||
deletes = [*deletes, "SUBSCRIPTION_PAGE_CONFIG_JSON"]
|
||||
|
||||
result = await update_overrides(
|
||||
settings,
|
||||
async_session_factory,
|
||||
updates=updates,
|
||||
deletes=deletes,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "validation_failed", "errors": result.get("errors", {})},
|
||||
status=400,
|
||||
)
|
||||
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=deletes)
|
||||
|
||||
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
|
||||
@@ -0,0 +1,168 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
import asyncio
|
||||
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .auth import _require_admin_user_id
|
||||
from .common import _ok, _serialize_payment
|
||||
from bot.utils.ttl_cache import AsyncTTLCache
|
||||
|
||||
_ADMIN_PANEL_STATS_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
|
||||
_ADMIN_DB_STATS_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
|
||||
|
||||
|
||||
async def admin_me_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
return _ok({}, user_id=user_id, admin_ids=list(settings.ADMIN_IDS or []))
|
||||
|
||||
|
||||
async def admin_stats_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
payload = dict(await _load_admin_db_stats(settings, async_session_factory))
|
||||
|
||||
panel_service = request.app.get("panel_service")
|
||||
if panel_service is not None:
|
||||
payload["panel"] = await _load_admin_panel_stats(request, settings, panel_service)
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
if queue_manager:
|
||||
try:
|
||||
payload["queue"] = queue_manager.get_queue_stats()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
payload["queue"] = None
|
||||
|
||||
payload["currency_symbol"] = default_payment_currency_code_for_settings(settings)
|
||||
return _ok(payload)
|
||||
|
||||
|
||||
async def _load_admin_db_stats(
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
) -> Dict[str, Any]:
|
||||
cache = _admin_db_stats_cache(settings)
|
||||
if cache is None:
|
||||
return await _load_admin_db_stats_uncached(async_session_factory)
|
||||
return await cache.get_or_load(
|
||||
"db",
|
||||
lambda: _load_admin_db_stats_uncached(async_session_factory),
|
||||
)
|
||||
|
||||
|
||||
async def _load_admin_db_stats_uncached(async_session_factory: sessionmaker) -> Dict[str, Any]:
|
||||
async with async_session_factory() as session:
|
||||
user_stats = await user_dal.get_enhanced_user_statistics(session)
|
||||
financial_stats = await payment_dal.get_financial_statistics(session)
|
||||
sync_status = await panel_sync_dal.get_panel_sync_status(session)
|
||||
recent_payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=10)
|
||||
|
||||
return {
|
||||
"users": user_stats,
|
||||
"financial": financial_stats,
|
||||
"panel_sync": {
|
||||
"status": sync_status.status if sync_status else "never_run",
|
||||
"last_sync_time": sync_status.last_sync_time.isoformat()
|
||||
if sync_status and sync_status.last_sync_time
|
||||
else None,
|
||||
"details": sync_status.details if sync_status else None,
|
||||
"users_processed": sync_status.users_processed_from_panel if sync_status else 0,
|
||||
"subscriptions_synced": sync_status.subscriptions_synced if sync_status else 0,
|
||||
},
|
||||
"recent_payments": [_serialize_payment(p) for p in recent_payments],
|
||||
}
|
||||
|
||||
|
||||
def _admin_db_stats_cache(settings: Settings) -> Optional[AsyncTTLCache]:
|
||||
ttl_seconds = int(getattr(settings, "ADMIN_DB_STATS_CACHE_TTL_SECONDS", 5) or 0)
|
||||
if ttl_seconds <= 0:
|
||||
return None
|
||||
cache_key = (id(settings), ttl_seconds)
|
||||
cache = _ADMIN_DB_STATS_CACHES.get(cache_key)
|
||||
if cache is None:
|
||||
cache = AsyncTTLCache(
|
||||
ttl_seconds=ttl_seconds,
|
||||
settings=settings,
|
||||
namespace="admin:db_stats",
|
||||
)
|
||||
_ADMIN_DB_STATS_CACHES[cache_key] = cache
|
||||
return cache
|
||||
|
||||
|
||||
async def _load_admin_panel_stats(
|
||||
request: web.Request,
|
||||
settings: Settings,
|
||||
panel_service,
|
||||
) -> Dict[str, Any]:
|
||||
cache = _admin_panel_stats_cache(settings)
|
||||
if cache is None:
|
||||
return await _load_admin_panel_stats_uncached(panel_service)
|
||||
return await cache.get_or_load("panel", lambda: _load_admin_panel_stats_uncached(panel_service))
|
||||
|
||||
|
||||
def _admin_panel_stats_cache(settings: Settings) -> Optional[AsyncTTLCache]:
|
||||
ttl_seconds = int(getattr(settings, "ADMIN_PANEL_STATS_CACHE_TTL_SECONDS", 15) or 0)
|
||||
if ttl_seconds <= 0:
|
||||
return None
|
||||
cache_key = (id(settings), ttl_seconds)
|
||||
cache = _ADMIN_PANEL_STATS_CACHES.get(cache_key)
|
||||
if cache is None:
|
||||
cache = AsyncTTLCache(
|
||||
ttl_seconds=ttl_seconds,
|
||||
settings=settings,
|
||||
namespace="admin:panel_stats",
|
||||
)
|
||||
_ADMIN_PANEL_STATS_CACHES[cache_key] = cache
|
||||
return cache
|
||||
|
||||
|
||||
async def _load_admin_panel_stats_uncached(panel_service) -> Dict[str, Any]:
|
||||
try:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
start_d = today - timedelta(days=7)
|
||||
system, bandwidth, nodes, nodes_bw, lookups = await asyncio.gather(
|
||||
_safe_panel_call(panel_service.get_system_stats(), "system stats"),
|
||||
_safe_panel_call(panel_service.get_bandwidth_stats(), "bandwidth stats"),
|
||||
_safe_panel_call(panel_service.get_nodes_statistics(), "nodes stats"),
|
||||
_safe_panel_call(
|
||||
panel_service.get_nodes_bandwidth_usage(
|
||||
start=start_d.isoformat(),
|
||||
end=today.isoformat(),
|
||||
top_nodes_limit=64,
|
||||
),
|
||||
"nodes bandwidth range",
|
||||
),
|
||||
_safe_panel_call(panel_service.get_nodes_online_lookups(), "nodes online lookups"),
|
||||
)
|
||||
|
||||
panel_body: Dict[str, Any] = {
|
||||
"system": system or {},
|
||||
"bandwidth": bandwidth or {},
|
||||
"nodes": nodes or {},
|
||||
"nodes_bandwidth": nodes_bw or {},
|
||||
}
|
||||
if isinstance(lookups, dict):
|
||||
try:
|
||||
online_map = _panel_nodes_online_by_uuid(panel_body.get("nodes"))
|
||||
for k, v in lookups.get("byUuid", {}).items():
|
||||
online_map[k] = v
|
||||
_enrich_bandwidth_nodes_with_online(
|
||||
panel_body.get("nodes_bandwidth"),
|
||||
online_map,
|
||||
lookups.get("byName") or {},
|
||||
)
|
||||
except Exception as exc_merge: # pragma: no cover
|
||||
logger.debug("Panel nodes online merge skipped: %s", exc_merge)
|
||||
return panel_body
|
||||
except Exception as exc:
|
||||
logger.debug("Panel stats unavailable: %s", exc)
|
||||
return {"error": "unavailable"}
|
||||
|
||||
|
||||
async def _safe_panel_call(awaitable, label: str) -> Any:
|
||||
try:
|
||||
return await awaitable
|
||||
except Exception as exc: # pragma: no cover - optional panel endpoints
|
||||
logger.debug("Panel %s unavailable: %s", label, exc)
|
||||
return None
|
||||
@@ -0,0 +1,251 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, constr, field_validator
|
||||
|
||||
from bot.services.support_service import TicketNotFound
|
||||
from db.dal import support_dal, user_dal
|
||||
from db.models import SupportTicket, SupportTicketMessage
|
||||
|
||||
|
||||
class AdminTicketReplyPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
body: constr(min_length=1, max_length=4000)
|
||||
is_internal_note: bool = False
|
||||
|
||||
@field_validator("body")
|
||||
@classmethod
|
||||
def _strip_body(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("empty_text")
|
||||
return stripped
|
||||
|
||||
|
||||
class AdminTicketPatchPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
status: Optional[Literal["open", "awaiting_user", "awaiting_admin", "resolved", "closed"]] = (
|
||||
None
|
||||
)
|
||||
priority: Optional[Literal["low", "normal", "high", "urgent"]] = None
|
||||
category: Optional[Literal["billing", "technical", "account", "other"]] = None
|
||||
assigned_admin_id: Optional[int] = None
|
||||
|
||||
|
||||
def _validate_model_payload(model_cls, payload: Dict[str, Any]):
|
||||
try:
|
||||
return model_cls.model_validate(payload), None
|
||||
except ValidationError:
|
||||
return None, _error(400, "invalid_request", "Invalid request")
|
||||
|
||||
|
||||
def _support_ticket_payload(ticket: SupportTicket) -> Dict[str, Any]:
|
||||
return {
|
||||
"ticket_id": ticket.ticket_id,
|
||||
"user_id": ticket.user_id,
|
||||
"subject": ticket.subject,
|
||||
"category": ticket.category,
|
||||
"priority": ticket.priority,
|
||||
"status": ticket.status,
|
||||
"assigned_admin_id": ticket.assigned_admin_id,
|
||||
"last_message_at": ticket.last_message_at.isoformat() if ticket.last_message_at else None,
|
||||
"last_message_role": ticket.last_message_role,
|
||||
"unread_user_count": int(ticket.unread_user_count or 0),
|
||||
"unread_admin_count": int(ticket.unread_admin_count or 0),
|
||||
"created_at": ticket.created_at.isoformat() if ticket.created_at else None,
|
||||
"updated_at": ticket.updated_at.isoformat() if ticket.updated_at else None,
|
||||
"closed_at": ticket.closed_at.isoformat() if ticket.closed_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _user_display_name(user) -> Optional[str]:
|
||||
if not user:
|
||||
return None
|
||||
name = " ".join(
|
||||
part.strip() for part in [user.first_name, user.last_name] if part and part.strip()
|
||||
).strip()
|
||||
return name or user.username or user.email or str(user.user_id)
|
||||
|
||||
|
||||
def _support_message_payload(
|
||||
message: SupportTicketMessage,
|
||||
*,
|
||||
authors: Optional[Dict[int, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
author = authors.get(message.author_user_id) if authors and message.author_user_id else None
|
||||
return {
|
||||
"message_id": message.message_id,
|
||||
"ticket_id": message.ticket_id,
|
||||
"author_role": message.author_role,
|
||||
"author_user_id": message.author_user_id,
|
||||
"author_name": _user_display_name(author),
|
||||
"body": message.body,
|
||||
"is_internal_note": bool(message.is_internal_note),
|
||||
"created_at": message.created_at.isoformat() if message.created_at else None,
|
||||
"read_by_user_at": message.read_by_user_at.isoformat() if message.read_by_user_at else None,
|
||||
"read_by_admin_at": message.read_by_admin_at.isoformat()
|
||||
if message.read_by_admin_at
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
def _admin_support_user_payload(user) -> Dict[str, Any]:
|
||||
if not user:
|
||||
return {}
|
||||
return {
|
||||
"user_id": user.user_id,
|
||||
"telegram_id": user.telegram_id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"email": user.email,
|
||||
"telegram_photo_url": user.telegram_photo_url,
|
||||
"is_banned": bool(user.is_banned),
|
||||
"registration_date": user.registration_date.isoformat() if user.registration_date else None,
|
||||
}
|
||||
|
||||
|
||||
def _support_limit_offset(request: web.Request) -> tuple[int, int]:
|
||||
limit = max(1, min(100, int(request.query.get("limit", 25) or 25)))
|
||||
offset = max(0, int(request.query.get("offset", 0) or 0))
|
||||
return limit, offset
|
||||
|
||||
|
||||
async def admin_support_tickets_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
limit, offset = _support_limit_offset(request)
|
||||
assigned_raw = request.query.get("assigned")
|
||||
assigned_admin_id = None
|
||||
if assigned_raw and assigned_raw not in {"all", "any"}:
|
||||
assigned_admin_id = int(assigned_raw)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
tickets = await support_dal.list_admin_tickets(
|
||||
session,
|
||||
status=request.query.get("status") or None,
|
||||
priority=request.query.get("priority") or None,
|
||||
category=request.query.get("category") or None,
|
||||
assigned_admin_id=assigned_admin_id,
|
||||
search=request.query.get("search") or None,
|
||||
sort=request.query.get("sort") or "updated_desc",
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tickets": [
|
||||
{
|
||||
**_support_ticket_payload(ticket),
|
||||
"user": _admin_support_user_payload(getattr(ticket, "user", None)),
|
||||
}
|
||||
for ticket in tickets
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_support_ticket_detail_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
service = request.app["support_service"]
|
||||
async with async_session_factory() as session:
|
||||
ticket, messages = await support_dal.get_ticket(session, ticket_id, include_internal=True)
|
||||
if not ticket:
|
||||
return _error(404, "not_found", "Ticket not found")
|
||||
user = await user_dal.get_user_by_id(session, ticket.user_id)
|
||||
snapshot = await service.build_user_snapshot(user, session=session) if user else {}
|
||||
author_ids = {m.author_user_id for m in messages if m.author_user_id is not None}
|
||||
authors = {}
|
||||
for author_id in author_ids:
|
||||
author = await user_dal.get_user_by_id(session, author_id)
|
||||
if author:
|
||||
authors[author_id] = author
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"ticket": {
|
||||
**_support_ticket_payload(ticket),
|
||||
"user": _admin_support_user_payload(user),
|
||||
},
|
||||
"messages": [_support_message_payload(m, authors=authors) for m in messages],
|
||||
"user_snapshot": snapshot,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_support_ticket_reply_route(request: web.Request) -> web.Response:
|
||||
admin_id = _require_admin_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
payload, error = _validate_model_payload(AdminTicketReplyPayload, await _read_json(request))
|
||||
if error:
|
||||
return error
|
||||
try:
|
||||
ticket, message = await request.app["support_service"].reply_as_admin(
|
||||
admin_id,
|
||||
ticket_id,
|
||||
payload.body,
|
||||
is_internal_note=payload.is_internal_note,
|
||||
)
|
||||
except TicketNotFound:
|
||||
return _error(404, "not_found", "Ticket not found")
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
admin = await user_dal.get_user_by_id(session, admin_id)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"ticket": _support_ticket_payload(ticket),
|
||||
"message": _support_message_payload(
|
||||
message, authors={admin_id: admin} if admin else {}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_support_ticket_patch_route(request: web.Request) -> web.Response:
|
||||
admin_id = _require_admin_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
payload, error = _validate_model_payload(AdminTicketPatchPayload, await _read_json(request))
|
||||
if error:
|
||||
return error
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
try:
|
||||
if updates.get("status") == "closed":
|
||||
ticket = await request.app["support_service"].close_ticket(admin_id, ticket_id)
|
||||
updates.pop("status", None)
|
||||
if updates:
|
||||
ticket = await request.app["support_service"]._update_and_audit(
|
||||
admin_id,
|
||||
ticket_id,
|
||||
**updates,
|
||||
)
|
||||
else:
|
||||
ticket = await request.app["support_service"]._update_and_audit(
|
||||
admin_id,
|
||||
ticket_id,
|
||||
**updates,
|
||||
)
|
||||
except TicketNotFound:
|
||||
return _error(404, "not_found", "Ticket not found")
|
||||
return web.json_response({"ok": True, "ticket": _support_ticket_payload(ticket)})
|
||||
|
||||
|
||||
async def admin_support_ticket_read_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
await request.app["support_service"].mark_read_as_admin(ticket_id)
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
|
||||
async def admin_support_stats_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
stats = await support_dal.admin_stats(session)
|
||||
return web.json_response({"ok": True, "stats": stats})
|
||||
@@ -0,0 +1,16 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def admin_sync_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
queued = await enqueue_webhook_event(
|
||||
settings,
|
||||
"panel_sync",
|
||||
{"requested_by": _require_admin_user_id(request)},
|
||||
event_id=None,
|
||||
)
|
||||
if queued:
|
||||
return _ok({"result": {"status": "queued"}})
|
||||
return _error(503, "queue_unavailable")
|
||||
@@ -0,0 +1,114 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
|
||||
|
||||
|
||||
async def admin_tariffs_get_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
path = _tariffs_config_path(settings)
|
||||
|
||||
try:
|
||||
config = settings.tariffs_config
|
||||
except Exception as exc:
|
||||
logger.warning("Invalid tariffs config requested from admin UI: %s", exc)
|
||||
return _error(400, "invalid_tariffs_config", str(exc))
|
||||
|
||||
if config is None:
|
||||
return _ok(
|
||||
{
|
||||
"exists": path.exists(),
|
||||
"path": str(path),
|
||||
"catalog": {
|
||||
"default_tariff": "",
|
||||
"default_currency": "rub",
|
||||
"topup_packages_default": {"rub": [], "stars": []},
|
||||
"tariffs": [],
|
||||
},
|
||||
"provider_currency_support": _provider_currency_support_payload(
|
||||
settings,
|
||||
request.app,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"exists": True,
|
||||
"path": str(path),
|
||||
"catalog": _tariffs_config_payload(config),
|
||||
"provider_currency_support": _provider_currency_support_payload(settings, request.app),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_tariffs_save_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
catalog = payload.get("catalog") if "catalog" in payload else payload
|
||||
if not isinstance(catalog, dict):
|
||||
return _error(400, "invalid_payload", "catalog must be an object")
|
||||
|
||||
try:
|
||||
config = TariffsConfig.model_validate(catalog)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
return _error(400, "invalid_tariffs_config", str(exc))
|
||||
|
||||
path = _tariffs_config_path(settings)
|
||||
try:
|
||||
_write_tariffs_config_file(path, config)
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to write tariffs config to %s", path)
|
||||
return _error(500, "write_failed", str(exc))
|
||||
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"exists": True,
|
||||
"path": str(path),
|
||||
"catalog": _tariffs_config_payload(config),
|
||||
"provider_currency_support": _provider_currency_support_payload(settings, request.app),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _provider_currency_support_payload(
|
||||
settings: Settings,
|
||||
app: web.Application,
|
||||
) -> List[Dict[str, Any]]:
|
||||
from bot.payment_providers import iter_provider_specs, resolve_provider_presentation
|
||||
|
||||
default_currency = default_payment_currency_code_for_settings(settings)
|
||||
providers: List[Dict[str, Any]] = []
|
||||
for spec in iter_provider_specs():
|
||||
presentation = resolve_provider_presentation(spec, settings)
|
||||
supported = spec.supported_currency_codes(settings)
|
||||
providers.append(
|
||||
{
|
||||
"id": spec.id,
|
||||
"provider_key": spec.provider_key,
|
||||
"label": presentation.webapp_label or spec.label,
|
||||
"telegram_label": presentation.telegram_label,
|
||||
"icon": presentation.webapp_icon,
|
||||
"enabled": spec.is_effectively_enabled(settings),
|
||||
"configured": spec.is_service_configured(app),
|
||||
"admin_only": spec.is_admin_only_enabled(settings),
|
||||
"price_source": spec.price_source,
|
||||
"currencies": list(supported) if supported is not None else None,
|
||||
"accepts_any_currency": supported is None,
|
||||
"supports_default_currency": spec.is_usable_for_payment_currency(
|
||||
settings,
|
||||
default_currency,
|
||||
),
|
||||
"directly_supports_default_currency": spec.supports_currency(
|
||||
settings,
|
||||
default_currency,
|
||||
),
|
||||
"default_currency": default_currency,
|
||||
"note": spec.currency_support_note,
|
||||
"docs_url": spec.currency_support_url,
|
||||
}
|
||||
)
|
||||
return providers
|
||||
@@ -0,0 +1,489 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import shutil
|
||||
import re
|
||||
import socket
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
|
||||
from config.webapp_themes_config import (
|
||||
WebappThemesConfig,
|
||||
ensure_webapp_core_themes,
|
||||
resolved_webapp_themes_catalog,
|
||||
write_webapp_theme_dir,
|
||||
)
|
||||
|
||||
|
||||
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
||||
WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "uploads"
|
||||
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
||||
WEBAPP_FAVICON_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "favicons"
|
||||
WEBAPP_FAVICON_PATH = "/webapp-favicon"
|
||||
WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-emoji"
|
||||
WEBAPP_FAVICON_SIZES = (16, 32, 48, 180, 192, 512)
|
||||
WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = {
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
|
||||
|
||||
def _theme_payload_for_version_compare(theme: Any) -> Dict[str, Any]:
|
||||
if hasattr(theme, "model_dump"):
|
||||
data = theme.model_dump(mode="json", exclude_none=True)
|
||||
elif isinstance(theme, dict):
|
||||
data = dict(theme)
|
||||
else:
|
||||
data = {}
|
||||
data.pop("assets_version", None)
|
||||
data.pop("default", None)
|
||||
return data
|
||||
|
||||
|
||||
def _bump_theme_asset_versions(
|
||||
config: WebappThemesConfig,
|
||||
previous: WebappThemesConfig,
|
||||
) -> WebappThemesConfig:
|
||||
previous_by_key = {theme.key: theme for theme in previous.themes}
|
||||
default_changed = config.default_theme != previous.default_theme
|
||||
data = config.model_dump(mode="json", exclude_none=True)
|
||||
for theme in data.get("themes", []):
|
||||
if not isinstance(theme, dict):
|
||||
continue
|
||||
if not str(theme.get("css_file") or "").strip():
|
||||
continue
|
||||
key = str(theme.get("key") or "")
|
||||
previous_theme = previous_by_key.get(key)
|
||||
previous_version = int(getattr(previous_theme, "assets_version", 0) or 0)
|
||||
current_version = int(theme.get("assets_version") or 1)
|
||||
theme_changed = previous_theme is None or _theme_payload_for_version_compare(
|
||||
theme
|
||||
) != _theme_payload_for_version_compare(previous_theme)
|
||||
if theme_changed or (default_changed and key == config.default_theme):
|
||||
theme["assets_version"] = max(previous_version + 1, current_version, 1)
|
||||
elif previous_version > current_version:
|
||||
theme["assets_version"] = previous_version
|
||||
return WebappThemesConfig.model_validate(data)
|
||||
|
||||
|
||||
def _detect_logo_extension(
|
||||
body: bytes, content_type: str = "", filename: str = ""
|
||||
) -> Optional[str]:
|
||||
content_type = (content_type or "").split(";", 1)[0].strip().lower()
|
||||
suffix = Path(filename or "").suffix.lower()
|
||||
if content_type == "image/png" or body.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return ".png"
|
||||
if content_type == "image/jpeg" or body.startswith(b"\xff\xd8\xff"):
|
||||
return ".jpg"
|
||||
if content_type == "image/gif" or body.startswith((b"GIF87a", b"GIF89a")):
|
||||
return ".gif"
|
||||
if content_type == "image/webp" or (
|
||||
len(body) > 12 and body[:4] == b"RIFF" and body[8:12] == b"WEBP"
|
||||
):
|
||||
return ".webp"
|
||||
if content_type in {"image/svg+xml", "image/svg"} or suffix == ".svg":
|
||||
head = body[:512].lstrip().lower()
|
||||
if head.startswith(b"<svg") or b"<svg" in head:
|
||||
return ".svg"
|
||||
if content_type == "image/x-icon" or suffix == ".ico":
|
||||
if body.startswith(b"\x00\x00\x01\x00"):
|
||||
return ".ico"
|
||||
return suffix if suffix in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES else None
|
||||
|
||||
|
||||
def _write_uploaded_logo(body: bytes, content_type: str = "", filename: str = "") -> str:
|
||||
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
|
||||
raise ValueError("logo must be a non-empty image up to 2 MiB")
|
||||
ext = _detect_logo_extension(body, content_type, filename)
|
||||
if ext not in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES:
|
||||
raise ValueError("unsupported image type")
|
||||
digest = hashlib.sha256(body).hexdigest()[:16]
|
||||
safe_name = f"logo-{digest}{ext}"
|
||||
WEBAPP_UPLOADED_LOGO_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(WEBAPP_UPLOADED_LOGO_DIR / safe_name).write_bytes(body)
|
||||
return f"{WEBAPP_UPLOADED_LOGO_PATH}/{safe_name}"
|
||||
|
||||
|
||||
def _uploaded_logo_filename(url: str) -> Optional[str]:
|
||||
parsed = urlsplit(str(url or ""))
|
||||
path = parsed.path if parsed.scheme or parsed.netloc else str(url or "")
|
||||
prefix = f"{WEBAPP_UPLOADED_LOGO_PATH}/"
|
||||
if not path.startswith(prefix):
|
||||
return None
|
||||
filename = path.removeprefix(prefix)
|
||||
if re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename):
|
||||
return filename
|
||||
return None
|
||||
|
||||
|
||||
def _favicon_digest(url: str) -> Optional[str]:
|
||||
parsed = urlsplit(str(url or ""))
|
||||
path = parsed.path if parsed.scheme or parsed.netloc else str(url or "")
|
||||
match = re.fullmatch(
|
||||
rf"{re.escape(WEBAPP_FAVICON_PATH)}/([0-9a-f]{{16}})/(?:[A-Za-z0-9_.-]+)",
|
||||
path,
|
||||
)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def _emoji_to_codepoints(value: str) -> str:
|
||||
return "_".join(f"{ord(char):x}" for char in str(value or "").strip())
|
||||
|
||||
|
||||
def prune_unused_appearance_assets(settings: Settings) -> None:
|
||||
keep_logos = {
|
||||
filename
|
||||
for filename in [
|
||||
_uploaded_logo_filename(getattr(settings, "WEBAPP_LOGO_URL", "")),
|
||||
]
|
||||
if filename
|
||||
}
|
||||
keep_favicons = {
|
||||
digest
|
||||
for digest in [
|
||||
_favicon_digest(getattr(settings, "WEBAPP_FAVICON_URL", "")),
|
||||
_favicon_digest(getattr(settings, "WEBAPP_LOGO_FAVICON_URL", "")),
|
||||
]
|
||||
if digest
|
||||
}
|
||||
keep_emoji_prefixes = set()
|
||||
if (
|
||||
getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False)
|
||||
and str(getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") or "").strip()
|
||||
== "noto-color-animated"
|
||||
):
|
||||
codepoints = _emoji_to_codepoints(getattr(settings, "WEBAPP_LOGO_EMOJI", ""))
|
||||
if codepoints:
|
||||
keep_emoji_prefixes.add(f"{codepoints}.512.")
|
||||
|
||||
for path in WEBAPP_UPLOADED_LOGO_DIR.glob("logo-*"):
|
||||
if path.is_file() and path.name not in keep_logos:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to remove unused webapp logo %s", path, exc_info=True)
|
||||
|
||||
for path in WEBAPP_FAVICON_DIR.glob("*"):
|
||||
if (
|
||||
path.is_dir()
|
||||
and re.fullmatch(r"[0-9a-f]{16}", path.name)
|
||||
and path.name not in keep_favicons
|
||||
):
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
except OSError:
|
||||
logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True)
|
||||
|
||||
for path in WEBAPP_EMOJI_CACHE_DIR.glob("*.512.*"):
|
||||
if path.is_file() and not any(
|
||||
path.name.startswith(prefix) for prefix in keep_emoji_prefixes
|
||||
):
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to remove unused webapp emoji asset %s", path, exc_info=True)
|
||||
|
||||
|
||||
async def _persist_appearance_upload(
|
||||
request: web.Request,
|
||||
updates: Dict[str, Any],
|
||||
actor_id: int,
|
||||
) -> bool:
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
result = await update_overrides(
|
||||
settings,
|
||||
async_session_factory,
|
||||
updates=updates,
|
||||
deletes=[],
|
||||
actor_id=actor_id,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
logger.warning("Failed to persist uploaded appearance asset settings: %s", result)
|
||||
return False
|
||||
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=[])
|
||||
return True
|
||||
|
||||
|
||||
def _image_to_square_icon(source: Image.Image, size: int) -> Image.Image:
|
||||
fitted = source.copy()
|
||||
fitted.thumbnail((size, size), Image.Resampling.LANCZOS)
|
||||
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
left = (size - fitted.width) // 2
|
||||
top = (size - fitted.height) // 2
|
||||
canvas.alpha_composite(fitted, (left, top))
|
||||
return canvas
|
||||
|
||||
|
||||
def _write_favicon_set(body: bytes, content_type: str = "", filename: str = "") -> Dict[str, Any]:
|
||||
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
|
||||
raise ValueError("favicon source must be a non-empty image up to 2 MiB")
|
||||
|
||||
ext = _detect_logo_extension(body, content_type, filename)
|
||||
digest = hashlib.sha256(body).hexdigest()[:16]
|
||||
target_dir = WEBAPP_FAVICON_DIR / digest
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if ext == ".svg":
|
||||
safe_name = "favicon.svg"
|
||||
(target_dir / safe_name).write_bytes(body)
|
||||
return {
|
||||
"favicon_url": f"{WEBAPP_FAVICON_PATH}/{digest}/{safe_name}",
|
||||
"variants": {"svg": f"{WEBAPP_FAVICON_PATH}/{digest}/{safe_name}"},
|
||||
}
|
||||
|
||||
try:
|
||||
with Image.open(io.BytesIO(body)) as image:
|
||||
image.seek(0)
|
||||
source = ImageOps.exif_transpose(image).convert("RGBA")
|
||||
except (OSError, UnidentifiedImageError, ValueError) as exc:
|
||||
raise ValueError("favicon source must be a raster image") from exc
|
||||
|
||||
if source.width < 1 or source.height < 1 or source.width > 8192 or source.height > 8192:
|
||||
raise ValueError("favicon source dimensions are not supported")
|
||||
|
||||
variants: Dict[str, str] = {}
|
||||
png_icons: Dict[int, Image.Image] = {}
|
||||
for size in WEBAPP_FAVICON_SIZES:
|
||||
icon = _image_to_square_icon(source, size)
|
||||
png_icons[size] = icon
|
||||
filename = f"icon-{size}.png"
|
||||
icon.save(target_dir / filename, format="PNG", optimize=True)
|
||||
variants[f"{size}"] = f"{WEBAPP_FAVICON_PATH}/{digest}/{filename}"
|
||||
|
||||
png_icons[180].save(target_dir / "apple-touch-icon.png", format="PNG", optimize=True)
|
||||
variants["apple_touch"] = f"{WEBAPP_FAVICON_PATH}/{digest}/apple-touch-icon.png"
|
||||
png_icons[32].save(
|
||||
target_dir / "favicon.ico",
|
||||
format="ICO",
|
||||
sizes=[(16, 16), (32, 32), (48, 48)],
|
||||
)
|
||||
variants["ico"] = f"{WEBAPP_FAVICON_PATH}/{digest}/favicon.ico"
|
||||
return {
|
||||
"favicon_url": variants["180"],
|
||||
"variants": variants,
|
||||
}
|
||||
|
||||
|
||||
async def _read_uploaded_logo_file(request: web.Request) -> tuple[bytes, str, str]:
|
||||
reader = await request.multipart()
|
||||
async for part in reader:
|
||||
if part.name != "file":
|
||||
continue
|
||||
body = bytearray()
|
||||
while True:
|
||||
chunk = await part.read_chunk(size=64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
body.extend(chunk)
|
||||
if len(body) > WEBAPP_LOGO_MAX_BYTES:
|
||||
raise ValueError("logo must be up to 2 MiB")
|
||||
return bytes(body), part.headers.get("Content-Type", ""), part.filename or ""
|
||||
raise ValueError("file field is required")
|
||||
|
||||
|
||||
async def _hostname_resolves_to_public_address(hostname: str) -> bool:
|
||||
if not hostname:
|
||||
return False
|
||||
try:
|
||||
ip_obj = ipaddress.ip_address(hostname)
|
||||
return not (
|
||||
ip_obj.is_private
|
||||
or ip_obj.is_loopback
|
||||
or ip_obj.is_link_local
|
||||
or ip_obj.is_unspecified
|
||||
or ip_obj.is_reserved
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
resolved = await loop.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
found_public_ip = False
|
||||
for entry in resolved:
|
||||
sockaddr = entry[4]
|
||||
candidate = sockaddr[0] if sockaddr else ""
|
||||
try:
|
||||
ip_obj = ipaddress.ip_address(candidate)
|
||||
except ValueError:
|
||||
continue
|
||||
if (
|
||||
ip_obj.is_private
|
||||
or ip_obj.is_loopback
|
||||
or ip_obj.is_link_local
|
||||
or ip_obj.is_unspecified
|
||||
or ip_obj.is_reserved
|
||||
):
|
||||
return False
|
||||
found_public_ip = True
|
||||
return found_public_ip
|
||||
|
||||
|
||||
async def _fetch_logo_from_url(url: str) -> tuple[bytes, str, str]:
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme != "https" or not parsed.hostname:
|
||||
raise ValueError("only https image URLs are supported")
|
||||
if not await _hostname_resolves_to_public_address(parsed.hostname):
|
||||
raise ValueError("logo URL must resolve to a public address")
|
||||
|
||||
timeout = ClientTimeout(total=5)
|
||||
async with ClientSession(timeout=timeout, headers={"User-Agent": "Mozilla/5.0"}) as session:
|
||||
async with session.get(
|
||||
url,
|
||||
allow_redirects=False,
|
||||
headers={"Accept": "image/avif,image/webp,image/svg+xml,image/png,image/*,*/*;q=0.8"},
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
raise ValueError(f"logo URL returned HTTP {response.status}")
|
||||
content_type = (
|
||||
(response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
|
||||
)
|
||||
if content_type and not content_type.startswith("image/"):
|
||||
raise ValueError("logo URL returned non-image content")
|
||||
body = bytearray()
|
||||
async for chunk in response.content.iter_chunked(64 * 1024):
|
||||
body.extend(chunk)
|
||||
if len(body) > WEBAPP_LOGO_MAX_BYTES:
|
||||
raise ValueError("logo must be up to 2 MiB")
|
||||
return bytes(body), content_type, Path(parsed.path).name
|
||||
|
||||
|
||||
async def admin_appearance_logo_upload_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
content_type = (request.headers.get("Content-Type") or "").lower()
|
||||
try:
|
||||
if content_type.startswith("multipart/form-data"):
|
||||
body, detected_content_type, filename = await _read_uploaded_logo_file(request)
|
||||
else:
|
||||
payload = await _read_json(request)
|
||||
source_url = str(payload.get("url") or "").strip()
|
||||
if not source_url:
|
||||
return _error(400, "invalid_payload", "url or file is required")
|
||||
body, detected_content_type, filename = await _fetch_logo_from_url(source_url)
|
||||
logo_url = _write_uploaded_logo(body, detected_content_type, filename)
|
||||
try:
|
||||
favicon_payload = _write_favicon_set(body, detected_content_type, filename)
|
||||
except ValueError:
|
||||
favicon_payload = {}
|
||||
except ValueError as exc:
|
||||
return _error(400, "invalid_logo", str(exc))
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to save uploaded webapp logo")
|
||||
return _error(500, "write_failed", str(exc))
|
||||
persisted = await _persist_appearance_upload(
|
||||
request,
|
||||
{
|
||||
"WEBAPP_LOGO_URL": logo_url,
|
||||
"WEBAPP_LOGO_USE_EMOJI": False,
|
||||
**(
|
||||
{"WEBAPP_LOGO_FAVICON_URL": favicon_payload["favicon_url"]}
|
||||
if favicon_payload.get("favicon_url")
|
||||
else {}
|
||||
),
|
||||
},
|
||||
actor_id,
|
||||
)
|
||||
|
||||
return _ok({"logo_url": logo_url, "persisted": persisted, **favicon_payload})
|
||||
|
||||
|
||||
async def admin_appearance_favicon_upload_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
content_type = (request.headers.get("Content-Type") or "").lower()
|
||||
try:
|
||||
if content_type.startswith("multipart/form-data"):
|
||||
body, detected_content_type, filename = await _read_uploaded_logo_file(request)
|
||||
else:
|
||||
payload = await _read_json(request)
|
||||
source_url = str(payload.get("url") or "").strip()
|
||||
if not source_url:
|
||||
return _error(400, "invalid_payload", "url or file is required")
|
||||
body, detected_content_type, filename = await _fetch_logo_from_url(source_url)
|
||||
favicon_payload = _write_favicon_set(body, detected_content_type, filename)
|
||||
except ValueError as exc:
|
||||
return _error(400, "invalid_favicon", str(exc))
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to save uploaded webapp favicon")
|
||||
return _error(500, "write_failed", str(exc))
|
||||
persisted = await _persist_appearance_upload(
|
||||
request,
|
||||
{
|
||||
"WEBAPP_FAVICON_URL": favicon_payload["favicon_url"],
|
||||
"WEBAPP_FAVICON_USE_CUSTOM": True,
|
||||
},
|
||||
actor_id,
|
||||
)
|
||||
|
||||
return _ok({"persisted": persisted, **favicon_payload})
|
||||
|
||||
|
||||
async def admin_themes_get_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
primary = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a"
|
||||
catalog = resolved_webapp_themes_catalog(
|
||||
primary_accent=primary,
|
||||
env_default_theme=settings.WEBAPP_DEFAULT_THEME,
|
||||
theme_dir=settings.WEBAPP_THEMES_DIR,
|
||||
)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"exists": Path(settings.WEBAPP_THEMES_DIR).expanduser().exists(),
|
||||
"themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()),
|
||||
"catalog": _webapp_themes_catalog_payload(catalog),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_themes_save_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
previous_config = resolved_webapp_themes_catalog(
|
||||
primary_accent=settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
|
||||
env_default_theme=settings.WEBAPP_DEFAULT_THEME,
|
||||
theme_dir=settings.WEBAPP_THEMES_DIR,
|
||||
)
|
||||
payload = await _read_json(request)
|
||||
catalog = payload.get("catalog") if "catalog" in payload else payload
|
||||
if not isinstance(catalog, dict):
|
||||
return _error(400, "invalid_payload", "catalog must be an object")
|
||||
|
||||
try:
|
||||
config = WebappThemesConfig.model_validate(catalog)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
return _error(400, "invalid_webapp_themes_config", str(exc))
|
||||
|
||||
config, _changed = ensure_webapp_core_themes(config, settings.WEBAPP_PRIMARY_COLOR or "#00fe7a")
|
||||
config = _bump_theme_asset_versions(config, previous_config)
|
||||
|
||||
try:
|
||||
write_webapp_theme_dir(settings.WEBAPP_THEMES_DIR, config, delete_missing=True)
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to write webapp themes to %s", settings.WEBAPP_THEMES_DIR)
|
||||
return _error(500, "write_failed", str(exc))
|
||||
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"exists": True,
|
||||
"themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()),
|
||||
"catalog": _webapp_themes_catalog_payload(config),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n, locale_language_options, resolve_locale_key
|
||||
from bot.services.locale_override_service import (
|
||||
LOCALE_OVERRIDES_PATH,
|
||||
audience_for_locale_key,
|
||||
group_id_for_locale_key,
|
||||
locale_group_catalog,
|
||||
load_locale_overrides,
|
||||
update_locale_overrides,
|
||||
)
|
||||
|
||||
|
||||
def _locale_languages(
|
||||
i18n: JsonI18n,
|
||||
overrides: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
base_languages = set((i18n.base_locales_data or {}).keys())
|
||||
override_languages = {str(entry.get("lang") or "") for entry in overrides or []}
|
||||
override_languages.update((i18n.locale_overrides or {}).keys())
|
||||
return locale_language_options(
|
||||
base_languages | override_languages,
|
||||
base_languages=base_languages,
|
||||
)
|
||||
|
||||
|
||||
def _locale_override_meta_map(overrides: List[Dict[str, Any]]) -> Dict[Tuple[str, str], Dict]:
|
||||
result: Dict[Tuple[str, str], Dict] = {}
|
||||
for entry in overrides:
|
||||
lang = str(entry.get("lang") or "")
|
||||
raw_key = str(entry.get("key") or "")
|
||||
key = resolve_locale_key(raw_key)
|
||||
if lang and key:
|
||||
if raw_key != key and (lang, key) in result:
|
||||
continue
|
||||
result[(lang, key)] = entry
|
||||
return result
|
||||
|
||||
|
||||
def _admin_translations_payload(
|
||||
i18n: JsonI18n,
|
||||
overrides: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
base_data = i18n.base_locales_data or i18n.locales_data or {}
|
||||
effective_data = i18n.locales_data or {}
|
||||
override_meta = _locale_override_meta_map(overrides)
|
||||
language_items = _locale_languages(i18n, overrides)
|
||||
languages = [item["code"] for item in language_items]
|
||||
all_keys = sorted(
|
||||
{key for messages in base_data.values() for key in messages.keys()}
|
||||
| {key for _, key in override_meta.keys()}
|
||||
)
|
||||
|
||||
groups_by_id = {
|
||||
group["id"]: {
|
||||
**group,
|
||||
"items": [],
|
||||
}
|
||||
for group in locale_group_catalog()
|
||||
}
|
||||
|
||||
for key in all_keys:
|
||||
values: Dict[str, Dict[str, Any]] = {}
|
||||
for lang in languages:
|
||||
meta = override_meta.get((lang, key))
|
||||
fallback_base = base_data.get(i18n.default_lang, {}).get(key, "")
|
||||
values[lang] = {
|
||||
"base": base_data.get(lang, {}).get(key, ""),
|
||||
"fallback": fallback_base,
|
||||
"effective": effective_data.get(lang, {}).get(key, ""),
|
||||
"override": meta.get("value") if meta else "",
|
||||
"overridden": bool(meta),
|
||||
"updated_at": meta.get("updated_at") if meta else None,
|
||||
"updated_by": meta.get("updated_by") if meta else None,
|
||||
}
|
||||
group_id = group_id_for_locale_key(key)
|
||||
groups_by_id.setdefault(
|
||||
group_id,
|
||||
{"id": group_id, "title": group_id, "description": "", "items": []},
|
||||
)
|
||||
groups_by_id[group_id]["items"].append(
|
||||
{
|
||||
"key": key,
|
||||
"audience": audience_for_locale_key(key),
|
||||
"values": values,
|
||||
}
|
||||
)
|
||||
|
||||
groups = [group for group in groups_by_id.values() if group["items"]]
|
||||
return {
|
||||
"languages": language_items,
|
||||
"groups": groups,
|
||||
"path": str(LOCALE_OVERRIDES_PATH),
|
||||
"override_count": len(overrides),
|
||||
}
|
||||
|
||||
|
||||
async def admin_translations_get_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
i18n: Optional[JsonI18n] = request.app.get("i18n")
|
||||
if i18n is None:
|
||||
return _error(503, "i18n_unavailable")
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
await load_locale_overrides(i18n, async_session_factory)
|
||||
async with async_session_factory() as session:
|
||||
overrides = await locale_overrides_dal.get_overrides_with_meta(session)
|
||||
|
||||
return _ok(_admin_translations_payload(i18n, overrides))
|
||||
|
||||
|
||||
async def admin_translations_patch_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
i18n: Optional[JsonI18n] = request.app.get("i18n")
|
||||
if i18n is None:
|
||||
return _error(503, "i18n_unavailable")
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
payload = await _read_json(request)
|
||||
updates = payload.get("updates") or {}
|
||||
deletes = payload.get("deletes") or []
|
||||
if not isinstance(updates, dict):
|
||||
return _error(400, "invalid_updates")
|
||||
if not isinstance(deletes, list):
|
||||
return _error(400, "invalid_deletes")
|
||||
|
||||
result = await update_locale_overrides(
|
||||
i18n,
|
||||
async_session_factory,
|
||||
updates=updates,
|
||||
deletes=deletes,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "validation_failed", "errors": result.get("errors", {})},
|
||||
status=400,
|
||||
)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"applied": result.get("applied", 0),
|
||||
"reverted": result.get("reverted", 0),
|
||||
"file_written": result.get("file_written", False),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from bot.app.web.webapp.cache_helpers import (
|
||||
invalidate_all_webapp_user_payloads,
|
||||
reset_subscription_guides_cache,
|
||||
reset_webapp_settings_cache,
|
||||
)
|
||||
|
||||
WEBAPP_APPEARANCE_SETTING_KEYS = frozenset(
|
||||
{
|
||||
"WEBAPP_TITLE",
|
||||
"WEBAPP_LOGO_URL",
|
||||
"WEBAPP_LOGO_USE_EMOJI",
|
||||
"WEBAPP_LOGO_EMOJI",
|
||||
"WEBAPP_LOGO_EMOJI_FONT",
|
||||
"WEBAPP_FAVICON_URL",
|
||||
"WEBAPP_FAVICON_USE_CUSTOM",
|
||||
"WEBAPP_LOGO_FAVICON_URL",
|
||||
}
|
||||
)
|
||||
|
||||
WEBAPP_DEVICE_PAYLOAD_SETTING_KEYS = frozenset(
|
||||
{
|
||||
"MY_DEVICES_SECTION_ENABLED",
|
||||
"USER_HWID_DEVICE_LIMIT",
|
||||
"USER_TRAFFIC_LIMIT_GB",
|
||||
"USER_TRAFFIC_STRATEGY",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def changed_setting_keys(
|
||||
updates: Mapping[str, Any] | None = None,
|
||||
deletes: Sequence[Any] | None = None,
|
||||
) -> set[str]:
|
||||
keys = {str(key) for key in (updates or {}).keys()}
|
||||
keys.update(str(key) for key in (deletes or []) if key is not None)
|
||||
return keys
|
||||
|
||||
|
||||
async def refresh_webapp_runtime_after_settings_change(
|
||||
request: Any,
|
||||
*,
|
||||
updates: Mapping[str, Any] | None = None,
|
||||
deletes: Sequence[Any] | None = None,
|
||||
include_user_payloads: bool = True,
|
||||
) -> None:
|
||||
settings = request.app["settings"]
|
||||
keys = changed_setting_keys(updates, deletes)
|
||||
|
||||
reset_webapp_settings_cache(request.app)
|
||||
reset_subscription_guides_cache(request.app)
|
||||
|
||||
if include_user_payloads:
|
||||
await invalidate_all_webapp_user_payloads(
|
||||
settings,
|
||||
include_devices=bool(keys & WEBAPP_DEVICE_PAYLOAD_SETTING_KEYS),
|
||||
)
|
||||
|
||||
if keys & WEBAPP_APPEARANCE_SETTING_KEYS:
|
||||
request.app["webapp_logo_cache"] = None
|
||||
from bot.app.web.admin_api_impl.themes import prune_unused_appearance_assets
|
||||
|
||||
prune_unused_appearance_assets(settings)
|
||||
@@ -0,0 +1,815 @@
|
||||
"""Manifest of settings editable from the admin web app.
|
||||
|
||||
Each entry describes a single overridable attribute on the global
|
||||
``Settings`` instance. The manifest is the only contract between the
|
||||
admin UI and the backend: keys not listed here cannot be changed via
|
||||
the API, even by an admin.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SettingField:
|
||||
key: str
|
||||
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "icon" | "json"
|
||||
section: str
|
||||
label: str
|
||||
description: str = ""
|
||||
placeholder: str = ""
|
||||
optional: bool = True
|
||||
secret: bool = False
|
||||
min: Optional[float] = None
|
||||
max: Optional[float] = None
|
||||
choices: Optional[Tuple[Tuple[str, str], ...]] = None
|
||||
subsection: Optional[str] = None # group label inside a section
|
||||
i18n_label_key: Optional[str] = None
|
||||
i18n_description_key: Optional[str] = None
|
||||
i18n_subsection_key: Optional[str] = None
|
||||
webhook_path: Optional[str] = None
|
||||
webhook_requires_base_url: bool = False
|
||||
webhook_provider_id: Optional[str] = None
|
||||
webhook_hint_i18n_key: Optional[str] = None
|
||||
webhook_hint: str = ""
|
||||
|
||||
|
||||
SETTINGS_MANIFEST: List[SettingField] = [
|
||||
# ─── General ────────────────────────────────────────────────────
|
||||
SettingField(
|
||||
"WEBAPP_TITLE",
|
||||
"string",
|
||||
"general",
|
||||
"Web App title",
|
||||
placeholder="My subscription",
|
||||
),
|
||||
SettingField(
|
||||
"DEFAULT_LANGUAGE",
|
||||
"string",
|
||||
"general",
|
||||
"Язык по умолчанию",
|
||||
"Используется для приветственных сообщений и публичных страниц.",
|
||||
),
|
||||
SettingField(
|
||||
"DEFAULT_CURRENCY_SYMBOL",
|
||||
"string",
|
||||
"general",
|
||||
"Валюта",
|
||||
"Например, RUB, USD, EUR.",
|
||||
placeholder="RUB",
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_LINK", "url", "general", "Ссылка поддержки", "Куда вести пользователей за помощью."
|
||||
),
|
||||
SettingField("SERVER_STATUS_URL", "url", "general", "Ссылка на статус серверов"),
|
||||
SettingField("TERMS_OF_SERVICE_URL", "url", "general", "Условия использования"),
|
||||
SettingField("PRIVACY_POLICY_URL", "url", "general", "Политика конфиденциальности"),
|
||||
SettingField("USER_AGREEMENT_URL", "url", "general", "Пользовательское соглашение"),
|
||||
SettingField("DISABLE_WELCOME_MESSAGE", "bool", "general", "Скрыть приветствие /start"),
|
||||
SettingField(
|
||||
"START_COMMAND_DESCRIPTION", "string", "general", "Описание /start", placeholder=""
|
||||
),
|
||||
SettingField(
|
||||
"REQUIRED_CHANNEL_ID",
|
||||
"int",
|
||||
"general",
|
||||
"ID обязательного канала",
|
||||
"Telegram ID канала, в котором нужно состоять.",
|
||||
),
|
||||
SettingField(
|
||||
"REQUIRED_CHANNEL_LINK",
|
||||
"string",
|
||||
"general",
|
||||
"Ссылка на канал",
|
||||
"Имя пользователя или invite-link.",
|
||||
),
|
||||
SettingField(
|
||||
"PANEL_API_URL",
|
||||
"url",
|
||||
"remnawave",
|
||||
"URL API Remnawave",
|
||||
"Например, https://panel.example.com/api.",
|
||||
),
|
||||
SettingField(
|
||||
"PANEL_API_KEY",
|
||||
"string",
|
||||
"remnawave",
|
||||
"API-ключ Remnawave",
|
||||
"Секретный ключ API панели.",
|
||||
secret=True,
|
||||
),
|
||||
SettingField(
|
||||
"PANEL_WEBHOOK_SECRET",
|
||||
"string",
|
||||
"remnawave",
|
||||
"Секрет вебхуков Remnawave",
|
||||
"Используется для проверки входящих вебхуков панели.",
|
||||
secret=True,
|
||||
webhook_path="/webhook/panel",
|
||||
webhook_requires_base_url=True,
|
||||
webhook_provider_id="remnawave",
|
||||
webhook_hint_i18n_key="admin_settings_panel_webhook_url_hint",
|
||||
webhook_hint="Use this URL as WEBHOOK_URL in Remnawave Panel.",
|
||||
),
|
||||
SettingField(
|
||||
"USER_SQUAD_UUIDS",
|
||||
"string",
|
||||
"remnawave",
|
||||
"Internal Squads по умолчанию",
|
||||
"UUID через запятую для legacy-режима без JSON-каталога тарифов.",
|
||||
),
|
||||
SettingField(
|
||||
"USER_EXTERNAL_SQUAD_UUID",
|
||||
"string",
|
||||
"remnawave",
|
||||
"External Squad по умолчанию",
|
||||
"Необязательный UUID External Squad для новых пользователей.",
|
||||
),
|
||||
# ─── Web app appearance ────────────────────────────────────────
|
||||
SettingField(
|
||||
"SUBSCRIPTION_MINI_APP_URL",
|
||||
"url",
|
||||
"appearance",
|
||||
"Публичный URL Mini App",
|
||||
"Например, https://app.example.com/.",
|
||||
),
|
||||
SettingField(
|
||||
"WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"
|
||||
),
|
||||
SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Использовать эмоджи-логотип"),
|
||||
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
|
||||
SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"),
|
||||
SettingField(
|
||||
"WEBAPP_LOGO_EMOJI_FONT",
|
||||
"string",
|
||||
"appearance",
|
||||
"Шрифт эмоджи-логотипа",
|
||||
"Выберите шрифт для отображения эмодзи-логотипа",
|
||||
choices=(
|
||||
("system", "Системный (по умолчанию)"),
|
||||
("noto-color", "Noto Color Emoji"),
|
||||
("noto-color-animated", "Noto Color Emoji Animated"),
|
||||
("noto-emoji", "Noto Emoji"),
|
||||
("twemoji", "Twitter Emoji"),
|
||||
("openmoji", "OpenMoji"),
|
||||
("apple", "Apple Color Emoji (local)"),
|
||||
("segoe", "Segoe UI Emoji (local)"),
|
||||
("noto-local", "Noto Emoji (local)"),
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"WEBAPP_FAVICON_USE_CUSTOM",
|
||||
"bool",
|
||||
"appearance",
|
||||
"Использовать отдельную favicon",
|
||||
),
|
||||
SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL отдельной favicon"),
|
||||
SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon из логотипа"),
|
||||
SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_GUIDES_ENABLED",
|
||||
"bool",
|
||||
"subscription_guides",
|
||||
"Embedded install guides",
|
||||
"Open install instructions inside the Web App instead of an external connect page.",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED",
|
||||
"bool",
|
||||
"subscription_guides",
|
||||
"Open install guides from bot",
|
||||
(
|
||||
"Use the Telegram Mini App install screen for bot connect buttons and show "
|
||||
"public install guide links."
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED",
|
||||
"bool",
|
||||
"subscription_guides",
|
||||
"Use Remnawave Panel config",
|
||||
(
|
||||
"Fetch Subscription Page config from Remnawave Panel by the user's "
|
||||
"subscription short UUID."
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED",
|
||||
"bool",
|
||||
"subscription_guides",
|
||||
"Enable admin JSON override",
|
||||
"Use the JSON field below instead of Remnawave Panel config. Disabled by default.",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PAGE_CONFIG_PATH",
|
||||
"string",
|
||||
"subscription_guides",
|
||||
"Subscription Page config path",
|
||||
"Fallback path to a Remnawave Subscription Page v1 JSON config file.",
|
||||
placeholder="data/subpage-config/multiapp.json",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PAGE_CONFIG_JSON",
|
||||
"json",
|
||||
"subscription_guides",
|
||||
"Subscription Page config JSON",
|
||||
(
|
||||
"Optional admin JSON override. It is applied only when the JSON override "
|
||||
"switch is enabled."
|
||||
),
|
||||
placeholder='{\n "version": "1"\n}',
|
||||
),
|
||||
# ─── Subscription periods & pricing ────────────────────────────
|
||||
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
|
||||
SettingField("MONTH_3_ENABLED", "bool", "pricing", "Тариф 3 месяца"),
|
||||
SettingField("MONTH_6_ENABLED", "bool", "pricing", "Тариф 6 месяцев"),
|
||||
SettingField("MONTH_12_ENABLED", "bool", "pricing", "Тариф 12 месяцев"),
|
||||
SettingField("RUB_PRICE_1_MONTH", "int", "pricing", "Цена 1 мес. (RUB)"),
|
||||
SettingField("RUB_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (RUB)"),
|
||||
SettingField("RUB_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (RUB)"),
|
||||
SettingField("RUB_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (RUB)"),
|
||||
SettingField("STARS_PRICE_1_MONTH", "int", "pricing", "Цена 1 мес. (Stars)"),
|
||||
SettingField("STARS_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (Stars)"),
|
||||
SettingField("STARS_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (Stars)"),
|
||||
SettingField("STARS_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (Stars)"),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашающему: 1 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашающему: 3 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашающему: 6 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашающему: 12 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашённому: 1 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашённому: 3 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашённому: 6 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашённому: 12 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
|
||||
),
|
||||
SettingField("STARS_TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика (Stars)"),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED",
|
||||
"bool",
|
||||
"payments",
|
||||
"Показывать описание подписки",
|
||||
"Текст появится перед выбором срока покупки или продления.",
|
||||
subsection="checkout",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PURCHASE_DESCRIPTION_RU",
|
||||
"text",
|
||||
"payments",
|
||||
"Описание подписки (RU)",
|
||||
"Русская версия текста на этапе оплаты.",
|
||||
subsection="checkout",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PURCHASE_DESCRIPTION_EN",
|
||||
"text",
|
||||
"payments",
|
||||
"Описание подписки (EN)",
|
||||
"Английская версия текста на этапе оплаты.",
|
||||
subsection="checkout",
|
||||
),
|
||||
# ─── Payment providers (toggles) ───────────────────────────────
|
||||
# Common
|
||||
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="common"),
|
||||
SettingField(
|
||||
"STARS_ADMIN_ONLY_ENABLED",
|
||||
"bool",
|
||||
"payments",
|
||||
"Telegram Stars admin-only",
|
||||
(
|
||||
"Shows Telegram Stars only to users from ADMIN_IDS. "
|
||||
"Payment callbacks remain active for admin test payments."
|
||||
),
|
||||
subsection="common",
|
||||
i18n_label_key="admin_settings_provider_admin_only_label",
|
||||
i18n_description_key="admin_settings_provider_admin_only_description",
|
||||
),
|
||||
SettingField(
|
||||
"PAYMENT_METHODS_ORDER",
|
||||
"string",
|
||||
"payments",
|
||||
"Порядок методов оплаты",
|
||||
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket",
|
||||
subsection="common",
|
||||
),
|
||||
# ─── Trial ─────────────────────────────────────────────────────
|
||||
SettingField(
|
||||
"TRIAL_ENABLED",
|
||||
"bool",
|
||||
"pricing",
|
||||
"Триал включён",
|
||||
optional=False,
|
||||
subsection="trial",
|
||||
),
|
||||
SettingField(
|
||||
"TRIAL_DURATION_DAYS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Длительность триала (дней)",
|
||||
optional=False,
|
||||
min=0,
|
||||
subsection="trial",
|
||||
),
|
||||
SettingField(
|
||||
"TRIAL_TRAFFIC_LIMIT_GB",
|
||||
"float",
|
||||
"pricing",
|
||||
"Лимит трафика триала (ГБ)",
|
||||
optional=False,
|
||||
min=0,
|
||||
subsection="trial",
|
||||
),
|
||||
SettingField(
|
||||
"TRIAL_TRAFFIC_STRATEGY",
|
||||
"string",
|
||||
"pricing",
|
||||
"Стратегия сброса трафика триала",
|
||||
optional=False,
|
||||
subsection="trial",
|
||||
),
|
||||
SettingField(
|
||||
"TRIAL_SQUAD_UUIDS",
|
||||
"string",
|
||||
"pricing",
|
||||
"Internal Squads для триала",
|
||||
"UUID через запятую. Если пусто, используется USER_SQUAD_UUIDS.",
|
||||
subsection="trial",
|
||||
),
|
||||
# ─── Referral program ──────────────────────────────────────────
|
||||
SettingField(
|
||||
"REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого"
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
|
||||
),
|
||||
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
|
||||
# ─── Notifications ─────────────────────────────────────────────
|
||||
SettingField(
|
||||
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
|
||||
"bool",
|
||||
"notifications",
|
||||
"Включены уведомления о подписке",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED",
|
||||
"bool",
|
||||
"notifications",
|
||||
"Дублировать уведомления о подписке на email",
|
||||
"Письма отправляются только пользователям с привязанным email и рабочим SMTP.",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", "bool", "notifications", "Уведомлять после истечения"
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_NOTIFY_DAYS_BEFORE",
|
||||
"int",
|
||||
"notifications",
|
||||
"За сколько дней предупреждать",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_NOTIFY_HOURS_BEFORE",
|
||||
"int",
|
||||
"notifications",
|
||||
"За сколько часов предупреждать",
|
||||
min=0,
|
||||
max=23,
|
||||
),
|
||||
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
|
||||
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
|
||||
SettingField("LOG_SUPPORT", "bool", "notifications", "Логировать тикеты поддержки"),
|
||||
SettingField(
|
||||
"LOG_PROMO_ACTIVATIONS", "bool", "notifications", "Логировать активации промокодов"
|
||||
),
|
||||
SettingField("LOG_TRIAL_ACTIVATIONS", "bool", "notifications", "Логировать активации триала"),
|
||||
SettingField(
|
||||
"LOG_SUSPICIOUS_ACTIVITY", "bool", "notifications", "Логировать подозрительные действия"
|
||||
),
|
||||
SettingField(
|
||||
"LOG_ADMIN_ACTIONS",
|
||||
"bool",
|
||||
"notifications",
|
||||
"Логировать действия администраторов",
|
||||
"Если выключено, события от пользователей из ADMIN_IDS не записываются в message logs.",
|
||||
i18n_label_key="admin_settings_field_log_admin_actions_label",
|
||||
i18n_description_key="admin_settings_field_log_admin_actions_description",
|
||||
),
|
||||
SettingField(
|
||||
"LOG_LEVEL",
|
||||
"string",
|
||||
"notifications",
|
||||
"Глобальный уровень логов",
|
||||
"DEBUG / INFO / WARNING / ERROR",
|
||||
),
|
||||
SettingField("LOG_CHAT_ID", "int", "notifications", "ID чата для логов"),
|
||||
SettingField("LOG_THREAD_ID", "int", "notifications", "ID треда (для супергрупп)"),
|
||||
SettingField(
|
||||
"LOG_SUPPORT_THREAD_ID",
|
||||
"int",
|
||||
"notifications",
|
||||
"ID треда поддержки",
|
||||
"Тред лог-чата для уведомлений о тикетах поддержки.",
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_ENABLED",
|
||||
"bool",
|
||||
"backups",
|
||||
"Бэкапы включены",
|
||||
"Worker будет периодически собирать ZIP-архив и отправлять его в Telegram.",
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_CHAT_ID",
|
||||
"int",
|
||||
"backups",
|
||||
"ID чата для бэкапов",
|
||||
"Куда отправлять ZIP-архивы. Если пусто, используется LOG_CHAT_ID.",
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_THREAD_ID",
|
||||
"int",
|
||||
"backups",
|
||||
"ID треда для бэкапов",
|
||||
"Необязательный topic/thread ID. Если пусто, используется LOG_THREAD_ID.",
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_INTERVAL_SECONDS",
|
||||
"int",
|
||||
"backups",
|
||||
"Период бэкапов (сек.)",
|
||||
"По умолчанию 3600: запуск на границе часа (12:00, 13:00 и т.д.).",
|
||||
optional=False,
|
||||
min=60,
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_LOCAL_RETENTION",
|
||||
"int",
|
||||
"backups",
|
||||
"Сколько архивов хранить",
|
||||
"Сколько последних ZIP-архивов оставлять в data/backups на сервере.",
|
||||
optional=False,
|
||||
min=1,
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_COMPOSE_ENABLED",
|
||||
"bool",
|
||||
"backups",
|
||||
"Добавлять compose-папку",
|
||||
(
|
||||
"Добавляет snapshot /app/compose-source. Если папка не смонтирована, "
|
||||
"бэкап БД все равно будет создан."
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_TICKETS_ENABLED",
|
||||
"bool",
|
||||
"support",
|
||||
"Тикеты поддержки включены",
|
||||
"Показывает раздел поддержки в ЛК и включает создание тикетов.",
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED",
|
||||
"bool",
|
||||
"support",
|
||||
"Email-уведомления админам",
|
||||
(
|
||||
"Если выключено, новые тикеты и ответы пользователей останутся "
|
||||
"только в Telegram и лог-чате."
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS",
|
||||
"int",
|
||||
"support",
|
||||
"Пауза Telegram-уведомлений",
|
||||
(
|
||||
"Минимум секунд между повторными Telegram/log уведомлениями "
|
||||
"по одному непрочитанному тикету."
|
||||
),
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS",
|
||||
"int",
|
||||
"support",
|
||||
"Пауза email-уведомлений",
|
||||
"Минимум секунд между повторными email-уведомлениями по одному непрочитанному тикету.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_TICKET_MAX_BODY_LENGTH",
|
||||
"int",
|
||||
"support",
|
||||
"Макс. длина сообщения",
|
||||
"Максимальное количество символов в сообщении тикета.",
|
||||
min=1,
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_TICKET_MAX_SUBJECT_LENGTH",
|
||||
"int",
|
||||
"support",
|
||||
"Макс. длина темы",
|
||||
"Максимальное количество символов в теме тикета.",
|
||||
min=1,
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_TICKET_RATE_LIMIT_PER_HOUR",
|
||||
"int",
|
||||
"support",
|
||||
"Лимит тикетов в час",
|
||||
"Сколько новых тикетов пользователь может создать за час. 0 — без лимита.",
|
||||
min=0,
|
||||
),
|
||||
# ─── Devices ───────────────────────────────────────────────────
|
||||
SettingField("MY_DEVICES_SECTION_ENABLED", "bool", "devices", "Раздел «Мои устройства»"),
|
||||
SettingField(
|
||||
"USER_HWID_DEVICE_LIMIT", "int", "devices", "Лимит устройств по умолчанию (0 = ∞)", min=0
|
||||
),
|
||||
SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"),
|
||||
SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"),
|
||||
# ─── System ────────────────────────────────────────────────────
|
||||
SettingField(
|
||||
"TELEMETRY_ENABLED",
|
||||
"bool",
|
||||
"system",
|
||||
"Анонимная статистика установки",
|
||||
"Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число "
|
||||
"пользователей в виде диапазона. Без персональных данных, токенов и "
|
||||
"доменов. Помогает понять число активных установок и какие версии "
|
||||
"используются. Можно отключить здесь без перезапуска.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _provider_field_to_setting_field(spec: Any, manifest_field: Any) -> SettingField:
|
||||
return SettingField(
|
||||
key=manifest_field.key,
|
||||
type=manifest_field.type,
|
||||
section="payments",
|
||||
label=manifest_field.label,
|
||||
description=manifest_field.description,
|
||||
placeholder=manifest_field.placeholder,
|
||||
optional=manifest_field.optional,
|
||||
secret=manifest_field.secret,
|
||||
min=manifest_field.min,
|
||||
max=manifest_field.max,
|
||||
choices=tuple(manifest_field.choices) if manifest_field.choices else None,
|
||||
subsection=manifest_field.subsection,
|
||||
i18n_label_key=getattr(manifest_field, "i18n_label_key", None),
|
||||
i18n_description_key=getattr(manifest_field, "i18n_description_key", None),
|
||||
i18n_subsection_key=getattr(manifest_field, "i18n_subsection_key", None),
|
||||
)
|
||||
|
||||
|
||||
def aggregated_manifest() -> List[SettingField]:
|
||||
"""SETTINGS_MANIFEST + per-provider fragments declared in provider SPECs."""
|
||||
from bot.payment_providers import iter_provider_manifest_fields # local to avoid cycle
|
||||
|
||||
fields: List[SettingField] = list(SETTINGS_MANIFEST)
|
||||
for spec, manifest_field in iter_provider_manifest_fields():
|
||||
fields.append(_provider_field_to_setting_field(spec, manifest_field))
|
||||
return fields
|
||||
|
||||
|
||||
def get_field_by_key(key: str) -> Optional[SettingField]:
|
||||
for field in aggregated_manifest():
|
||||
if field.key == key:
|
||||
return field
|
||||
return None
|
||||
|
||||
|
||||
def manifest_keys() -> List[str]:
|
||||
return [f.key for f in aggregated_manifest()]
|
||||
|
||||
|
||||
def coerce_value(field: SettingField, raw: Any) -> Any:
|
||||
"""Coerce a value coming from JSON to the type declared by the field."""
|
||||
|
||||
if field.type == "json":
|
||||
if raw is None:
|
||||
return ""
|
||||
text = raw if isinstance(raw, str) else str(raw)
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return ""
|
||||
from config.subscription_guides_config import validate_subscription_guides_config_text
|
||||
|
||||
validate_subscription_guides_config_text(text)
|
||||
return text
|
||||
|
||||
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
|
||||
if not field.optional:
|
||||
raise ValueError(f"{field.key}: value required")
|
||||
return None
|
||||
|
||||
if field.type == "bool":
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
if isinstance(raw, (int, float)):
|
||||
return bool(raw)
|
||||
if isinstance(raw, str):
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
return bool(raw)
|
||||
|
||||
if field.type == "int":
|
||||
try:
|
||||
value = int(str(raw).strip())
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{field.key}: integer expected") from exc
|
||||
if field.min is not None and value < field.min:
|
||||
raise ValueError(f"{field.key}: must be >= {field.min:g}")
|
||||
if field.max is not None and value > field.max:
|
||||
raise ValueError(f"{field.key}: must be <= {field.max:g}")
|
||||
return value
|
||||
|
||||
if field.type == "float":
|
||||
try:
|
||||
value = float(str(raw).strip())
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{field.key}: number expected") from exc
|
||||
if field.min is not None and value < field.min:
|
||||
raise ValueError(f"{field.key}: must be >= {field.min:g}")
|
||||
if field.max is not None and value > field.max:
|
||||
raise ValueError(f"{field.key}: must be <= {field.max:g}")
|
||||
return value
|
||||
|
||||
if isinstance(raw, str):
|
||||
return raw.strip()
|
||||
return str(raw)
|
||||
|
||||
|
||||
def _i18n_slug(value: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "_", value.strip().lower()).strip("_")
|
||||
return slug or "default"
|
||||
|
||||
|
||||
def manifest_payload() -> List[dict]:
|
||||
"""Serialize the manifest for the admin UI.
|
||||
|
||||
For provider presentation fields we resolve the SPEC-declared default
|
||||
(e.g. the button text the bot would use if the admin leaves the override
|
||||
blank) and expose it as ``default``; ``placeholder`` falls back to the
|
||||
same value so existing UIs that only read ``placeholder`` also show the
|
||||
hint inside the empty input.
|
||||
"""
|
||||
from bot.payment_providers import (
|
||||
find_manifest_owner,
|
||||
manifest_field_default,
|
||||
provider_admin_only_pairs,
|
||||
provider_webhook_metadata,
|
||||
)
|
||||
|
||||
sections_order = {
|
||||
"general": 1,
|
||||
"appearance": 2,
|
||||
"remnawave": 3,
|
||||
"pricing": 11,
|
||||
"payments": 4,
|
||||
"trial": 5,
|
||||
"referral": 6,
|
||||
"notifications": 7,
|
||||
"support": 8,
|
||||
"backups": 9,
|
||||
"devices": 10,
|
||||
"subscription_guides": 10,
|
||||
"system": 12,
|
||||
}
|
||||
exclusive_map = {
|
||||
key: opposite
|
||||
for public_key, admin_key in provider_admin_only_pairs()
|
||||
for key, opposite in ((public_key, admin_key), (admin_key, public_key))
|
||||
}
|
||||
items: List[dict] = []
|
||||
for field in aggregated_manifest():
|
||||
auto_label_i18n_key = f"admin_settings_field_{field.key.lower()}_label"
|
||||
auto_description_i18n_key = f"admin_settings_field_{field.key.lower()}_description"
|
||||
auto_subsection_i18n_key = (
|
||||
f"admin_settings_subsection_{_i18n_slug(field.subsection)}"
|
||||
if field.subsection
|
||||
else None
|
||||
)
|
||||
|
||||
default_value: Optional[str] = None
|
||||
webhook_metadata: Optional[dict] = None
|
||||
owner = find_manifest_owner(field.key)
|
||||
if owner is not None:
|
||||
spec, manifest_field = owner
|
||||
default_value = manifest_field_default(spec, manifest_field)
|
||||
webhook_metadata = provider_webhook_metadata(spec)
|
||||
|
||||
placeholder = field.placeholder
|
||||
if not placeholder and default_value:
|
||||
placeholder = default_value
|
||||
|
||||
item = {
|
||||
"key": field.key,
|
||||
"type": field.type,
|
||||
"section": field.section,
|
||||
"section_order": sections_order.get(field.section, 99),
|
||||
"subsection": field.subsection,
|
||||
"label": field.label,
|
||||
"description": field.description,
|
||||
"i18n_label_key": field.i18n_label_key or auto_label_i18n_key,
|
||||
"i18n_description_key": field.i18n_description_key
|
||||
or (auto_description_i18n_key if field.description else None),
|
||||
"i18n_subsection_key": field.i18n_subsection_key or auto_subsection_i18n_key,
|
||||
"i18n_placeholder_key": (
|
||||
f"admin_settings_field_{field.key.lower()}_placeholder" if placeholder else None
|
||||
),
|
||||
"placeholder": placeholder,
|
||||
"optional": field.optional,
|
||||
"secret": field.secret,
|
||||
}
|
||||
if field.min is not None:
|
||||
item["min"] = field.min
|
||||
if field.max is not None:
|
||||
item["max"] = field.max
|
||||
if field.key in exclusive_map:
|
||||
item["mutually_exclusive_key"] = exclusive_map[field.key]
|
||||
if default_value is not None:
|
||||
item["default"] = default_value
|
||||
if webhook_metadata:
|
||||
item.update(webhook_metadata)
|
||||
if field.webhook_path:
|
||||
item["webhook_path"] = field.webhook_path
|
||||
item["webhook_requires_base_url"] = field.webhook_requires_base_url
|
||||
if field.webhook_provider_id:
|
||||
item["provider_id"] = field.webhook_provider_id
|
||||
if field.webhook_hint_i18n_key:
|
||||
item["webhook_hint_i18n_key"] = field.webhook_hint_i18n_key
|
||||
if field.webhook_hint:
|
||||
item["webhook_hint"] = field.webhook_hint
|
||||
if field.choices:
|
||||
item["choices"] = [
|
||||
{
|
||||
"value": v,
|
||||
"label": lbl,
|
||||
"i18n_label_key": (
|
||||
f"admin_settings_field_{field.key.lower()}_choice_{_i18n_slug(str(v))}"
|
||||
),
|
||||
}
|
||||
for v, lbl in field.choices
|
||||
]
|
||||
items.append(item)
|
||||
return items
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from bot.app.web.webapp_auth import verify_webapp_session_token
|
||||
from config.settings import Settings
|
||||
|
||||
WEBAPP_SESSION_COOKIE_NAME = "rw_webapp_session"
|
||||
|
||||
|
||||
def extract_authenticated_user_id(request: web.Request) -> Optional[int]:
|
||||
settings: Settings = request.app["settings"]
|
||||
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
user_id = verify_webapp_session_token(
|
||||
settings,
|
||||
auth_header.removeprefix("Bearer ").strip(),
|
||||
)
|
||||
if user_id:
|
||||
return user_id
|
||||
|
||||
session_cookie = request.cookies.get(WEBAPP_SESSION_COOKIE_NAME)
|
||||
if session_cookie:
|
||||
return verify_webapp_session_token(settings, session_cookie)
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Compatibility facade for the subscription Mini App backend."""
|
||||
|
||||
# ruff: noqa: I001
|
||||
|
||||
from bot.app.web.webapp import (
|
||||
_runtime as _runtime,
|
||||
account as _account,
|
||||
application as _application,
|
||||
assets as _assets,
|
||||
auth as _auth,
|
||||
billing as _billing,
|
||||
common as _common,
|
||||
devices as _devices,
|
||||
guides as _guides,
|
||||
payloads as _payloads,
|
||||
routes as _routes,
|
||||
serializers as _serializers,
|
||||
support as _support,
|
||||
telegram_notifications as _telegram_notifications,
|
||||
)
|
||||
|
||||
_MODULES = (
|
||||
_runtime,
|
||||
_payloads,
|
||||
_common,
|
||||
_assets,
|
||||
_auth,
|
||||
_account,
|
||||
_telegram_notifications,
|
||||
_serializers,
|
||||
_billing,
|
||||
_devices,
|
||||
_guides,
|
||||
_support,
|
||||
_routes,
|
||||
_application,
|
||||
)
|
||||
|
||||
_NAMESPACE = {}
|
||||
for _module in _MODULES:
|
||||
_NAMESPACE.update(
|
||||
{
|
||||
_name: _value
|
||||
for _name, _value in vars(_module).items()
|
||||
if not _name.startswith("__") and _name != "annotations"
|
||||
}
|
||||
)
|
||||
|
||||
for _module in _MODULES:
|
||||
vars(_module).update(_NAMESPACE)
|
||||
|
||||
globals().update(_NAMESPACE)
|
||||
|
||||
__all__ = sorted(_name for _name in _NAMESPACE if not _name.startswith("__"))
|
||||
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 738 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,223 @@
|
||||
<!doctype html>
|
||||
<html lang="__LANG__">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link id="app-favicon" rel="icon" href="data:," sizes="any">
|
||||
<title>__PAGE_TITLE__</title>
|
||||
<style nonce="__NONCE__">
|
||||
:root {
|
||||
color-scheme: dark light;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", sans-serif;
|
||||
background: #0b1017;
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(100%, 420px);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #aeb8c5;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
min-height: 46px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: #14b86f;
|
||||
color: #03120b;
|
||||
padding: 0 18px;
|
||||
box-sizing: border-box;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
border-color: #2d3847;
|
||||
background: transparent;
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.button[aria-disabled="true"] {
|
||||
pointer-events: none;
|
||||
background: #344052;
|
||||
color: #aeb8c5;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1 id="title"></h1>
|
||||
<p id="status"></p>
|
||||
<div class="actions">
|
||||
<a id="open-link" class="button" href="#" rel="noreferrer"></a>
|
||||
<button id="close-button" class="button secondary" type="button" hidden></button>
|
||||
</div>
|
||||
</main>
|
||||
<script nonce="__NONCE__">
|
||||
(() => {
|
||||
const messages = __MESSAGES_JSON__;
|
||||
const titleEl = document.getElementById("title");
|
||||
const statusEl = document.getElementById("status");
|
||||
const openLink = document.getElementById("open-link");
|
||||
const closeButton = document.getElementById("close-button");
|
||||
const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||
const target = String(params.get("url") || "").trim();
|
||||
const isUnsafe =
|
||||
!target ||
|
||||
hasControlChars(target) ||
|
||||
/^(?:javascript|data|vbscript|https?):/i.test(target);
|
||||
let attempted = false;
|
||||
let pageLeft = false;
|
||||
let state = "opening";
|
||||
let closeAttemptTimer = null;
|
||||
const CLOSE_ATTEMPT_DELAY_MS = 2500;
|
||||
|
||||
function hasControlChars(value) {
|
||||
return Array.from(String(value || "")).some((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code <= 31 || code === 127;
|
||||
});
|
||||
}
|
||||
|
||||
function text(key, fallback) {
|
||||
const value = messages && messages[key];
|
||||
return typeof value === "string" && value ? value : fallback;
|
||||
}
|
||||
|
||||
function tryCloseWindow() {
|
||||
try {
|
||||
window.close();
|
||||
} catch (_error) {
|
||||
void _error;
|
||||
}
|
||||
}
|
||||
|
||||
function render(nextState) {
|
||||
state = nextState;
|
||||
if (nextState === "unavailable") {
|
||||
titleEl.textContent = text("unavailableTitle", "App link unavailable");
|
||||
statusEl.textContent = text("unavailableHint", "Return to Telegram and try again.");
|
||||
openLink.textContent = text("button", "Open app");
|
||||
openLink.setAttribute("aria-disabled", "true");
|
||||
openLink.removeAttribute("href");
|
||||
closeButton.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextState === "done") {
|
||||
titleEl.textContent = text("doneTitle", "Settings added");
|
||||
statusEl.textContent = text("doneHint", "You can close this window.");
|
||||
openLink.textContent = text("retryButton", "Open again");
|
||||
openLink.removeAttribute("aria-disabled");
|
||||
openLink.href = target;
|
||||
closeButton.textContent = text("closeButton", "Close window");
|
||||
closeButton.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
titleEl.textContent = text("title", "Opening app");
|
||||
statusEl.textContent =
|
||||
nextState === "manual"
|
||||
? text("manualHint", "If the app did not open automatically, tap the button below.")
|
||||
: text("hint", "Opening the app on this device...");
|
||||
openLink.textContent = text("button", "Open app");
|
||||
openLink.removeAttribute("aria-disabled");
|
||||
openLink.href = target;
|
||||
closeButton.hidden = true;
|
||||
}
|
||||
|
||||
function markDone() {
|
||||
if (state === "done" || isUnsafe) return;
|
||||
render("done");
|
||||
if (closeAttemptTimer) window.clearTimeout(closeAttemptTimer);
|
||||
closeAttemptTimer = window.setTimeout(() => {
|
||||
if (pageLeft || document.hidden) tryCloseWindow();
|
||||
}, CLOSE_ATTEMPT_DELAY_MS);
|
||||
}
|
||||
|
||||
function notePageLeft() {
|
||||
if (!attempted) return;
|
||||
pageLeft = true;
|
||||
window.setTimeout(markDone, 900);
|
||||
}
|
||||
|
||||
function openTarget() {
|
||||
if (isUnsafe) return;
|
||||
attempted = true;
|
||||
pageLeft = false;
|
||||
render("opening");
|
||||
window.location.href = target;
|
||||
window.setTimeout(() => {
|
||||
if (state === "opening" && !pageLeft) render("manual");
|
||||
}, 1600);
|
||||
}
|
||||
|
||||
if (isUnsafe) {
|
||||
render("unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
openLink.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
openTarget();
|
||||
});
|
||||
closeButton.addEventListener("click", () => {
|
||||
tryCloseWindow();
|
||||
render("done");
|
||||
});
|
||||
window.addEventListener("pagehide", notePageLeft);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!attempted) return;
|
||||
if (document.hidden) {
|
||||
pageLeft = true;
|
||||
} else if (pageLeft) {
|
||||
markDone();
|
||||
}
|
||||
});
|
||||
|
||||
render("opening");
|
||||
window.setTimeout(openTarget, 80);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,73 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#03070b" />
|
||||
<link id="app-favicon" rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png" />
|
||||
<link
|
||||
id="app-apple-touch-icon"
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon-precomposed"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon-precomposed.png"
|
||||
/>
|
||||
<title>/minishop</title>
|
||||
<link rel="stylesheet" href="/subscription_webapp.css" />
|
||||
<style>
|
||||
.app-boot-fallback {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: #03070b;
|
||||
}
|
||||
|
||||
.app-boot-fallback__spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 2px solid rgba(242, 247, 244, 0.18);
|
||||
border-top-color: #00fe7a;
|
||||
border-radius: 999px;
|
||||
animation: appBootSpin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-boot-fallback__spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes appBootSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main id="app">
|
||||
<div class="app-boot-fallback" role="status" aria-label="Загрузка">
|
||||
<div class="app-boot-fallback__spinner" aria-hidden="true"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- WEBAPP_I18N_SCRIPT -->
|
||||
<!-- WEBAPP_CONFIG_SCRIPT -->
|
||||
<!-- WEBAPP_JS_SCRIPT -->
|
||||
<!-- WEBAPP_DEV_MOCK_START -->
|
||||
<script src="/subscription_webapp.js" defer></script>
|
||||
<!-- WEBAPP_DEV_MOCK_END -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"key": "ascii",
|
||||
"names": {
|
||||
"ru": "ASCII",
|
||||
"en": "ASCII"
|
||||
},
|
||||
"enabled": true,
|
||||
"default": false,
|
||||
"use_primary_accent": false,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 4,
|
||||
"tokens": {
|
||||
"color_scheme": "dark",
|
||||
"style_preset": "ascii"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"key": "dark",
|
||||
"names": {
|
||||
"ru": "Темная",
|
||||
"en": "Dark"
|
||||
},
|
||||
"enabled": true,
|
||||
"default": true,
|
||||
"use_primary_accent": true,
|
||||
"use_in_admin": true,
|
||||
"assets_version": 1,
|
||||
"tokens": {
|
||||
"color_scheme": "dark",
|
||||
"bg": "#03070b",
|
||||
"panel": "#111820",
|
||||
"panel_2": "#0b1118",
|
||||
"panel_3": "#17212b",
|
||||
"border": "rgba(255, 255, 255, 0.12)",
|
||||
"border_strong": "rgba(255, 255, 255, 0.2)",
|
||||
"text": "#f2f7f4",
|
||||
"muted": "#a9b4b0",
|
||||
"dim": "#68736f",
|
||||
"danger": "#ff6b6b",
|
||||
"blue": "#2d9cff",
|
||||
"radius": "8px"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
.theme-key-light {
|
||||
color-scheme: light;
|
||||
--accent: #047857;
|
||||
--bg: #f7f8fb;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f1f5f9;
|
||||
--panel-3: #e8edf3;
|
||||
--border: rgba(15, 23, 42, 0.11);
|
||||
--border-strong: rgba(15, 23, 42, 0.2);
|
||||
--text: #0f172a;
|
||||
--muted: #475569;
|
||||
--dim: #64748b;
|
||||
--danger: #dc2626;
|
||||
--danger-text: #b91c1c;
|
||||
--danger-soft: color-mix(in srgb, var(--danger) 9%, var(--panel));
|
||||
--danger-border: color-mix(in srgb, var(--danger) 34%, var(--border));
|
||||
--success: #16a34a;
|
||||
--success-text: #166534;
|
||||
--success-soft: color-mix(in srgb, var(--success) 10%, var(--panel));
|
||||
--success-border: color-mix(in srgb, var(--success) 34%, var(--border));
|
||||
--warning: #d97706;
|
||||
--warning-text: #92400e;
|
||||
--warning-soft: color-mix(in srgb, var(--warning) 11%, var(--panel));
|
||||
--warning-border: color-mix(in srgb, var(--warning) 34%, var(--border));
|
||||
--info: #2563eb;
|
||||
--info-text: #1d4ed8;
|
||||
--info-soft: color-mix(in srgb, var(--info) 9%, var(--panel));
|
||||
--info-border: color-mix(in srgb, var(--info) 30%, var(--border));
|
||||
--blue: #2563eb;
|
||||
--radius: 8px;
|
||||
--accent-contrast: #ffffff;
|
||||
--surface-sheen: rgba(15, 23, 42, 0.035);
|
||||
--surface-sheen-soft: rgba(15, 23, 42, 0.012);
|
||||
--surface-hover: rgba(15, 23, 42, 0.045);
|
||||
--surface-muted: rgba(15, 23, 42, 0.035);
|
||||
--surface-subtle: rgba(15, 23, 42, 0.025);
|
||||
--surface-subtle-border: rgba(15, 23, 42, 0.1);
|
||||
--overlay-scrim: rgba(15, 23, 42, 0.34);
|
||||
--nav-bg: rgba(255, 255, 255, 0.88);
|
||||
--rail-bg: rgba(255, 255, 255, 0.72);
|
||||
--shadow-soft: 0 6px 18px rgba(15, 23, 42, 0.06);
|
||||
--shadow-strong: 0 18px 44px rgba(15, 23, 42, 0.12);
|
||||
--shadow-popover: 0 14px 28px rgba(15, 23, 42, 0.12);
|
||||
--inset-highlight: rgba(255, 255, 255, 0.75);
|
||||
--admin-bg: #f7f8fb;
|
||||
--admin-surface: #ffffff;
|
||||
--admin-surface-2: #f1f5f9;
|
||||
--admin-elev: #e8edf3;
|
||||
--admin-border: rgba(15, 23, 42, 0.1);
|
||||
--admin-border-strong: rgba(15, 23, 42, 0.18);
|
||||
--admin-text: #0f172a;
|
||||
--admin-muted: #64748b;
|
||||
--admin-dim: #64748b;
|
||||
--admin-chart-stroke: #065f46;
|
||||
--admin-chart-fill: rgba(6, 95, 70, 0.22);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-spinner,
|
||||
.theme-key-light .brand-mark-spinner {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.theme-key-light .telegram-button-spinner {
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-light .btn-primary,
|
||||
.theme-key-light .admin-btn.admin-btn-primary,
|
||||
.theme-key-light .admin-extend-control .admin-btn.admin-btn-primary {
|
||||
background: color-mix(in srgb, var(--accent) 50%, #000000);
|
||||
border-color: color-mix(in srgb, var(--accent) 42%, #000000);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-light .btn-primary:hover:not(:disabled),
|
||||
.theme-key-light .admin-btn.admin-btn-primary:hover:not(:disabled),
|
||||
.theme-key-light .admin-extend-control .admin-btn.admin-btn-primary:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent) 52%, #000000);
|
||||
}
|
||||
|
||||
.theme-key-light.app-shell {
|
||||
background: var(--bg) !important;
|
||||
}
|
||||
|
||||
.theme-key-light .phone-screen {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* Flatten Settings rows: no gradient sheen, no inset highlight that reads as a 3D bevel */
|
||||
.theme-key-light .settings-row {
|
||||
background: var(--panel);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.theme-key-light .settings-row-linked {
|
||||
background: var(--success-soft);
|
||||
}
|
||||
|
||||
/* Avatar/profile card: bigger lift, but rows below have an opaque background and
|
||||
stack above, so the shadow stays visually under them instead of bleeding through. */
|
||||
.theme-key-light .settings-profile {
|
||||
box-shadow:
|
||||
0 10px 24px rgba(15, 23, 42, 0.10),
|
||||
inset 0 1px 0 var(--inset-highlight);
|
||||
}
|
||||
|
||||
.theme-key-light .settings-links-block {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* New user-facing activation surfaces */
|
||||
.theme-key-light .trial-offer-card,
|
||||
.theme-key-light .trial-activation-card,
|
||||
.theme-key-light .activation-success-dialog {
|
||||
border-color: color-mix(in srgb, var(--accent) 24%, var(--border));
|
||||
background: #ffffff;
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.theme-key-light .trial-card-head > svg,
|
||||
.theme-key-light .dialog-title-icon {
|
||||
color: color-mix(in srgb, var(--accent) 54%, #000000);
|
||||
}
|
||||
|
||||
.theme-key-light .trial-card-facts span,
|
||||
.theme-key-light .trial-activation-facts div {
|
||||
border-color: rgba(15, 23, 42, 0.12);
|
||||
background: rgba(15, 23, 42, 0.025);
|
||||
}
|
||||
|
||||
/* Slightly stronger axis/grid contrast for the revenue chart on a light surface */
|
||||
.theme-key-light .admin-revenue-svg-frame {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* Bonus section: drop accent color from body strongs; only the bonus-system heading
|
||||
and explicitly-accent card headings stay tinted — and they use the same darkened
|
||||
accent technique as .btn-primary on light, so they remain readable on white. */
|
||||
.theme-key-light .bonus-card strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.theme-key-light .bonus-card-head strong,
|
||||
.theme-key-light .card-heading-accent {
|
||||
color: color-mix(in srgb, var(--accent) 50%, #000000);
|
||||
}
|
||||
|
||||
.theme-key-light .bonus-card-head > svg {
|
||||
color: color-mix(in srgb, var(--accent) 50%, #000000);
|
||||
}
|
||||
|
||||
/* Install guide theme surfaces */
|
||||
|
||||
.theme-key-light .install-platform-trigger,
|
||||
.theme-key-light .install-app-button,
|
||||
.theme-key-light .install-step,
|
||||
.theme-key-light .install-subscription-card,
|
||||
.theme-key-light .install-qr-wrap {
|
||||
background: #ffffff;
|
||||
border-color: rgba(15, 23, 42, 0.12);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.055);
|
||||
}
|
||||
|
||||
.theme-key-light .install-app-button.active {
|
||||
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.theme-key-light .install-platform-trigger:focus-visible,
|
||||
.theme-key-light .install-platform-trigger[data-state="open"],
|
||||
.theme-key-light .install-app-button:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--accent) 48%, var(--border));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
|
||||
body:has(.theme-key-light) .install-platform-content {
|
||||
background: #ffffff;
|
||||
border-color: rgba(15, 23, 42, 0.14);
|
||||
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
body:has(.theme-key-light) .install-platform-item[data-highlighted],
|
||||
body:has(.theme-key-light) .install-platform-item[data-selected] {
|
||||
background: color-mix(in srgb, var(--accent) 9%, #ffffff);
|
||||
}
|
||||
|
||||
.theme-key-light .install-step-icon,
|
||||
.theme-key-light .install-subscription-header-icon {
|
||||
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
|
||||
color: color-mix(in srgb, var(--accent) 55%, #000000);
|
||||
}
|
||||
|
||||
.theme-key-light .install-qr-divider {
|
||||
color: rgba(15, 23, 42, 0.24);
|
||||
}
|
||||
|
||||
.theme-key-light .install-feature-star.attention-dot {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.theme-key-light .install-loading .ui-spinner {
|
||||
color: color-mix(in srgb, var(--accent) 55%, #000000);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"key": "light",
|
||||
"names": {
|
||||
"ru": "Светлая",
|
||||
"en": "Light"
|
||||
},
|
||||
"enabled": true,
|
||||
"default": false,
|
||||
"use_primary_accent": true,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 3,
|
||||
"tokens": {
|
||||
"color_scheme": "light"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 340 B |
|
After Width: | Height: | Size: 375 B |
|
After Width: | Height: | Size: 424 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 356 B |
|
After Width: | Height: | Size: 419 B |
|
After Width: | Height: | Size: 372 B |
|
After Width: | Height: | Size: 388 B |
|
After Width: | Height: | Size: 378 B |
|
After Width: | Height: | Size: 424 B |
|
After Width: | Height: | Size: 636 B |
|
After Width: | Height: | Size: 364 B |
|
After Width: | Height: | Size: 390 B |
|
After Width: | Height: | Size: 385 B |
|
After Width: | Height: | Size: 415 B |
|
After Width: | Height: | Size: 356 B |
|
After Width: | Height: | Size: 393 B |
|
After Width: | Height: | Size: 474 B |
|
After Width: | Height: | Size: 395 B |
|
After Width: | Height: | Size: 461 B |
|
After Width: | Height: | Size: 327 B |
|
After Width: | Height: | Size: 411 B |
|
After Width: | Height: | Size: 395 B |
|
After Width: | Height: | Size: 415 B |
|
After Width: | Height: | Size: 393 B |
|
After Width: | Height: | Size: 422 B |
|
After Width: | Height: | Size: 478 B |
|
After Width: | Height: | Size: 500 B |
|
After Width: | Height: | Size: 589 B |
|
After Width: | Height: | Size: 392 B |
|
After Width: | Height: | Size: 392 B |
|
After Width: | Height: | Size: 384 B |
|
After Width: | Height: | Size: 419 B |
|
After Width: | Height: | Size: 403 B |
|
After Width: | Height: | Size: 371 B |