Compare commits
836 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d72a5c91cf | ||
|
|
cacd992aad | ||
|
|
23ad016476 | ||
|
|
cc571409b9 | ||
|
|
d7bad69d40 | ||
|
|
63bb678a72 | ||
|
|
2a41c5a393 | ||
|
|
9b80492b4f | ||
|
|
315d74eb2d | ||
|
|
c21df253a1 | ||
|
|
e4e9369d54 | ||
|
|
26d05911fd | ||
|
|
85a6d66228 | ||
|
|
1c755306f9 | ||
|
|
2e97142d9e | ||
|
|
a08a0fd3e6 | ||
|
|
420a22970d | ||
|
|
d4109c8cf5 | ||
|
|
945d579f57 | ||
|
|
6e1f07563d | ||
|
|
ce6f8b41dc | ||
|
|
aa75305da4 | ||
|
|
03cf533c9d | ||
|
|
ccf0584db8 | ||
|
|
833fd26091 | ||
|
|
fd72c838c3 | ||
|
|
63c770db73 | ||
|
|
6237620390 | ||
|
|
4a9bac1027 | ||
|
|
817107622a | ||
|
|
ca7c553147 | ||
|
|
d00507fd20 | ||
|
|
2bbf97be19 | ||
|
|
4f135220bc | ||
|
|
a2500add5a | ||
|
|
26284e89f6 | ||
|
|
8030a7c896 | ||
|
|
5010159621 | ||
|
|
92090bcf99 | ||
|
|
655096ea1e | ||
|
|
362984a391 | ||
|
|
9c9e55147d | ||
|
|
f7a72133c6 | ||
|
|
341dd5930e | ||
|
|
5baa3b98cd | ||
|
|
99c6559a54 | ||
|
|
12827f6c84 | ||
|
|
ad942fd194 | ||
|
|
b5e348ad2b | ||
|
|
0b8376a19e | ||
|
|
b64e2ac9a5 | ||
|
|
c9e2e72d60 | ||
|
|
fc990a9ac2 | ||
|
|
8601fbf8e0 | ||
|
|
48b547661f | ||
|
|
423b060857 | ||
|
|
48cf1a7e39 | ||
|
|
47954db751 | ||
|
|
1fe868b41e | ||
|
|
07f683282a | ||
|
|
0a98eea1ac | ||
|
|
40d86214cb | ||
|
|
959b10c2a1 | ||
|
|
f11fc2e3bc | ||
|
|
2e2fcb0beb | ||
|
|
d2d90083be | ||
|
|
355ffd5975 | ||
|
|
0010ece89c | ||
|
|
c8db639701 | ||
|
|
3bb499dc9d | ||
|
|
45d83435cb | ||
|
|
2535aba2fe | ||
|
|
3dc039135a | ||
|
|
726f90bfae | ||
|
|
709c394d2d | ||
|
|
fb5ea43ef5 | ||
|
|
49f2026c47 | ||
|
|
5ad225fa78 | ||
|
|
155edc997a | ||
|
|
591547c167 | ||
|
|
14e8e63f30 | ||
|
|
ccba0e5137 | ||
|
|
a62e6a5dfc | ||
|
|
975c47a79b | ||
|
|
fcfd5f4981 | ||
|
|
e5315095be | ||
|
|
44e4b5fce2 | ||
|
|
4bb116417e | ||
|
|
51f604d061 | ||
|
|
ab7694b50b | ||
|
|
556e1411f4 | ||
|
|
5fa528fd2d | ||
|
|
3635a13682 | ||
|
|
b59cd73e43 | ||
|
|
6e31a7e2dd | ||
|
|
a4b94b4f45 | ||
|
|
622de21dcf | ||
|
|
0ab5bd1705 | ||
|
|
f74e85662a | ||
|
|
862217a627 | ||
|
|
861e72b331 | ||
|
|
550cb36bd1 | ||
|
|
03d3cee18b | ||
|
|
331e7c13fa | ||
|
|
429538688c | ||
|
|
70ee98f9f0 | ||
|
|
320e9cd9d0 | ||
|
|
50455b828d | ||
|
|
b525b03ef8 | ||
|
|
641669873d | ||
|
|
fb1b673dee | ||
|
|
598a389e3d | ||
|
|
1a0e32504f | ||
|
|
cbff3085fa | ||
|
|
0ecacc6064 | ||
|
|
f36a32773a | ||
|
|
58a3fe1f72 | ||
|
|
4d58fc5f88 | ||
|
|
7d947015d3 | ||
|
|
77d2ad3b6b | ||
|
|
f83695190d | ||
|
|
815721adb2 | ||
|
|
836974e1b2 | ||
|
|
96983d70c8 | ||
|
|
9400a5bc66 | ||
|
|
033319af29 | ||
|
|
787d0e7b4c | ||
|
|
d90617c027 | ||
|
|
98303a286a | ||
|
|
aa791f1948 | ||
|
|
e46466180d | ||
|
|
3b956ca82e | ||
|
|
a0e69009f0 | ||
|
|
59400dbc61 | ||
|
|
0b06dd29c4 | ||
|
|
0152acde9a | ||
|
|
273e5caa6b | ||
|
|
8d5aa0d0ae | ||
|
|
e75c71e0a2 | ||
|
|
f423b221e6 | ||
|
|
702e42b8b3 | ||
|
|
bbc99bbeaa | ||
|
|
e2543bda67 | ||
|
|
442ce1fac1 | ||
|
|
03367b2cac | ||
|
|
a2d212e396 | ||
|
|
cecf9ba0d4 | ||
|
|
5aebc323d5 | ||
|
|
2543cd08c2 | ||
|
|
9c353f2a91 | ||
|
|
722e7e66c2 | ||
|
|
1296992752 | ||
|
|
ab5a82858e | ||
|
|
073772ad60 | ||
|
|
d7e4431bd8 | ||
|
|
96ec12f2bd | ||
|
|
85eea23d98 | ||
|
|
51961d16ba | ||
|
|
d6d73e81d6 | ||
|
|
27a80734f9 | ||
|
|
1d4ea7d0a0 | ||
|
|
6d2e517e82 | ||
|
|
982d3ec4c9 | ||
|
|
5e653c51f3 | ||
|
|
875387166e | ||
|
|
909614c3e7 | ||
|
|
3178ca7572 | ||
|
|
442bdd2220 | ||
|
|
a680db92a7 | ||
|
|
08c350d50b | ||
|
|
fe20187b0c | ||
|
|
65a25a7e66 | ||
|
|
607d8e9566 | ||
|
|
8f3b860cc7 | ||
|
|
a3dc8b7e07 | ||
|
|
6bfb345169 | ||
|
|
704157be00 | ||
|
|
b1c578ccf4 | ||
|
|
7c9f917114 | ||
|
|
b5f28da452 | ||
|
|
34f5455ba5 | ||
|
|
1caf9b3c88 | ||
|
|
60ccc51232 | ||
|
|
1780aa567f | ||
|
|
6a8b21c5f2 | ||
|
|
fc4cd4cd27 | ||
|
|
465ed9f84f | ||
|
|
d88194b9bd | ||
|
|
6ebc7d18bf | ||
|
|
0fe574fbd9 | ||
|
|
c7ba9944f0 | ||
|
|
8781e48601 | ||
|
|
eb941794a8 | ||
|
|
0783749e6e | ||
|
|
87c0f54a8d | ||
|
|
febbe27a0d | ||
|
|
e67f1bf1a9 | ||
|
|
60dbfa2d1e | ||
|
|
0b43ad4ed5 | ||
|
|
94efe9f746 | ||
|
|
5fe0e0ab9f | ||
|
|
db1e812190 | ||
|
|
aab8d6ed77 | ||
|
|
5d49a56d94 | ||
|
|
492d5715fe | ||
|
|
0595224daa | ||
|
|
58098a45af | ||
|
|
a0bafadc39 | ||
|
|
2190f482d1 | ||
|
|
024b692b8c | ||
|
|
6a5e97b788 | ||
|
|
b8a1e416d4 | ||
|
|
3ea8f272f7 | ||
|
|
7c3f84ba9c | ||
|
|
0094ce7d57 | ||
|
|
c2b08a326d | ||
|
|
ba183660a9 | ||
|
|
423d8f5063 | ||
|
|
3c38a0edbf | ||
|
|
4df313fa43 | ||
|
|
35f1c06d34 | ||
|
|
12e745691e | ||
|
|
25ed44a5f3 | ||
|
|
4ea695d81e | ||
|
|
dd91a5cb86 | ||
|
|
9998aff69a | ||
|
|
5ebcb9d51c | ||
|
|
c60f93dfe8 | ||
|
|
95e77b2e21 | ||
|
|
5a335a1465 | ||
|
|
4e7256fb6c | ||
|
|
dd8119d952 | ||
|
|
4f8fd7fb5b | ||
|
|
bd573f34c0 | ||
|
|
37576f332c | ||
|
|
81f137eed1 | ||
|
|
cae22a9316 | ||
|
|
cbb8de01b7 | ||
|
|
a2e263a7d1 | ||
|
|
7a51acbfe4 | ||
|
|
aa04ede019 | ||
|
|
9cca1d97cd | ||
|
|
e7fcdf0e65 | ||
|
|
d123d6aa9e | ||
|
|
42d5785025 | ||
|
|
bdd14604d5 | ||
|
|
908e9f07c0 | ||
|
|
488ba7be38 | ||
|
|
a0165f6f02 | ||
|
|
92f825963a | ||
|
|
010ce5ff7a | ||
|
|
bc4c63b998 | ||
|
|
537b45951e | ||
|
|
a92f449e7f | ||
|
|
bcb6346f81 | ||
|
|
7cb66e26e5 | ||
|
|
41ddf73e4f | ||
|
|
4f02c44e39 | ||
|
|
3c87b78dd9 | ||
|
|
eb619b6544 | ||
|
|
79e8b24d7a | ||
|
|
80fd7c9842 | ||
|
|
9409370984 | ||
|
|
006fde502e | ||
|
|
c02cfffc9b | ||
|
|
688e941d64 | ||
|
|
0fd3981d9b | ||
|
|
617f7ee133 | ||
|
|
42d1abe130 | ||
|
|
d8e624ad22 | ||
|
|
30acc4f9b8 | ||
|
|
c93211b68f | ||
|
|
1d7d82b793 | ||
|
|
b40abafb95 | ||
|
|
18f8921eba | ||
|
|
285215cf4b | ||
|
|
fe4097a724 | ||
|
|
364b010ceb | ||
|
|
37bdf50bb0 | ||
|
|
70e35b8bd7 | ||
|
|
2657e74803 | ||
|
|
372514709d | ||
|
|
c922dc5b50 | ||
|
|
6fff8a887e | ||
|
|
0a7093a3b4 | ||
|
|
d8fe593323 | ||
|
|
4dcec4b9c7 | ||
|
|
ac56ad1400 | ||
|
|
d09ee59a1a | ||
|
|
3299398806 | ||
|
|
b53120f271 | ||
|
|
1dfe13951f | ||
|
|
732ce1bc57 | ||
|
|
94e076401e | ||
|
|
fb83094532 | ||
|
|
dec5197bfd | ||
|
|
ebff016b5d | ||
|
|
da0dc7f1c0 | ||
|
|
f6044578c0 | ||
|
|
699cbee240 | ||
|
|
ef253de56b | ||
|
|
9715f90a48 | ||
|
|
792296e3bc | ||
|
|
31d3e52229 | ||
|
|
4a92712c90 | ||
|
|
47188da5c2 | ||
|
|
bdae52fad7 | ||
|
|
1ec3ddad9f | ||
|
|
64a144034d | ||
|
|
d0f740f99d | ||
|
|
58c7b695c9 | ||
|
|
b19efc4ee6 | ||
|
|
8ba6131d22 | ||
|
|
c5683dbc71 | ||
|
|
3067db9c31 | ||
|
|
28440a9096 | ||
|
|
07d02f8302 | ||
|
|
01a75c3e23 | ||
|
|
4cc5fd7189 | ||
|
|
16c5420c6f | ||
|
|
eab33d9f6d | ||
|
|
471021623b | ||
|
|
e7f4de2202 | ||
|
|
44e8035ff0 | ||
|
|
e38ac62ae4 | ||
|
|
b47a481678 | ||
|
|
632393b88d | ||
|
|
d2da9048d7 | ||
|
|
f1b56268bb | ||
|
|
acba411c3a | ||
|
|
f26042a21e | ||
|
|
0967d471ee | ||
|
|
302c4c189c | ||
|
|
c52ba2162e | ||
|
|
2d98c6cff4 | ||
|
|
d710b9ad4d | ||
|
|
5cc97eaf17 | ||
|
|
dca83dcc8e | ||
|
|
3c0f3e90d8 | ||
|
|
d6f5c91d78 | ||
|
|
0c257b7342 | ||
|
|
c5f4098b5b | ||
|
|
0b9206012f | ||
|
|
41dff3d5bb | ||
|
|
763d231e64 | ||
|
|
8f3f1fcda8 | ||
|
|
93ae2ac6d8 | ||
|
|
6404fda04d | ||
|
|
3133996f33 | ||
|
|
cef84fad10 | ||
|
|
58c2fa3dde | ||
|
|
56ee54811d | ||
|
|
c1a2fb2d4a | ||
|
|
0b73e3ff2b | ||
|
|
0e9a7d0641 | ||
|
|
dda363b344 | ||
|
|
0ccc314833 | ||
|
|
da4470bc4f | ||
|
|
11eb907ced | ||
|
|
ea57d657fe | ||
|
|
71922212d9 | ||
|
|
bb41bc3844 | ||
|
|
39f6d14163 | ||
|
|
941367f77b | ||
|
|
2f4e68969a | ||
|
|
275d9188bf | ||
|
|
4b564d7f4a | ||
|
|
c7995cdbba | ||
|
|
e623897fc1 | ||
|
|
ee05f4fc19 | ||
|
|
436a1db087 | ||
|
|
f4a7238110 | ||
|
|
65662c57bc | ||
|
|
3559e32c2f | ||
|
|
a1612949bf | ||
|
|
ae808c5109 | ||
|
|
418f3c4566 | ||
|
|
399ce3b044 | ||
|
|
1aa100dc7d | ||
|
|
6347495b5b | ||
|
|
02f4ba6e8e | ||
|
|
d2e5209832 | ||
|
|
b5dea7755b | ||
|
|
848b532b3c | ||
|
|
a11ac1b1a3 | ||
|
|
73197df8d9 | ||
|
|
a492c06077 | ||
|
|
bc66f46d9e | ||
|
|
eefd350754 | ||
|
|
494b8b2399 | ||
|
|
e901ba6bb5 | ||
|
|
4455c15bca | ||
|
|
c2bdc67242 | ||
|
|
37545e1e36 | ||
|
|
19495be6e9 | ||
|
|
37a062e24d | ||
|
|
a4c60c71ea | ||
|
|
9e9f46d97b | ||
|
|
f063b970b4 | ||
|
|
711b817cff | ||
|
|
fcdd4e4518 | ||
|
|
6c30a1ff40 | ||
|
|
a7aa3fd53e | ||
|
|
32161139b2 | ||
|
|
7c808b56f7 | ||
|
|
2057823b7a | ||
|
|
e5f79c60ae | ||
|
|
8307d3da90 | ||
|
|
6bad293f74 | ||
|
|
b2771e6cc5 | ||
|
|
e71d492495 | ||
|
|
0e7245e6b9 | ||
|
|
59e9d457c2 | ||
|
|
48be756e48 | ||
|
|
ab3989f233 | ||
|
|
d2c7bf06f7 | ||
|
|
3f59312dfc | ||
|
|
d62add0195 | ||
|
|
fd32b73132 | ||
|
|
2d7f44eeec | ||
|
|
cf5ec3b319 | ||
|
|
2237286656 | ||
|
|
3a0f61e324 | ||
|
|
69569e556a | ||
|
|
86c7551ff8 | ||
|
|
a52dd26ec6 | ||
|
|
6308db495a | ||
|
|
ef7132bf3d | ||
|
|
790accc007 | ||
|
|
2310130e6b | ||
|
|
284312713c | ||
|
|
b40211a6e0 | ||
|
|
ce6a5772b1 | ||
|
|
86c37fb423 | ||
|
|
3c3297c8e7 | ||
|
|
a9dc601751 | ||
|
|
51e3c37694 | ||
|
|
62e27c394d | ||
|
|
8f3c723b07 | ||
|
|
3c28537498 | ||
|
|
b0e94a4ef6 | ||
|
|
baeb89b694 | ||
|
|
016263894f | ||
|
|
5fe532fb78 | ||
|
|
598859ae31 | ||
|
|
d8dcb84870 | ||
|
|
28ca02272c | ||
|
|
448955c915 | ||
|
|
ffd46ff190 | ||
|
|
44311162a6 | ||
|
|
f289680d98 | ||
|
|
280c6e4f16 | ||
|
|
54e4a51a7f | ||
|
|
711394232b | ||
|
|
15a317f84f | ||
|
|
f348262f88 | ||
|
|
e9b8d970d1 | ||
|
|
fb5d3c4165 | ||
|
|
c442ff5f98 | ||
|
|
2d066ea7cd | ||
|
|
efa113ab5f | ||
|
|
d60dea61db | ||
|
|
a136800ff2 | ||
|
|
db1c62cc46 | ||
|
|
1fa340f096 | ||
|
|
2a6937228c | ||
|
|
785395dd20 | ||
|
|
385953b0cb | ||
|
|
35f8337a36 | ||
|
|
769a7c45da | ||
|
|
a97bccc88f | ||
|
|
7b9cdf385a | ||
|
|
73e985c45c | ||
|
|
9c34192b4f | ||
|
|
dabef831d7 | ||
|
|
e44d11c58c | ||
|
|
48a2058e81 | ||
|
|
cd98e51ea9 | ||
|
|
fbbb03a47d | ||
|
|
6f5fc0948a | ||
|
|
05d473dc97 | ||
|
|
e9e361ae60 | ||
|
|
98d9bc62ff | ||
|
|
6f0f6e6901 | ||
|
|
c5d45355a8 | ||
|
|
1a85feb344 | ||
|
|
c75418db67 | ||
|
|
7c8e463929 | ||
|
|
8f04f49086 | ||
|
|
ec2f826dec | ||
|
|
6b576f2ffe | ||
|
|
7c989fda08 | ||
|
|
a4d436b16b | ||
|
|
9339992693 | ||
|
|
214d16cf0e | ||
|
|
a085e91cc6 | ||
|
|
272c38e0c5 | ||
|
|
6052329c0b | ||
|
|
acfdcdbc63 | ||
|
|
a7529c7498 | ||
|
|
c85a7843d0 | ||
|
|
186bf30eca | ||
|
|
45e74f6e33 | ||
|
|
59654b72e6 | ||
|
|
d5531ed73e | ||
|
|
ae208a87e0 | ||
|
|
0a56f7ceed | ||
|
|
9678e5cc1a | ||
|
|
e4b335f4f6 | ||
|
|
b5ae5f94fd | ||
|
|
867aad7896 | ||
|
|
97f42b2f37 | ||
|
|
59fbfdc8f3 | ||
|
|
c8b89f412b | ||
|
|
f4038f00ed | ||
|
|
8091d4cba6 | ||
|
|
189b1055e1 | ||
|
|
2c00f7e5e6 | ||
|
|
c2f592272d | ||
|
|
3fedc42a4a | ||
|
|
3c5826ae2f | ||
|
|
45d90f7459 | ||
|
|
d40acc855a | ||
|
|
8ee5377910 | ||
|
|
78c07aad3e | ||
|
|
9df2a82b6d | ||
|
|
11eae035d9 | ||
|
|
66e6b68b8c | ||
|
|
37156979d1 | ||
|
|
d7c94edc61 | ||
|
|
46566fb98c | ||
|
|
010b95a2f6 | ||
|
|
8f2a28e650 | ||
|
|
8a6102b7b9 | ||
|
|
0ce5c9923d | ||
|
|
4073ebe534 | ||
|
|
387fe082ef | ||
|
|
ddc36ae897 | ||
|
|
c62876ff3a | ||
|
|
2fd71acbb2 | ||
|
|
4c1d8ed2a1 | ||
|
|
7223981280 | ||
|
|
47536f3e63 | ||
|
|
ac4fecd819 | ||
|
|
b75bd4d6c5 | ||
|
|
2be7baea4a | ||
|
|
d56d45a404 | ||
|
|
b50d66d265 | ||
|
|
aec0a5349a | ||
|
|
20560332ed | ||
|
|
202ee0977e | ||
|
|
f460bfcfc6 | ||
|
|
4f5d12f800 | ||
|
|
9092b98b28 | ||
|
|
0f72a85724 | ||
|
|
0840931fed | ||
|
|
00379824df | ||
|
|
f823705e40 | ||
|
|
269836fc99 | ||
|
|
49d8c6f8e4 | ||
|
|
278588ca39 | ||
|
|
ab05c07469 | ||
|
|
04c94ba55a | ||
|
|
6e205760c3 | ||
|
|
82032b98a8 | ||
|
|
e8666d5bf2 | ||
|
|
d1affe271c | ||
|
|
ea109c7b63 | ||
|
|
cb5a8c1c23 | ||
|
|
7f518f55b2 | ||
|
|
ca4fbc0ad5 | ||
|
|
b259dd7b00 | ||
|
|
dc2c2f1164 | ||
|
|
bc2e9cffda | ||
|
|
ade032241a | ||
|
|
eff313be41 | ||
|
|
ff73c72b0e | ||
|
|
1bb83c88d9 | ||
|
|
195813c058 | ||
|
|
733ab37539 | ||
|
|
c0c91b4aad | ||
|
|
83712a6937 | ||
|
|
290d02d248 | ||
|
|
9cd402a15d | ||
|
|
1a6897637f | ||
|
|
213b1e7f9e | ||
|
|
10c8d4ad2f | ||
|
|
4fcb58aefa | ||
|
|
8c2a35f755 | ||
|
|
a66c522b73 | ||
|
|
d0de1142ae | ||
|
|
8d6ad7e3c8 | ||
|
|
8ae5dd97b2 | ||
|
|
cf747c1ddb | ||
|
|
8cb53d1c6f | ||
|
|
bd8ecebf89 | ||
|
|
09158b5bb5 | ||
|
|
aa30f1c392 | ||
|
|
4a2fc6d418 | ||
|
|
1846e31bf5 | ||
|
|
1be20d471d | ||
|
|
3739634b63 | ||
|
|
3951116bdc | ||
|
|
a288ba4461 | ||
|
|
f34ba5df18 | ||
|
|
44d7e173e3 | ||
|
|
663389693f | ||
|
|
591b843148 | ||
|
|
de3c06129d | ||
|
|
0238c6778c | ||
|
|
d00f3fcfbc | ||
|
|
47ce8a9ec4 | ||
|
|
2d83718f81 | ||
|
|
a0db685af2 | ||
|
|
4fa0630aef | ||
|
|
3cad30a8e5 | ||
|
|
44172074b9 | ||
|
|
1032e4e747 | ||
|
|
a73dfddd3f | ||
|
|
274324557c | ||
|
|
5a0677bac8 | ||
|
|
df1581d48e | ||
|
|
9d1c7bba6f | ||
|
|
b620c0d9ae | ||
|
|
2c787b4d46 | ||
|
|
69dcaf3797 | ||
|
|
43e36ee6fc | ||
|
|
53c9569a37 | ||
|
|
c39a9e80e7 | ||
|
|
3d0f756264 | ||
|
|
85de1c97ff | ||
|
|
2c8afecfbb | ||
|
|
4924700c52 | ||
|
|
e2c24a2593 | ||
|
|
31b7ede665 | ||
|
|
dba7d0bd4e | ||
|
|
73cfa5bef2 | ||
|
|
6909477f45 | ||
|
|
701d1305d3 | ||
|
|
08498074ed | ||
|
|
28d321986a | ||
|
|
943d523f3f | ||
|
|
8f88b6aaa2 | ||
|
|
7f60598d4a | ||
|
|
18e82fd04b | ||
|
|
d7d7146e12 | ||
|
|
aaa5217398 | ||
|
|
9610b89fa5 | ||
|
|
9809611d0d | ||
|
|
b1e38ba15d | ||
|
|
35a765aa01 | ||
|
|
82411f1868 | ||
|
|
b0e01144f4 | ||
|
|
04f354b3d1 | ||
|
|
918f3ad588 | ||
|
|
635c2be32c | ||
|
|
3143d32b45 | ||
|
|
742f5c095a | ||
|
|
7b2a6cdf74 | ||
|
|
2f3d5e4e3a | ||
|
|
2fb2f3ee74 | ||
|
|
7813c8c68b | ||
|
|
e528f7c348 | ||
|
|
77f6b1042e | ||
|
|
7db94dcebf | ||
|
|
70afc21217 | ||
|
|
525c13ff6a | ||
|
|
0366e5116d | ||
|
|
62923d5e45 | ||
|
|
10a32ad1ae | ||
|
|
e52e21a54b | ||
|
|
7c861e5763 | ||
|
|
9c771e193e | ||
|
|
f37451021f | ||
|
|
4aa095d466 | ||
|
|
638be18ea8 | ||
|
|
42264f0547 | ||
|
|
07d738006f | ||
|
|
4bc51570c2 | ||
|
|
cf94fdb2f0 | ||
|
|
4864c6c53c | ||
|
|
5702e8012c | ||
|
|
523902f951 | ||
|
|
dd93758b0e | ||
|
|
b595d3ea03 | ||
|
|
49dfac514d | ||
|
|
543f23c8ef | ||
|
|
f6fdd41b35 | ||
|
|
4f78b7c33b | ||
|
|
9956bbd974 | ||
|
|
ff1ea8549a | ||
|
|
5a2d3d2ee2 | ||
|
|
729548334d | ||
|
|
27f85f866e | ||
|
|
c43d5cf1b0 | ||
|
|
3538935d3b | ||
|
|
edf6c13f03 | ||
|
|
b30d6c3ee1 | ||
|
|
3ff5e6555a | ||
|
|
2430fc68ba | ||
|
|
bc8f6b7cd6 | ||
|
|
e31d11e2bb | ||
|
|
3d45f2b95e | ||
|
|
80ebafa9f9 | ||
|
|
471497ff6a | ||
|
|
1badc4975e | ||
|
|
0728c8bdd3 | ||
|
|
498f7bd29b | ||
|
|
ad3e6ad7dc | ||
|
|
e2b975ac9c | ||
|
|
b7bf1f835e | ||
|
|
525eaab4bb | ||
|
|
a67119d1ec | ||
|
|
10cc130674 | ||
|
|
044ce6fbd8 | ||
|
|
a4bb2de901 | ||
|
|
05df04b754 | ||
|
|
998b719f38 | ||
|
|
2ec34278cc | ||
|
|
0dbe058433 | ||
|
|
4ddb7dce32 | ||
|
|
148c36cb64 | ||
|
|
448df6c1e3 | ||
|
|
72c616811b | ||
|
|
2a816b397c | ||
|
|
bb9e94c632 | ||
|
|
fec7a7aa70 | ||
|
|
f9a5e32ec9 | ||
|
|
4551ae3fa1 | ||
|
|
a2af9ca4d2 | ||
|
|
623934c980 | ||
|
|
720ff1f7a6 | ||
|
|
68e062ff08 | ||
|
|
d6176d3f39 | ||
|
|
edd3aeba16 | ||
|
|
cf3efe770d | ||
|
|
826deec8a8 | ||
|
|
fc8910ffee | ||
|
|
41bd828367 | ||
|
|
ddb996882f | ||
|
|
5d1917efa2 | ||
|
|
9ec54ecec8 | ||
|
|
8f25e18c53 | ||
|
|
a734afaabe | ||
|
|
ca87a3f93f | ||
|
|
a8d9c90bfa | ||
|
|
68a2a945f9 | ||
|
|
3c45fcbef2 | ||
|
|
71efae7300 | ||
|
|
25b5ae398e | ||
|
|
86b540c13a | ||
|
|
971007fc3f | ||
|
|
c006575498 | ||
|
|
51917c5403 | ||
|
|
cc226bfe9e | ||
|
|
a965f7fbb0 | ||
|
|
7bcc949a17 | ||
|
|
a4d9d83fac | ||
|
|
8edf712669 | ||
|
|
084f8aa658 | ||
|
|
084a62e60f | ||
|
|
655dc88c62 | ||
|
|
46109d1ea3 | ||
|
|
f7d931be0c | ||
|
|
8d6af53e54 | ||
|
|
8d3bd52fc5 | ||
|
|
8da95ed824 | ||
|
|
8207a75820 | ||
|
|
2c48ce0152 | ||
|
|
dae0ad1de5 | ||
|
|
7c76b58ab8 | ||
|
|
4ea2dfdfb7 | ||
|
|
d8d478a95e | ||
|
|
4c20250888 | ||
|
|
f5a15905e4 | ||
|
|
53742e5ec2 | ||
|
|
504c75566a | ||
|
|
ed4dcbac3b | ||
|
|
a0f1cd5814 | ||
|
|
4607a30e6a | ||
|
|
fca370b9d9 | ||
|
|
dc3f1661e8 | ||
|
|
463fe97b29 | ||
|
|
b08527bce2 | ||
|
|
41c092f578 | ||
|
|
311ecb7030 | ||
|
|
4a28ea7003 | ||
|
|
0a82f889f3 | ||
|
|
00e6da520d | ||
|
|
0b830e9b5e | ||
|
|
468b2f3284 | ||
|
|
db21131185 | ||
|
|
7d9555fdf7 | ||
|
|
729552a827 | ||
|
|
cdc8f9af4b | ||
|
|
9e5034ebab | ||
|
|
c2f835c897 | ||
|
|
9c2f27bcdb | ||
|
|
423fc4ac80 | ||
|
|
e1292a0780 | ||
|
|
f72960635d | ||
|
|
b5c80e9d27 | ||
|
|
3fa4b01115 | ||
|
|
65f402fd35 | ||
|
|
46f1bc20c8 | ||
|
|
a13a72c626 | ||
|
|
5a80145607 | ||
|
|
baf5e6a593 | ||
|
|
850bb8f44e | ||
|
|
b17d8424e9 | ||
|
|
d2253ff069 | ||
|
|
0946b3a1da | ||
|
|
e1c215b72e | ||
|
|
ea0598e507 | ||
|
|
28c3d9d2e4 | ||
|
|
e9f9d9dc98 | ||
|
|
bb75bfd15d | ||
|
|
9c84fb5887 | ||
|
|
3bb9272f06 | ||
|
|
a735e4ff29 | ||
|
|
63948a6de0 | ||
|
|
a470d77938 | ||
|
|
833be688ac | ||
|
|
fc7ae0ec4e | ||
|
|
753f5fc517 | ||
|
|
f1b7ef303d | ||
|
|
e7d4b5051b | ||
|
|
b7b3aa1eb7 | ||
|
|
f083d6b53f | ||
|
|
7caa5c5d57 | ||
|
|
65c2722a20 | ||
|
|
6b3fc3d492 | ||
|
|
fec9776def | ||
|
|
bfeab3648c | ||
|
|
c0f2409fcc | ||
|
|
ef5d89f323 |
171
.drone.yml
@@ -1,171 +0,0 @@
|
||||
---
|
||||
name: jfa-go
|
||||
kind: pipeline
|
||||
type: docker
|
||||
|
||||
steps:
|
||||
- name: fetch
|
||||
image: docker:git
|
||||
commands:
|
||||
- git fetch --tags
|
||||
- name: release
|
||||
image: hrfee/jfa-go-build-docker:latest
|
||||
volumes:
|
||||
- name: ssh_key
|
||||
path: /id_rsa
|
||||
environment:
|
||||
BUILDRONE_KEY:
|
||||
from_secret: BUILDRONE_KEY
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
commands:
|
||||
- curl -sL https://git.io/goreleaser > ../goreleaser
|
||||
- chmod +x ../goreleaser
|
||||
- ./scripts/version.sh ../goreleaser
|
||||
- wget https://builds.hrfee.pw/upload.py -P ../
|
||||
- pip3 install requests
|
||||
- bash -c 'sftp -P 2022 -i /id_rsa -o StrictHostKeyChecking=no root@161.97.102.153:/repo/incoming <<< $"put dist/*.deb"'
|
||||
- bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "repo-process-deb trusty"'
|
||||
- bash -c 'python3 ../upload.py https://builds.hrfee.pw hrfee jfa-go --tag internal=true'
|
||||
volumes:
|
||||
- name: ssh_key
|
||||
host:
|
||||
path: /root/.ssh/id_rsa_packaging
|
||||
trigger:
|
||||
event:
|
||||
- tag
|
||||
---
|
||||
name: docker-buildx
|
||||
kind: pipeline
|
||||
type: docker
|
||||
|
||||
steps:
|
||||
- name: build-deploy
|
||||
image: appleboy/drone-ssh
|
||||
volumes:
|
||||
- name: ssh_key
|
||||
path: /root/drone_rsa
|
||||
settings:
|
||||
host:
|
||||
from_secret: ssh2_host
|
||||
username:
|
||||
from_secret: ssh2_username
|
||||
port:
|
||||
from_secret: ssh2_port
|
||||
volumes:
|
||||
- /root/.ssh/docker-build:/root/drone_rsa
|
||||
key_path: /root/drone_rsa
|
||||
command_timeout: 50m
|
||||
script:
|
||||
- /mnt/buildx/jfa-go/build.sh stable
|
||||
- wget https://builds.hrfee.pw/upload.py -O /mnt/buildx/jfa-go/jfa-go/upload.py
|
||||
- pip3 install requests
|
||||
- bash -c 'cd /mnt/buildx/jfa-go/jfa-go && BUILDRONE_KEY=$(cat /mnt/buildx/jfa-go/key) python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-stable=true'
|
||||
- rm -f /mnt/buildx/jfa-go/jfa-go/upload.py
|
||||
trigger:
|
||||
event:
|
||||
- tag
|
||||
volumes:
|
||||
- name: ssh_key
|
||||
host:
|
||||
path: /root/.ssh/docker-build
|
||||
---
|
||||
name: jfa-go-git
|
||||
kind: pipeline
|
||||
type: docker
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
image: hrfee/jfa-go-build-docker:latest
|
||||
volumes:
|
||||
- name: ssh_key
|
||||
path: /id_rsa
|
||||
- name: ssh_key2
|
||||
path: /id_rsa2
|
||||
commands:
|
||||
- curl -sL https://git.io/goreleaser > goreleaser
|
||||
- chmod +x goreleaser
|
||||
- ./scripts/version.sh ./goreleaser --snapshot --skip-publish --rm-dist
|
||||
- wget https://builds.hrfee.pw/upload.py
|
||||
- pip3 install requests
|
||||
- bash -c 'sftp -i /id_rsa2 -o StrictHostKeyChecking=no root@161.97.102.153:/mnt/redoc <<< $"put docs/swagger.json jfa-go.json"'
|
||||
- bash -c 'sftp -P 2022 -i /id_rsa -o StrictHostKeyChecking=no root@161.97.102.153:/repo/incoming <<< $"put dist/*.deb"'
|
||||
# - bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "reprepro -Vb /repo remove trusty-unstable jfa-go"'
|
||||
# - bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "reprepro -Vb /repo remove trusty-unstable jfa-go-tray"'
|
||||
- bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "repo-process-deb trusty"'
|
||||
- bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --upload ./dist/*.zip ./dist/*.rpm ./dist/*.apk --tag internal-git=true'
|
||||
environment:
|
||||
BUILDRONE_KEY:
|
||||
from_secret: BUILDRONE_KEY
|
||||
|
||||
volumes:
|
||||
- name: ssh_key
|
||||
host:
|
||||
path: /root/.ssh/id_rsa_packaging
|
||||
- name: ssh_key2
|
||||
host:
|
||||
path: /root/.ssh/docker-build
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
- go1.16
|
||||
event:
|
||||
exclude:
|
||||
- pull_request
|
||||
|
||||
---
|
||||
name: docker-buildx-unstable
|
||||
kind: pipeline
|
||||
type: docker
|
||||
|
||||
steps:
|
||||
- name: build-deploy
|
||||
image: appleboy/drone-ssh
|
||||
volumes:
|
||||
- name: ssh_key
|
||||
path: /root/drone_rsa
|
||||
settings:
|
||||
host:
|
||||
from_secret: ssh2_host
|
||||
username:
|
||||
from_secret: ssh2_username
|
||||
port:
|
||||
from_secret: ssh2_port
|
||||
volumes:
|
||||
- /root/.ssh/docker-build:/root/drone_rsa
|
||||
key_path: /root/drone_rsa
|
||||
command_timeout: 50m
|
||||
script:
|
||||
- /mnt/buildx/jfa-go/build.sh
|
||||
- wget https://builds.hrfee.pw/upload.py -O /mnt/buildx/jfa-go/jfa-go/upload.py
|
||||
- pip3 install requests
|
||||
- bash -c 'cd /mnt/buildx/jfa-go/jfa-go && BUILDRONE_KEY=$(cat /mnt/buildx/jfa-go/key) python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-unstable=true'
|
||||
- rm -f /mnt/buildx/jfa-go/jfa-go/upload.py
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
event:
|
||||
exclude:
|
||||
- pull_request
|
||||
|
||||
volumes:
|
||||
- name: ssh_key
|
||||
host:
|
||||
path: /root/.ssh/docker-build
|
||||
---
|
||||
name: jfa-go-pr
|
||||
kind: pipeline
|
||||
type: docker
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
image: hrfee/jfa-go-build-docker:latest
|
||||
commands:
|
||||
- curl -sL https://git.io/goreleaser > goreleaser
|
||||
- chmod +x goreleaser
|
||||
- ./scripts/version.sh ./goreleaser --snapshot --skip-publish --rm-dist
|
||||
|
||||
trigger:
|
||||
event:
|
||||
include:
|
||||
- pull_request
|
||||
1
.git-blame-ignore-revs
Normal file
@@ -0,0 +1 @@
|
||||
817107622a8fe6f2fdaf198da4b2632854aa9bac
|
||||
6
.gitignore
vendored
@@ -24,3 +24,9 @@ matacc.txt
|
||||
scripts/langmover/lang
|
||||
scripts/langmover/lang2
|
||||
scripts/langmover/out
|
||||
tinyproxy.conf
|
||||
static/banner.svg
|
||||
start.sh
|
||||
ts/*.tsbuildinfo
|
||||
ts/**/*.tsbuildinfo
|
||||
js/
|
||||
|
||||
158
.goreleaser.yml
@@ -1,3 +1,5 @@
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
||||
version: 2
|
||||
project_name: jfa-go
|
||||
release:
|
||||
github:
|
||||
@@ -6,52 +8,37 @@ release:
|
||||
name_template: "v{{.Version}}"
|
||||
before:
|
||||
hooks:
|
||||
- go mod download
|
||||
- rm -rf data/web
|
||||
- mkdir -p data
|
||||
- cp -r static data/web
|
||||
- npm install
|
||||
- npm install esbuild
|
||||
- mkdir -p data/web/css
|
||||
- cp node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 data/web/css/
|
||||
- cp -r html data/
|
||||
- node scripts/missing-colors.js html data/html
|
||||
- cp -r lang data/
|
||||
- cp LICENSE data/
|
||||
- cp jfa-go.service data/
|
||||
- python3 scripts/enumerate_config.py -i config/config-base.json -o data/config-base.json
|
||||
- python3 scripts/generate_ini.py -i config/config-base.json -o data/config-default.ini
|
||||
- python3 scripts/compile_mjml.py -o data/
|
||||
- rm -rf tempts
|
||||
- cp -r ts tempts
|
||||
- scripts/dark-variant.sh tempts
|
||||
- scripts/dark-variant.sh tempts/modules
|
||||
- npx esbuild --target=es6 --format=esm --bundle tempts/admin.ts --outfile=./data/web/js/admin.js --minify
|
||||
- npx esbuild --target=es6 --format=esm --bundle tempts/pwr.ts --outfile=./data/web/js/pwr.js --minify
|
||||
- npx esbuild --target=es6 --format=esm --bundle tempts/form.ts --outfile=./data/web/js/form.js --minify
|
||||
- npx esbuild --target=es6 --format=esm --bundle tempts/setup.ts --outfile=./data/web/js/setup.js --minify
|
||||
- npx esbuild --target=es6 --format=esm --bundle tempts/crash.ts --outfile=./data/crash.js --minify
|
||||
- rm -r tempts
|
||||
- npx esbuild --bundle css/base.css --outfile=./data/web/css/bundle.css --external:remixicon.css --minify
|
||||
- cp html/crash.html data/
|
||||
- npx tailwindcss -i data/web/css/bundle.css -o data/bundle.css --content "html/crash.html"
|
||||
- node scripts/inline.js root data data/crash.html data/crash.html
|
||||
- rm data/bundle.css
|
||||
- npx tailwindcss -i data/web/css/bundle.css -o data/web/css/bundle.css
|
||||
- mv data/crash.html data/html/
|
||||
- go install github.com/swaggo/swag/cmd/swag@latest
|
||||
- swag init -g main.go
|
||||
- mv data/web/css/bundle.css data/web/css/{{.Env.JFA_GO_CSS_VERSION}}bundle.css
|
||||
- npm ci
|
||||
- env GOOS= GOARCH= make precompile
|
||||
builds:
|
||||
- id: notray
|
||||
dir: ./
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- -tags={{ .Env.JFA_GO_TAG }}
|
||||
ldflags:
|
||||
- -s -w -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}}
|
||||
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater={{.Env.JFA_GO_UPDATER}} {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- windows
|
||||
goarch:
|
||||
- arm
|
||||
- arm64
|
||||
- amd64
|
||||
- id: notray-e2ee
|
||||
dir: ./
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC={{ if eq .Arch "amd64" }}x86_64{{ else if eq .Arch "arm64" }}aarch64{{ else }}{{ .Arch }}{{ end }}-linux-gnu{{ if eq .Arch "arm" }}eabihf{{ end }}-gcc
|
||||
- CXX={{ if eq .Arch "amd64" }}x86_64{{ else if eq .Arch "arm64" }}aarch64{{ else }}{{ .Arch }}{{ end }}-linux-gnu{{ if eq .Arch "arm" }}eabihf{{ end }}-gcc
|
||||
- PKG_CONFIG_PATH=/usr/lib/{{ if eq .Arch "amd64" }}x86_64{{ else if eq .Arch "arm64" }}aarch64{{ else }}{{ .Arch }}{{ end }}-linux-gnu{{ if eq .Arch "arm" }}eabihf{{ end }}/pkgconfig:$PKG_CONFIG_PATH
|
||||
- GOARM={{ if eq .Arch "arm" }}7{{ end }}
|
||||
flags:
|
||||
- -tags=e2ee,goolm,{{ .Env.JFA_GO_TAG }}
|
||||
ldflags:
|
||||
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater={{.Env.JFA_GO_UPDATER}} {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- arm
|
||||
- arm64
|
||||
@@ -63,9 +50,9 @@ builds:
|
||||
- CC=x86_64-w64-mingw32-gcc
|
||||
- CXX=x86_64-w64-mingw32-g++
|
||||
flags:
|
||||
- -tags=tray
|
||||
- -tags=tray,{{ .Env.JFA_GO_TAG }}
|
||||
ldflags:
|
||||
- -s -w -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -H=windowsgui
|
||||
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater={{.Env.JFA_GO_UPDATER}} {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}" -H=windowsgui
|
||||
goos:
|
||||
- windows
|
||||
goarch:
|
||||
@@ -74,49 +61,62 @@ builds:
|
||||
dir: ./
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=x86_64-linux-gnu-gcc
|
||||
- CXX=x86_64-linux-gnu-gcc
|
||||
- PKG_CONFIG_PATH=/usr/lib/{{ if eq .Arch "amd64" }}x86_64{{ else if eq .Arch "arm64" }}aarch64{{ else }}{{ .Arch }}{{ end }}-linux-gnu{{ if eq .Arch "arm" }}eabihf{{ end }}/pkgconfig:$PKG_CONFIG_PATH
|
||||
flags:
|
||||
- -tags=tray
|
||||
- -tags=tray,e2ee,goolm,{{ .Env.JFA_GO_TAG }}
|
||||
ldflags:
|
||||
- -s -w -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}}
|
||||
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater={{.Env.JFA_GO_UPDATER}} {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
archives:
|
||||
- id: windows-tray
|
||||
builds:
|
||||
ids:
|
||||
- windows-tray
|
||||
format: zip
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_TrayIcon_{{ .Os }}_{{ .Arch }}"
|
||||
replacements:
|
||||
darwin: macOS
|
||||
linux: Linux
|
||||
windows: Windows
|
||||
amd64: x86_64
|
||||
formats: [ "zip" ]
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_{{ .Version }}_TrayIcon_
|
||||
{{- if eq .Os "darwin" }}macOS
|
||||
{{- else }}{{- title .Os }}{{ end }}_
|
||||
{{- if eq .Arch "amd64" }}x86_64
|
||||
{{- else }}{{ .Arch }}{{ end }}
|
||||
- id: linux-tray
|
||||
builds:
|
||||
ids:
|
||||
- linux-tray
|
||||
format: zip
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_TrayIcon_{{ .Os }}_{{ .Arch }}"
|
||||
replacements:
|
||||
darwin: macOS
|
||||
linux: Linux
|
||||
windows: Windows
|
||||
amd64: x86_64
|
||||
formats: [ "zip" ]
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_{{ .Version }}_TrayIcon_
|
||||
{{- if eq .Os "darwin" }}macOS
|
||||
{{- else }}{{- title .Os }}{{ end }}_
|
||||
{{- if eq .Arch "amd64" }}x86_64
|
||||
{{- else }}{{ .Arch }}{{ end }}
|
||||
- id: notray
|
||||
builds:
|
||||
ids:
|
||||
- notray
|
||||
format: zip
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
replacements:
|
||||
darwin: macOS
|
||||
linux: Linux
|
||||
windows: Windows
|
||||
amd64: x86_64
|
||||
formats: [ "zip" ]
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_{{ .Version }}_
|
||||
{{- if eq .Os "darwin" }}macOS
|
||||
{{- else }}{{- title .Os }}{{ end }}_
|
||||
{{- if eq .Arch "amd64" }}x86_64
|
||||
{{- else }}{{ .Arch }}{{ end }}
|
||||
- id: notray-e2ee
|
||||
ids:
|
||||
- notray-e2ee
|
||||
formats: [ "zip" ]
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_{{ .Version }}_MatrixE2EE_
|
||||
{{- if eq .Os "darwin" }}macOS
|
||||
{{- else }}{{- title .Os }}{{ end }}_
|
||||
{{- if eq .Arch "amd64" }}x86_64
|
||||
{{- else }}{{ .Arch }}{{ end }}
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
snapshot:
|
||||
name_template: "0.0.0-{{ .Env.JFA_GO_NFPM_EPOCH }}"
|
||||
version_template: "0.0.0-{{ .Env.JFA_GO_NFPM_EPOCH }}"
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
@@ -133,8 +133,8 @@ nfpms:
|
||||
license: MIT
|
||||
vendor: hrfee.dev
|
||||
version_metadata: git
|
||||
builds:
|
||||
- notray
|
||||
ids:
|
||||
- notray-e2ee
|
||||
contents:
|
||||
- src: ./LICENSE
|
||||
dst: /usr/share/licenses/jfa-go
|
||||
@@ -142,6 +142,16 @@ nfpms:
|
||||
- apk
|
||||
- deb
|
||||
- rpm
|
||||
overrides:
|
||||
deb:
|
||||
dependencies:
|
||||
- libolm-dev
|
||||
rpm:
|
||||
dependencies:
|
||||
- libolm
|
||||
apk:
|
||||
dependencies:
|
||||
- olm
|
||||
- id: tray
|
||||
file_name_template: '{{ .ProjectName }}{{ if .IsSnapshot }}-git{{ end }}_TrayIcon_{{ .Arch }}_{{ if .IsSnapshot }}{{ .ShortCommit }}{{ else }}v{{ .Version }}{{ end }}'
|
||||
package_name: jfa-go-tray
|
||||
@@ -151,7 +161,7 @@ nfpms:
|
||||
license: MIT
|
||||
vendor: hrfee.dev
|
||||
version_metadata: git
|
||||
builds:
|
||||
ids:
|
||||
- linux-tray
|
||||
contents:
|
||||
- src: ./LICENSE
|
||||
@@ -167,10 +177,10 @@ nfpms:
|
||||
replaces:
|
||||
- jfa-go
|
||||
dependencies:
|
||||
- libappindicator3-1
|
||||
- libolm-dev
|
||||
rpm:
|
||||
dependencies:
|
||||
- libappindicator-gtk3
|
||||
- libolm
|
||||
apk:
|
||||
dependencies:
|
||||
- libappindicator
|
||||
- olm
|
||||
|
||||
101
.woodpecker/stable.yaml
Normal file
@@ -0,0 +1,101 @@
|
||||
when:
|
||||
- event: tag
|
||||
branch: main
|
||||
|
||||
clone:
|
||||
git:
|
||||
image: woodpeckerci/plugin-git
|
||||
settings:
|
||||
tags: true
|
||||
partial: false
|
||||
depth: 0
|
||||
|
||||
steps:
|
||||
- name: precompile
|
||||
image: docker.io/hrfee/jfa-go-build-docker:latest
|
||||
environment:
|
||||
JFA_GO_SNAPSHOT: y
|
||||
JFA_GO_BUILT_BY:
|
||||
from_secret: BUILT_BY
|
||||
volumes:
|
||||
- jfa-go-build-cache:/root/.cache/go-build
|
||||
- jfa-go-mod-cache:/go/pkg/mod
|
||||
- jfa-go-npm-cache:/npm
|
||||
commands:
|
||||
- npm i
|
||||
- make precompile
|
||||
- go mod download
|
||||
- name: test
|
||||
image: docker.io/hrfee/jfa-go-build-docker:latest
|
||||
environment:
|
||||
JFA_GO_SNAPSHOT: y
|
||||
JFA_GO_BUILT_BY:
|
||||
from_secret: BUILT_BY
|
||||
volumes:
|
||||
- jfa-go-build-cache:/root/.cache/go-build
|
||||
- jfa-go-mod-cache:/go/pkg/mod
|
||||
- jfa-go-npm-cache:/npm
|
||||
commands:
|
||||
- make test
|
||||
- name: build
|
||||
image: docker.io/hrfee/jfa-go-build-docker:latest
|
||||
environment:
|
||||
JFA_GO_BUILT_BY:
|
||||
from_secret: BUILT_BY
|
||||
GITHUB_TOKEN:
|
||||
from_secret: GITHUB_TOKEN
|
||||
volumes:
|
||||
- jfa-go-build-cache:/root/.cache/go-build
|
||||
- jfa-go-mod-cache:/go/pkg/mod
|
||||
- jfa-go-npm-cache:/npm
|
||||
commands:
|
||||
- ./scripts/version.sh goreleaser
|
||||
- name: deb-repo
|
||||
image: docker.io/hrfee/jfa-go-build-docker:latest
|
||||
environment:
|
||||
REPO_SSH_ID:
|
||||
from_secret: REPO_SSH_ID
|
||||
commands:
|
||||
- sh -c "echo \"$REPO_SSH_ID\" > /tmp/id_repo && chmod 600 /tmp/id_repo"
|
||||
- bash -c 'sftp -P 2022 -i /tmp/id_repo -o StrictHostKeyChecking=no root@apt.hrfee.dev:/repo/incoming <<< $"put dist/*.deb"'
|
||||
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty"'
|
||||
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty-unstable"'
|
||||
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "rm -f /repo/incoming/*.deb"'
|
||||
- name: build-external
|
||||
image: docker.io/hrfee/jfa-go-build-docker:latest
|
||||
environment:
|
||||
JFA_GO_BUILT_BY:
|
||||
from_secret: BUILT_BY
|
||||
volumes:
|
||||
- jfa-go-build-cache:/root/.cache/go-build
|
||||
- jfa-go-mod-cache:/go/pkg/mod
|
||||
- jfa-go-npm-cache:/npm
|
||||
commands:
|
||||
- sed -i 's#id="password_resets-watch_directory" placeholder="/config/jellyfin"#id="password_resets-watch_directory" value="/jf" disabled#g' ./build/data/html/setup.html
|
||||
- env GOOS=linux INTERNAL=off ./scripts/version.sh goreleaser build --id notray-e2ee --clean
|
||||
- mv ./dist/notray-e2ee_linux_arm_6 ./dist/notray-e2ee_linux_arm
|
||||
- name: container
|
||||
image: docker.io/woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
dry_run: false
|
||||
dockerfile: Dockerfile.ci
|
||||
username:
|
||||
from_secret: DOCKER_USERNAME
|
||||
password:
|
||||
from_secret: DOCKER_TOKEN
|
||||
repo: docker.io/hrfee/jfa-go
|
||||
tags: stable
|
||||
registry: docker.io
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
build_args:
|
||||
- BUILT_BY:
|
||||
from_secret: BUILT_BY
|
||||
- name: buildrone
|
||||
image: docker.io/hrfee/jfa-go-build-docker:latest
|
||||
environment:
|
||||
BUILDRONE_KEY:
|
||||
from_secret: BUILDRONE_KEY
|
||||
commands:
|
||||
- wget https://builds.hrfee.pw/upload.py
|
||||
- bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag internal=true'
|
||||
- python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-stable=true
|
||||
119
.woodpecker/unstable.yaml
Normal file
@@ -0,0 +1,119 @@
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
# - evaluate: 'CI_PIPELINE_EVENT != "PULL_REQUEST" && CI_COMMIT_BRANCH == CI_REPO_DEFAULT_BRANCH'
|
||||
|
||||
clone:
|
||||
git:
|
||||
image: woodpeckerci/plugin-git
|
||||
settings:
|
||||
tags: true
|
||||
partial: false
|
||||
depth: 0
|
||||
|
||||
steps:
|
||||
- name: precompile
|
||||
image: docker.io/hrfee/jfa-go-build-docker:latest
|
||||
environment:
|
||||
JFA_GO_SNAPSHOT: y
|
||||
JFA_GO_BUILT_BY:
|
||||
from_secret: BUILT_BY
|
||||
volumes:
|
||||
- jfa-go-build-cache:/root/.cache/go-build
|
||||
- jfa-go-mod-cache:/go/pkg/mod
|
||||
- jfa-go-npm-cache:/npm
|
||||
commands:
|
||||
- npm ci --cache /npm --prefer-offline
|
||||
- make precompile
|
||||
- go mod download
|
||||
- name: test
|
||||
image: docker.io/hrfee/jfa-go-build-docker:latest
|
||||
environment:
|
||||
JFA_GO_SNAPSHOT: y
|
||||
JFA_GO_BUILT_BY:
|
||||
from_secret: BUILT_BY
|
||||
volumes:
|
||||
- jfa-go-build-cache:/root/.cache/go-build
|
||||
- jfa-go-mod-cache:/go/pkg/mod
|
||||
- jfa-go-npm-cache:/npm
|
||||
commands:
|
||||
- make test
|
||||
- name: build
|
||||
image: docker.io/hrfee/jfa-go-build-docker:latest
|
||||
environment:
|
||||
JFA_GO_SNAPSHOT: y
|
||||
JFA_GO_BUILT_BY:
|
||||
from_secret: BUILT_BY
|
||||
volumes:
|
||||
- jfa-go-build-cache:/root/.cache/go-build
|
||||
- jfa-go-mod-cache:/go/pkg/mod
|
||||
- jfa-go-npm-cache:/npm
|
||||
commands:
|
||||
- ./scripts/version.sh goreleaser --snapshot --skip=publish --clean
|
||||
- name: buildrone-binary
|
||||
image: docker.io/hrfee/jfa-go-build-docker:latest
|
||||
environment:
|
||||
BUILDRONE_KEY:
|
||||
from_secret: BUILDRONE_KEY
|
||||
commands:
|
||||
- wget https://builds.hrfee.pw/upload.py
|
||||
- bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --upload ./dist/*.zip ./dist/*.rpm ./dist/*.apk --tag internal-git=true'
|
||||
- name: redoc
|
||||
image: docker.io/hrfee/jfa-go-build-docker:latest
|
||||
environment:
|
||||
REDOC_SSH_ID:
|
||||
from_secret: REDOC_SSH_ID
|
||||
commands:
|
||||
- sh -c "echo \"$REDOC_SSH_ID\" > /tmp/id_redoc && chmod 600 /tmp/id_redoc"
|
||||
- bash -c 'sftp -P 3625 -i /tmp/id_redoc -o StrictHostKeyChecking=no redoc@api.jfa-go.com:/home/redoc <<< $"put docs/swagger.json jfa-go.json"'
|
||||
- name: deb-repo
|
||||
image: docker.io/hrfee/jfa-go-build-docker:latest
|
||||
environment:
|
||||
REPO_SSH_ID:
|
||||
from_secret: REPO_SSH_ID
|
||||
commands:
|
||||
- sh -c "echo \"$REPO_SSH_ID\" > /tmp/id_repo && chmod 600 /tmp/id_repo"
|
||||
- bash -c 'sftp -P 2022 -i /tmp/id_repo -o StrictHostKeyChecking=no root@apt.hrfee.dev:/repo/incoming <<< $"put dist/*.deb"'
|
||||
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty-unstable"'
|
||||
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty"'
|
||||
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "rm -f /repo/incoming/*.deb"'
|
||||
- name: build-external
|
||||
image: docker.io/hrfee/jfa-go-build-docker:latest
|
||||
environment:
|
||||
JFA_GO_SNAPSHOT: y
|
||||
JFA_GO_BUILT_BY:
|
||||
from_secret: BUILT_BY
|
||||
volumes:
|
||||
- jfa-go-build-cache:/root/.cache/go-build
|
||||
- jfa-go-mod-cache:/go/pkg/mod
|
||||
- jfa-go-npm-cache:/npm
|
||||
commands:
|
||||
- sed -i 's#id="password_resets-watch_directory" placeholder="/config/jellyfin"#id="password_resets-watch_directory" value="/jf" disabled#g' ./build/data/html/setup.html
|
||||
- env GOOS=linux INTERNAL=off ./scripts/version.sh goreleaser build --snapshot --id notray-e2ee --clean
|
||||
- mv ./dist/notray-e2ee_linux_arm_6 ./dist/notray-e2ee_linux_arm
|
||||
- name: container
|
||||
image: docker.io/woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
dry_run: false
|
||||
dockerfile: Dockerfile.ci
|
||||
username:
|
||||
from_secret: DOCKER_USERNAME
|
||||
password:
|
||||
from_secret: DOCKER_TOKEN
|
||||
repo: docker.io/hrfee/jfa-go
|
||||
tags: unstable
|
||||
registry: docker.io
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
build_args:
|
||||
- BUILT_BY:
|
||||
from_secret: BUILT_BY
|
||||
- name: buildrone-container
|
||||
image: docker.io/hrfee/jfa-go-build-docker:latest
|
||||
environment:
|
||||
BUILDRONE_KEY:
|
||||
from_secret: BUILDRONE_KEY
|
||||
commands:
|
||||
- wget https://builds.hrfee.pw/upload.py
|
||||
- python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-unstable=true
|
||||
|
||||
|
||||
@@ -3,42 +3,5 @@ title: "Building/Contributing for developers"
|
||||
date: 2021-07-25T00:33:36+01:00
|
||||
draft: false
|
||||
---
|
||||
# Code
|
||||
I use 4 spaces for indentation. Go should ideally be formatted with `goimports` and/or `gofmt`. I don't use a formatter on typescript, so don't worry about that.
|
||||
|
||||
Code in Go should ideally use `PascalCase` for exported values, and `camelCase` for non-exported, JSON for transferring data should use `snake_case`, and Typescript should use `camelCase`. Forgive me for my many inconsistencies in this, and feel free to fix them if you want.
|
||||
|
||||
Functions in Go that need to access `*appContext` should be generally be receivers, except when the behaviour could be seen as somewhat independent from it (`email.go` is the best example, its behaviour is broadly independent from the main app except from a couple config values).
|
||||
|
||||
|
||||
# Compiling
|
||||
|
||||
The Makefile is more suited towards development than other build methods, and provides separate build stages to speed up compilation when only making changes to specific aspects of the project.
|
||||
|
||||
Prefix each of these with `make DEBUG=on `:
|
||||
* `all` will download deps and build everything. The executable and data will be placed in `build`. This is only necessary the first time.
|
||||
* `npm` will download all node.js build-time dependencies.
|
||||
* `compile` will only compile go code into the `build/jfa-go` executable.
|
||||
* `typescript` will compile typescript w/ sourcemaps into `build/data/web/js`.
|
||||
* `bundle-css` will bundle CSS and place it in `build/data/web/css`.
|
||||
* `inline` will inline the css and javascript used in the single-file crash report webpage.
|
||||
* `configuration` will generate the `config-base.json` (used to render settings in the web ui) and `config-default.ini` and put them in `build/data`.
|
||||
* `email` will compile email mjml, and copy the text versions in to `build/data`.
|
||||
* `swagger`: generates swagger documentation for the API.
|
||||
* `copy` will copy iconography, html, language files and static data into `build/data`.
|
||||
|
||||
## Environment variables
|
||||
|
||||
* `DEBUG=on/off`: If on, compiles with type-checking for typescript, sourcemaps, non-minified css and no symbol stripping.
|
||||
* `INTERNAL=on/off`: Whether or not to embed file assets into the binary itself, or store them separately beside the binary.
|
||||
* `UPDATER=on/off/docker`: Enable/Disable the updater, or set a special update type (currently only docker, which disables self-updating the binary).
|
||||
* `TRAY=on/off`: Enable/disable the tray icon, which lets you start/stop/autostart on login. For linux, requires `libappindicator3-dev` for debian or the equivalent on other distributions.
|
||||
* `GOESBUILD=on`: Use a locally installed `esbuild` binary. NPM doesn't provide builds for all os/architectures, so `npx esbuild` might not work for you, so the binary is compiled/installed with `go get`.
|
||||
* `GOBINARY=<path to go>`: Alternative path to go executable. Useful for testing with unstable go releases.
|
||||
* `VERSION=v<semver>`: Alternative verision number, useful to test update functionality.
|
||||
* `COMMIT=<short commit>`: Self explanatory.
|
||||
* `LDFLAGS=<ldflags>`: Passed to `go build -ldflags`.
|
||||
* `E2EE=on/off`: Enable/disable end-to-end encryption support for Matrix, which is currently very broken. Must subsequently be enabled (with Advanced settings enabled) in Settings > Matrix.
|
||||
* `TAGS=<tags>`: Passed to `go build -tags`.
|
||||
* `OS=<os>`: Unrelated to GOOS, if set to `windows`, `-H=windowsgui` is passed to ldflags, which stops a windows terminal popping up when run.
|
||||
* `RACE=on/off`: If on, compiles with the go race detector included.
|
||||
[See the wiki page](https://wiki.jfa-go.com/docs/dev/).
|
||||
|
||||
30
Dockerfile
@@ -1,30 +1,20 @@
|
||||
FROM --platform=$BUILDPLATFORM golang:latest AS support
|
||||
FROM --platform=$BUILDPLATFORM docker.io/hrfee/jfa-go-build-docker:latest AS support
|
||||
ARG BUILT_BY
|
||||
ENV JFA_GO_BUILT_BY=$BUILT_BY
|
||||
|
||||
COPY . /opt/build
|
||||
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install build-essential python3-pip curl software-properties-common sed -y \
|
||||
&& (curl -sL https://deb.nodesource.com/setup_current.x | bash -) \
|
||||
&& apt-get install nodejs \
|
||||
&& (cd /opt/build; make configuration npm email typescript variants-html bundle-css inline-css swagger copy INTERNAL=off GOESBUILD=on) \
|
||||
&& sed -i 's#id="password_resets-watch_directory" placeholder="/config/jellyfin"#id="password_resets-watch_directory" value="/jf" disabled#g' /opt/build/build/data/html/setup.html
|
||||
RUN cd /opt/build; INTERNAL=off UPDATER=docker ./scripts/version.sh goreleaser build --snapshot --skip=validate --clean --id notray-e2ee
|
||||
RUN mv /opt/build/dist/*_linux_arm_6 /opt/build/dist/placeholder_linux_arm
|
||||
RUN sed -i 's#id="password_resets-watch_directory" placeholder="/config/jellyfin"#id="password_resets-watch_directory" value="/jf" disabled#g' /opt/build/build/data/html/setup.html
|
||||
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:latest AS build
|
||||
FROM gcr.io/distroless/base:latest AS final
|
||||
ARG TARGETARCH
|
||||
ENV GOARCH=$TARGETARCH
|
||||
|
||||
COPY --from=support /opt/build /opt/build
|
||||
|
||||
RUN (cd /opt/build; make compile INTERNAL=off UPDATER=docker)
|
||||
|
||||
FROM golang:latest
|
||||
|
||||
COPY --from=build /opt/build/build /opt/jfa-go
|
||||
COPY --from=support /opt/build/dist/*_linux_${TARGETARCH}* /jfa-go
|
||||
COPY --from=support /opt/build/build/data /jfa-go/data
|
||||
|
||||
EXPOSE 8056
|
||||
EXPOSE 8057
|
||||
|
||||
CMD [ "/opt/jfa-go/jfa-go", "-data", "/data" ]
|
||||
|
||||
|
||||
CMD [ "/jfa-go/jfa-go", "-data", "/data" ]
|
||||
|
||||
10
Dockerfile.ci
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM gcr.io/distroless/base:latest AS final
|
||||
ARG TARGETARCH
|
||||
|
||||
COPY ./dist/notray-e2ee_linux_${TARGETARCH}* /jfa-go
|
||||
COPY ./build/data /jfa-go/data
|
||||
|
||||
EXPOSE 8056
|
||||
EXPOSE 8057
|
||||
|
||||
CMD [ "/jfa-go/jfa-go", "-data", "/data" ]
|
||||
2
LICENSE
@@ -2,7 +2,7 @@
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Harvey Tindall
|
||||
Copyright (c) 2025 Harvey Tindall
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
256
Makefile
@@ -1,3 +1,8 @@
|
||||
.PHONY: configuration email typescript swagger copy compile compress inline-css variants-html install clean npm config-description config-default precompile test
|
||||
.DEFAULT_GOAL := all
|
||||
|
||||
TS ?= npx tsgo
|
||||
|
||||
GOESBUILD ?= off
|
||||
ifeq ($(GOESBUILD), on)
|
||||
ESBUILD := esbuild
|
||||
@@ -6,14 +11,16 @@ else
|
||||
endif
|
||||
GOBINARY ?= go
|
||||
|
||||
CSSVERSION ?= v3
|
||||
CSSVERSION ?= $(shell git describe --tags --abbrev=0)
|
||||
CSS_BUNDLE = $(DATA)/web/css/$(CSSVERSION)bundle.css
|
||||
|
||||
VERSION ?= $(shell git describe --exact-match HEAD 2> /dev/null || echo vgit)
|
||||
VERSION := $(shell echo $(VERSION) | sed 's/v//g')
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD || echo unknown)
|
||||
BUILDTIME ?= $(shell date +%s)
|
||||
|
||||
UPDATER ?= off
|
||||
LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.cssVersion=$(CSSVERSION)
|
||||
LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.cssVersion=$(CSSVERSION) -X main.buildTimeUnix=$(BUILDTIME) $(if $(BUILTBY),-X 'main.builtBy=$(BUILTBY)',)
|
||||
ifeq ($(UPDATER), on)
|
||||
LDFLAGS := $(LDFLAGS) -X main.updater=binary
|
||||
else ifneq ($(UPDATER), off)
|
||||
@@ -24,14 +31,16 @@ endif
|
||||
|
||||
INTERNAL ?= on
|
||||
TRAY ?= off
|
||||
E2EE ?= off
|
||||
E2EE ?= on
|
||||
TAGS := -tags "
|
||||
|
||||
ifeq ($(INTERNAL), on)
|
||||
DATA := data
|
||||
DATA := build/data
|
||||
COMPDEPS := $(BUILDDEPS)
|
||||
else
|
||||
DATA := build/data
|
||||
TAGS := $(TAGS) external
|
||||
COMPDEPS :=
|
||||
endif
|
||||
|
||||
ifeq ($(TRAY), on)
|
||||
@@ -39,7 +48,7 @@ ifeq ($(TRAY), on)
|
||||
endif
|
||||
|
||||
ifeq ($(E2EE), on)
|
||||
TAGS := $(TAGS) e2ee
|
||||
TAGS := $(TAGS) e2ee goolm
|
||||
endif
|
||||
|
||||
TAGS := $(TAGS)"
|
||||
@@ -52,17 +61,19 @@ endif
|
||||
DEBUG ?= off
|
||||
ifeq ($(DEBUG), on)
|
||||
SOURCEMAP := --sourcemap
|
||||
TYPECHECK := npx tsc -noEmit --project ts/tsconfig.json
|
||||
MINIFY :=
|
||||
TYPECHECK := $(TS) -noEmit --incremental --project ts/tsconfig.json
|
||||
# jank
|
||||
COPYTS := rm -r $(DATA)/web/js/ts; cp -r tempts $(DATA)/web/js/ts
|
||||
UNCSS := cp $(DATA)/web/css/bundle.css $(DATA)/bundle.css
|
||||
UNCSS := cp $(CSS_BUNDLE) $(DATA)/bundle.css
|
||||
# TAILWIND := --content ""
|
||||
else
|
||||
LDFLAGS := -s -w $(LDFLAGS)
|
||||
SOURCEMAP :=
|
||||
MINIFY := --minify
|
||||
COPYTS :=
|
||||
TYPECHECK :=
|
||||
UNCSS := npx tailwindcss -i $(DATA)/web/css/bundle.css -o $(DATA)/bundle.css --content "html/crash.html"
|
||||
UNCSS := npx tailwindcss -i $(CSS_BUNDLE) -o $(DATA)/bundle.css --content "html/crash.html"
|
||||
# UNCSS := npx uncss $(DATA)/crash.html --csspath web/css --output $(DATA)/bundle.css
|
||||
TAILWIND :=
|
||||
endif
|
||||
@@ -74,99 +85,178 @@ else
|
||||
RACEDETECTOR :=
|
||||
endif
|
||||
|
||||
npm:
|
||||
$(info installing npm dependencies)
|
||||
npm install
|
||||
@if [ "$(GOESBUILD)" = "off" ]; then\
|
||||
npm install esbuild;\
|
||||
else\
|
||||
go install github.com/evanw/esbuild/cmd/esbuild@latest;\
|
||||
fi
|
||||
ifeq (, $(shell which esbuild))
|
||||
ESBUILDINSTALL := go install github.com/evanw/esbuild/cmd/esbuild@latest
|
||||
else
|
||||
ESBUILDINSTALL :=
|
||||
endif
|
||||
|
||||
configuration:
|
||||
$(info Fixing config-base)
|
||||
-mkdir -p $(DATA)
|
||||
python3 scripts/enumerate_config.py -i config/config-base.json -o $(DATA)/config-base.json
|
||||
$(info Generating config-default.ini)
|
||||
python3 scripts/generate_ini.py -i config/config-base.json -o $(DATA)/config-default.ini
|
||||
ifeq ($(GOESBUILD), on)
|
||||
NPMIGNOREOPTIONAL := --no-optional
|
||||
NPMOPTS := $(NPMIGNOREOPTIONAL); $(ESBUILDINSTALL)
|
||||
else
|
||||
NPMOPTS :=
|
||||
endif
|
||||
|
||||
email:
|
||||
$(info Generating email html)
|
||||
python3 scripts/compile_mjml.py -o $(DATA)/
|
||||
ifeq (, $(shell which swag))
|
||||
SWAGINSTALL := $(GOBINARY) install github.com/swaggo/swag/cmd/swag@v1.16.4
|
||||
else
|
||||
SWAGINSTALL :=
|
||||
endif
|
||||
|
||||
typescript:
|
||||
$(TYPECHECK)
|
||||
$(adding dark variants to typescript)
|
||||
rm -rf tempts
|
||||
cp -r ts tempts
|
||||
scripts/dark-variant.sh tempts
|
||||
scripts/dark-variant.sh tempts/modules
|
||||
$(info compiling typescript)
|
||||
# FLAG HASHING: To rebuild on flag change.
|
||||
# credit for idea to https://bnikolic.co.uk/blog/sh/make/unix/2021/07/08/makefile.html
|
||||
rebuildFlags := GOESBUILD GOBINARY VERSION COMMIT UPDATER INTERNAL TRAY E2EE TAGS DEBUG RACE
|
||||
rebuildVals := $(foreach v,$(rebuildFlags),$(v)=$($(v)))
|
||||
rebuildHash := $(strip $(shell echo $(rebuildVals) | sha256sum | cut -d " " -f1))
|
||||
rebuildHashFile := $(DATA)/buildhash-$(rebuildHash).txt
|
||||
|
||||
CONFIG_BASE = config/config-base.yaml
|
||||
|
||||
# CONFIG_DESCRIPTION = $(DATA)/config-base.json
|
||||
CONFIG_DEFAULT = $(DATA)/config-default.ini
|
||||
# $(CONFIG_DESCRIPTION) &: $(CONFIG_BASE)
|
||||
# $(info Fixing config-base)
|
||||
# -mkdir -p $(DATA)
|
||||
|
||||
$(DATA):
|
||||
mkdir -p $(DATA)/web/js
|
||||
$(ESBUILD) --target=es6 --bundle tempts/admin.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/admin.js --minify
|
||||
$(ESBUILD) --target=es6 --bundle tempts/user.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/user.js --minify
|
||||
$(ESBUILD) --target=es6 --bundle tempts/pwr.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/pwr.js --minify
|
||||
$(ESBUILD) --target=es6 --bundle tempts/form.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/form.js --minify
|
||||
$(ESBUILD) --target=es6 --bundle tempts/setup.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/setup.js --minify
|
||||
$(ESBUILD) --target=es6 --bundle tempts/crash.ts --outfile=./$(DATA)/crash.js --minify
|
||||
mkdir -p $(DATA)/web/css
|
||||
|
||||
$(CONFIG_DEFAULT): $(CONFIG_BASE)
|
||||
$(info Generating config-default.ini)
|
||||
CGO_ENABLED=0 go run scripts/ini/main.go -in $(CONFIG_BASE) -out $(DATA)/config-default.ini
|
||||
|
||||
configuration: $(CONFIG_DEFAULT)
|
||||
|
||||
EMAIL_SRC = $(wildcard mail/*)
|
||||
EMAIL_TARGET = $(DATA)/confirmation.html
|
||||
$(EMAIL_TARGET): $(EMAIL_SRC)
|
||||
$(info Generating email html)
|
||||
npx mjml mail/*.mjml -o $(DATA)/
|
||||
$(info Copying plaintext mail)
|
||||
cp mail/*.txt $(DATA)/
|
||||
|
||||
TYPESCRIPT_FULLSRC = $(shell find ts/ -type f -name "*.ts")
|
||||
TYPESCRIPT_SRC = $(wildcard ts/*.ts)
|
||||
TYPESCRIPT_TEMPSRC = $(TYPESCRIPT_SRC:ts/%=tempts/%)
|
||||
# TYPESCRIPT_TARGET = $(patsubst %.ts,%.js,$(subst tempts/,./$(DATA)/web/js/,$(TYPESCRIPT_TEMPSRC)))
|
||||
TYPESCRIPT_TARGET = $(DATA)/web/js/admin.js
|
||||
$(TYPESCRIPT_TARGET): $(TYPESCRIPT_FULLSRC) ts/tsconfig.json
|
||||
$(TYPECHECK)
|
||||
# rm -rf tempts
|
||||
# cp -r ts tempts
|
||||
rm -rf tempts
|
||||
mkdir -p tempts
|
||||
$(adding dark variants to typescript)
|
||||
# scripts/dark-variant.sh tempts
|
||||
# scripts/dark-variant.sh tempts/modules
|
||||
CGO_ENABLED=0 go run scripts/variants/main.go -dir ts -out tempts
|
||||
$(info compiling typescript)
|
||||
$(foreach tempsrc,$(TYPESCRIPT_TEMPSRC),$(ESBUILD) --target=es6 --bundle $(tempsrc) $(SOURCEMAP) --outfile=$(patsubst %.ts,%.js,$(subst tempts/,./$(DATA)/web/js/,$(tempsrc))) $(MINIFY);)
|
||||
$(COPYTS)
|
||||
|
||||
swagger:
|
||||
$(GOBINARY) install github.com/swaggo/swag/cmd/swag@latest
|
||||
swag init -g main.go
|
||||
SWAGGER_SRC = $(wildcard api*.go) $(wildcard *auth.go) views.go
|
||||
SWAGGER_TARGET = docs/docs.go
|
||||
$(SWAGGER_TARGET): $(SWAGGER_SRC)
|
||||
$(SWAGINSTALL)
|
||||
swag init --parseDependency --parseInternal -g main.go
|
||||
|
||||
compile:
|
||||
$(info Downloading deps)
|
||||
$(GOBINARY) mod download
|
||||
$(info Building)
|
||||
mkdir -p build
|
||||
$(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o build/jfa-go
|
||||
|
||||
compress:
|
||||
upx --lzma build/jfa-go
|
||||
|
||||
bundle-css:
|
||||
mkdir -p $(DATA)/web/css
|
||||
$(info copying fonts)
|
||||
cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 $(DATA)/web/css/
|
||||
$(info bundling css)
|
||||
$(ESBUILD) --bundle css/base.css --outfile=$(DATA)/web/css/bundle.css --external:remixicon.css --minify
|
||||
npx tailwindcss -i $(DATA)/web/css/bundle.css -o $(DATA)/web/css/bundle.css $(TAILWIND)
|
||||
# npx postcss -o $(DATA)/web/css/bundle.css $(DATA)/web/css/bundle.css
|
||||
|
||||
inline-css:
|
||||
cp html/crash.html $(DATA)/crash.html
|
||||
$(UNCSS)
|
||||
node scripts/inline.js root $(DATA) $(DATA)/crash.html $(DATA)/crash.html
|
||||
rm $(DATA)/bundle.css
|
||||
|
||||
variants-html:
|
||||
VARIANTS_SRC = $(wildcard html/*.html) $(wildcard html/*.txt)
|
||||
VARIANTS_TARGET = $(DATA)/html/admin.html
|
||||
$(VARIANTS_TARGET): $(VARIANTS_SRC)
|
||||
$(info copying html)
|
||||
cp -r html $(DATA)/
|
||||
$(info adding dark variants to html)
|
||||
node scripts/missing-colors.js html $(DATA)/html
|
||||
|
||||
copy:
|
||||
ICON_SRC = node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2
|
||||
ICON_TARGET = $(ICON_SRC:node_modules/remixicon/fonts/%=$(DATA)/web/css/%)
|
||||
SYNTAX_LIGHT_SRC = node_modules/highlight.js/styles/base16/atelier-sulphurpool-light.min.css
|
||||
SYNTAX_LIGHT_TARGET = $(DATA)/web/css/$(CSSVERSION)highlightjs-light.css
|
||||
SYNTAX_DARK_SRC = node_modules/highlight.js/styles/base16/circus.min.css
|
||||
SYNTAX_DARK_TARGET = $(DATA)/web/css/$(CSSVERSION)highlightjs-dark.css
|
||||
CODEINPUT_SRC = node_modules/@webcoder49/code-input/code-input.min.css
|
||||
CODEINPUT_TARGET = $(DATA)/web/css/$(CSSVERSION)code-input.css
|
||||
CSS_SRC = $(wildcard css/*.css)
|
||||
CSS_TARGET = $(DATA)/web/css/part-bundle.css
|
||||
CSS_FULLTARGET = $(CSS_BUNDLE)
|
||||
ALL_CSS_SRC = $(ICON_SRC) $(CSS_SRC) $(SYNTAX_LIGHT_SRC) $(SYNTAX_DARK_SRC)
|
||||
ALL_CSS_TARGET = $(ICON_TARGET)
|
||||
|
||||
$(CSS_FULLTARGET): $(TYPESCRIPT_TARGET) $(VARIANTS_TARGET) $(ALL_CSS_SRC) $(wildcard html/*.html) $(wildcard html.*.txt)
|
||||
$(info copying fonts)
|
||||
cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 $(DATA)/web/css/
|
||||
cp -r $(SYNTAX_LIGHT_SRC) $(SYNTAX_LIGHT_TARGET)
|
||||
cp -r $(SYNTAX_DARK_SRC) $(SYNTAX_DARK_TARGET)
|
||||
cp -r $(CODEINPUT_SRC) $(CODEINPUT_TARGET)
|
||||
$(info bundling css)
|
||||
rm -f $(CSS_TARGET) $(CSS_FULLTARGET)
|
||||
$(ESBUILD) --bundle css/base.css --outfile=$(CSS_TARGET) --external:remixicon.css --external:../fonts/hanken* --minify
|
||||
|
||||
npx tailwindcss -i $(CSS_TARGET) -o $(CSS_FULLTARGET) $(TAILWIND)
|
||||
rm $(CSS_TARGET)
|
||||
# mv $(CSS_BUNDLE) $(DATA)/web/css/$(CSSVERSION)bundle.css
|
||||
# npx postcss -o $(CSS_TARGET) $(CSS_TARGET)
|
||||
|
||||
INLINE_SRC = html/crash.html
|
||||
INLINE_TARGET = $(DATA)/crash.html
|
||||
$(INLINE_TARGET): $(CSS_FULLTARGET) $(INLINE_SRC)
|
||||
cp html/crash.html $(DATA)/crash.html
|
||||
$(UNCSS) # generates $(DATA)/bundle.css for us
|
||||
node scripts/inline.js root $(DATA) $(DATA)/crash.html $(DATA)/crash.html
|
||||
rm $(DATA)/bundle.css
|
||||
|
||||
LANG_SRC = $(shell find ./lang)
|
||||
LANG_TARGET = $(LANG_SRC:lang/%=$(DATA)/lang/%)
|
||||
STATIC_SRC = $(wildcard static/*)
|
||||
STATIC_TARGET = $(STATIC_SRC:static/%=$(DATA)/web/%)
|
||||
COPY_SRC = images/banner.svg jfa-go.service LICENSE $(LANG_SRC) $(STATIC_SRC)
|
||||
COPY_TARGET = $(DATA)/jfa-go.service
|
||||
# $(DATA)/LICENSE $(LANG_TARGET) $(STATIC_TARGET) $(DATA)/web/css/$(CSSVERSION)bundle.css
|
||||
$(COPY_TARGET): $(INLINE_TARGET) $(STATIC_SRC) $(LANG_SRC) $(CONFIG_BASE)
|
||||
$(info copying $(CONFIG_BASE))
|
||||
CGO_ENABLED=0 go run scripts/yaml/main.go -in $(CONFIG_BASE) -out $(DATA)/$(shell basename $(CONFIG_BASE))
|
||||
$(info copying crash page)
|
||||
mv $(DATA)/crash.html $(DATA)/html/
|
||||
cp $(DATA)/crash.html $(DATA)/html/
|
||||
$(info copying static data)
|
||||
mkdir -p $(DATA)/web
|
||||
cp images/banner.svg static/banner.svg
|
||||
cp -r static/* $(DATA)/web/
|
||||
$(info copying systemd service)
|
||||
cp jfa-go.service $(DATA)/
|
||||
$(info copying language files)
|
||||
cp -r lang $(DATA)/
|
||||
cp LICENSE $(DATA)/
|
||||
mv $(DATA)/web/css/bundle.css $(DATA)/web/css/$(CSSVERSION)bundle.css
|
||||
|
||||
# internal-files:
|
||||
# python3 scripts/embed.py internal
|
||||
#
|
||||
# external-files:
|
||||
# python3 scripts/embed.py external
|
||||
# -mkdir -p build
|
||||
# $(info copying internal data into build/)
|
||||
# cp -r data build/
|
||||
BUILDDEPS := $(DATA) $(CONFIG_DEFAULT) $(EMAIL_TARGET) $(COPY_TARGET) $(SWAGGER_TARGET) $(INLINE_TARGET) $(CSS_FULLTARGET) $(TYPESCRIPT_TARGET)
|
||||
precompile: $(BUILDDEPS)
|
||||
|
||||
COMPDEPS = $(rebuildHashFile)
|
||||
ifeq ($(INTERNAL), on)
|
||||
COMPDEPS = $(BUILDDEPS) $(rebuildHashFile)
|
||||
endif
|
||||
|
||||
$(rebuildHashFile):
|
||||
$(info recording new flags $(rebuildVals))
|
||||
rm -f $(DATA)/buildhash-*.txt
|
||||
touch $(rebuildHashFile)
|
||||
|
||||
GO_SRC = $(shell find ./ -name "*.go")
|
||||
GO_TARGET = build/jfa-go
|
||||
$(GO_TARGET): $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
|
||||
$(info Downloading deps)
|
||||
$(GOBINARY) mod download
|
||||
$(info Building)
|
||||
mkdir -p build
|
||||
$(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o $(GO_TARGET) $(GOBUILDFLAGS)
|
||||
|
||||
test: $(BUILDDEPS) $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
|
||||
$(GOBINARY) test -ldflags="$(LDFLAGS)" $(TAGS) -p 1
|
||||
|
||||
all: $(BUILDDEPS) $(GO_TARGET) $(rebuildHashFile)
|
||||
|
||||
compress:
|
||||
upx --lzma $(GO_TARGET)
|
||||
|
||||
install:
|
||||
cp -r build $(DESTDIR)/jfa-go
|
||||
@@ -178,6 +268,6 @@ clean:
|
||||
-rm docs/docs.go docs/swagger.json docs/swagger.yaml
|
||||
go clean
|
||||
|
||||
quick: configuration typescript variants-html bundle-css inline-css copy compile
|
||||
|
||||
all: configuration npm email typescript variants-html bundle-css inline-css swagger copy compile
|
||||
npm:
|
||||
$(info installing npm dependencies)
|
||||
npm install $(NPMOPTS)
|
||||
|
||||
89
README.md
@@ -1,5 +1,5 @@
|
||||

|
||||
[](https://drone.hrfee.dev/hrfee/jfa-go)
|
||||
[](https://ci.hrfee.dev/repos/3)
|
||||
[](https://hub.docker.com/r/hrfee/jfa-go)
|
||||
[](https://weblate.jfa-go.com/engage/jfa-go/)
|
||||
[](https://wiki.jfa-go.com)
|
||||
@@ -9,62 +9,46 @@
|
||||
##### [docker](#docker) | [debian/ubuntu](#debian) | [arch (aur)](#aur) | [other platforms](#other-platforms)
|
||||
|
||||
---
|
||||
## Project Status: Active-ish
|
||||
Studies mean I can't work on this project a lot outside of breaks, however I hope i'll be able to fit in general support and things like bug fixes into my time. New features and such will likely come in short bursts throughout the year (if they do at all).
|
||||
|
||||
## Project Status
|
||||
Due to studies and general lack of enthusiasm for work on this project, new features are unlikely, and while occasionally I might fix a bug or two, I won't be supporting the project a lot.
|
||||
|
||||
#### Does it still work?
|
||||
jfa-go still appears to work on the latest version of Jellyfin (10.8.9), and unless any large architectural changes occur to it, functionality should still remain.
|
||||
#### Does/Will it still work?
|
||||
jfa-go currently works on Jellyfin 10.11.0, the latest version as of 21/10/25. I should be able to maintain compatibility in the future, unless any big changes occur.
|
||||
|
||||
#### Alternatives
|
||||
None of these have been tested by myself, but I have seen them mentioned quite frequently.
|
||||
If you want a bit more guarantee of support [Wizarr](https://github.com/Wizarrrr/wizarr) is popular and seems very polished. It supports multiple media servers, lots of customization and invitation through Discord.
|
||||
|
||||
* [Wizarr](https://github.com/Wizarrrr/wizarr) focuses on invites, and also includes some Discord & Ombi integration.
|
||||
* [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) is a fork of Overseerr, which can manage users and mainly acts as an Ombi alternative.
|
||||
* [Organizr](https://github.com/causefx/Organizr) doesn't focus on Jellyfin, but allows putting self-hosted services into "tabs" on a central page, and allows creating users, which lets one control who can access what.
|
||||
---
|
||||
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) (and now [Emby](https://emby.media/)) that provides invite-based account creation as well as other features that make one's instance much easier to manage.
|
||||
|
||||
a rewrite of [jellyfin-accounts](https://github.com/hrfee/jellyfin-accounts) (original naming for both, ik
|
||||
😂).
|
||||
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) (and [Emby](https://emby.media/) as 2nd class) that provides invite-based account creation as well as other features that make ones instance much easier to manage.
|
||||
|
||||
#### Features
|
||||
* 🧑 Invite based account creation: Send invites to your friends or family, and let them choose their own username and password without relying on you.
|
||||
* Send invites via a link and/or email
|
||||
* Granular control over invites: Validity period as well as number of uses can be specified.
|
||||
* Account profiles: Assign settings profiles to invites so new users have your predefined permissions, homescreen layout, etc. applied to their account on creation.
|
||||
* Password validation: Ensure users choose a strong password.
|
||||
* CAPTCHAs can be enabled to avoid bots
|
||||
* ⌛ User expiry: Specify a validity period, and new users accounts will be disabled/deleted after it. The period can be manually extended too.
|
||||
* 🔗 Ombi Integration: Automatically creates Ombi accounts for new users using their email address and login details, and your own defined set of permissions.
|
||||
* Account management: Apply settings to your users individually or en masse, and delete users, optionally sending them an email notification with a reason.
|
||||
* Telegram/Discord/Matrix Integration: Verify users via a chat bot, and send Password Resets, Announcements, etc. through it.
|
||||
* 📨 Email storage: Add your existing users email addresses through the UI, and jfa-go will ask new users for them on account creation.
|
||||
* Email addresses can optionally be used instead of usernames
|
||||
* 🔑 Password resets: When users forget their passwords and request a change in Jellyfin, jfa-go reads the PIN from the created file and sends it straight to them via email/telegram.
|
||||
* Admin Notifications: Get notified when someone creates an account, or an invite expires.
|
||||
* 📣 Announcements: Bulk message your users with announcements about your server.
|
||||
* Authentication via Jellyfin: Instead of using separate credentials for jfa-go and Jellyfin, jfa-go can use it as the authentication provider.
|
||||
* Enables the usage of jfa-go by multiple people
|
||||
* 🌓 Customizations
|
||||
* Customize emails with variables and markdown
|
||||
* Specify contact and help messages to appear in emails and pages
|
||||
* Light and dark themes available
|
||||
* **Invites**: Send invite links to new users so they can sign up without relying on you.
|
||||
* Customize with profiles: Apply Jellyfin settings (library access, transcoding, etc.) on sign-up, with different profiles for each user type.
|
||||
* Limit invites by time or number of uses, enforce strong passwords, require a CAPTCHA, and more
|
||||
* **Password Resets**: Let your users do it themselves. Works with the Jellyfin "Forgot Password" feature, or through the "My Account" page. [See the wiki for your options](https://wiki.jfa-go.com/docs/pwr/).
|
||||
* **Contact your users**: Collect email address, Discord/Telegram/Matrix info when the user signs up or add later, and jfa-go will contact them when needed (e.g. on/before account expiry, disabling/enabling, deletion) or when you wish with Markdown announcements.
|
||||
* "Confirm email" optional, similar is required for Discord/Telegram/Matrix
|
||||
* **"My Account"**: Lets your users change their password or email/contact info themselves and show them relevant info on a special page. Also,
|
||||
* Referrals: Allow users a special, limited invite to give to their friends/family.
|
||||
* **Advanced user management**: See all of your users at once and manage them in bulk (enable/disable/delete, send markdown announcements, apply profiles/settings, and more)
|
||||
* User expiry: Set on an invite, and any new users will be valid for a fixed period (e.g. 30 days). After time passes, account is disabled, deleted, or disabled then deleted.
|
||||
* **Ombi/Jellyseerr integration**: Sync username/passwords & contact details between your services.
|
||||
* **Customizable**: Edit messages sent to users and shown on invites, "My Account" page and more with full Markdown support.
|
||||
|
||||
#### Interface
|
||||
<p align="center">
|
||||
<img src="images/demo.gif" width="100%"></img>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="images/invites.png" width="31%" style="margin-left: 1.5%;" alt="Invites tab"></img>
|
||||
<img src="images/accounts.png" width="31%" style="margin-right: 1.5%;" alt="Accounts tab"></img>
|
||||
<img src="images/create.png" width="31%" style="margin-right: 1.5%;" alt="Accounts creation"></img>
|
||||
<img src="images/invites.png" width="47%" style="margin-left: 1.5%;" align="top" alt="Invites tab"></img>
|
||||
<img src="images/create.png" width="47%" style="margin-right: 1.5%;" align="top" alt="Accounts creation"></img>
|
||||
<img src="images/myaccount.png" width="47%" style="margin-left: 1.5%; margin-top: 1rem;" align="top" alt="My Account Page"></img>
|
||||
<img src="images/accounts.png" width="47%" style="margin-right: 1.5%; margin-top: 1rem;" align="top" alt="Accounts tab"></img>
|
||||
</p>
|
||||
|
||||
#### Install
|
||||
|
||||
**Note**: `TrayIcon` builds include a tray icon to start/stop/restart, and an option to automatically start when you log-in to your computer. For Linux users, these builds depend on the `libappindicator3-1`/`libappindicator-gtk3`/`libappindicator` package for Debian/Ubuntu, Fedora, and Alpine respectively.
|
||||
**Note**: `TrayIcon` builds include a tray icon to start/stop/restart, and an option to automatically start when you log-in to your computer.
|
||||
|
||||
`MatrixE2EE` builds (and Linux `TrayIcon` builds) include support for end-to-end encryption for the Matrix bot, but require the `libolm(-dev)` dependency. `.deb/.rpm/.apk` packages list this dependency, and docker images include it.
|
||||
|
||||
##### [Docker](https://hub.docker.com/r/hrfee/jfa-go)
|
||||
```sh
|
||||
@@ -73,7 +57,7 @@ docker create \
|
||||
-p 8056:8056 \
|
||||
# -p 8057:8057 if using tls
|
||||
-v /path/to/.config/jfa-go:/data \ # Path to wherever you want to store the config file and other data
|
||||
-v /path/to/jellyfin:/jf \ # Path to Jellyfin config directory, ignore if using Emby
|
||||
-v /path/to/jellyfin:/jf \ # Only needed for password resets through Jellyfin, ignore if not using or using Emby
|
||||
-v /etc/localtime:/etc/localtime:ro \ # Makes sure time is correct
|
||||
hrfee/jfa-go # hrfee/jfa-go:unstable for latest build from git
|
||||
```
|
||||
@@ -81,7 +65,7 @@ docker create \
|
||||
##### [Debian/Ubuntu](https://apt.hrfee.dev)
|
||||
```sh
|
||||
sudo apt-get update && sudo apt-get install curl apt-transport-https gnupg
|
||||
curl https://apt.hrfee.dev/hrfee.pubkey.gpg | sudo apt-key add -
|
||||
curl https://apt.hrfee.dev/hrfee.pubkey.gpg | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/apt.hrfee.dev.gpg
|
||||
|
||||
# For stable releases
|
||||
echo "deb https://apt.hrfee.dev trusty main" | sudo tee /etc/apt/sources.list.d/hrfee.list
|
||||
@@ -95,7 +79,7 @@ sudo apt-get update
|
||||
# For servers
|
||||
sudo apt-get install jfa-go
|
||||
# ------
|
||||
# For desktops/servers with GUI (has dependencies)
|
||||
# For desktops/servers with GUI (may pull in lots of dependencies)
|
||||
sudo apt-get install jfa-go-tray
|
||||
# ------
|
||||
```
|
||||
@@ -109,7 +93,7 @@ Available on the AUR as:
|
||||
##### Other platforms
|
||||
Download precompiled binaries from:
|
||||
* [The releases section](https://github.com/hrfee/jfa-go/releases) (stable)
|
||||
* [Buildrone](https://builds.hrfee.dev/view/hrfee/jfa-go) (nightly)
|
||||
* [dl.jfa-go.com](https://dl.jfa-go.com) (nightly)
|
||||
|
||||
unzip the `jfa-go`/`jfa-go.exe` executable to somewhere useful.
|
||||
* For \*nix/macOS users, `chmod +x jfa-go` then place it somewhere in your PATH like `/usr/bin`.
|
||||
@@ -148,6 +132,8 @@ Usage of jfa-go:
|
||||
alternate port to host web ui on.
|
||||
-pprof
|
||||
Exposes pprof profiler on /debug/pprof.
|
||||
-restore string
|
||||
path to database backup to restore.
|
||||
-swagger
|
||||
Enable swagger at /swagger/index.html
|
||||
```
|
||||
@@ -155,18 +141,9 @@ Usage of jfa-go:
|
||||
#### Systemd
|
||||
jfa-go does not run as a daemon by default. Run `jfa-go systemd` to create a systemd `.service` file in your current directory, which you can copy into `~/.config/systemd/user` or somewhere else.
|
||||
|
||||
---
|
||||
|
||||
If you're switching from jellyfin-accounts, copy your existing `~/.jf-accounts` to:
|
||||
|
||||
* `XDG_CONFIG_DIR/jfa-go` (usually ~/.config/jfa-go) on \*nix systems,
|
||||
* `%AppData%/jfa-go` on Windows,
|
||||
* `~/Library/Application Support/jfa-go` on macOS.
|
||||
|
||||
(or specify config/data path with `-config/-data` respectively.)
|
||||
|
||||
#### Contributing
|
||||
See [the wiki page](https://wiki.jfa-go.com/docs/dev/) or [CONTRIBUTING.md](https://github.com/hrfee/jfa-go/blob/main/CONTRIBUTING.md).
|
||||
See [the wiki page](https://wiki.jfa-go.com/docs/dev/).
|
||||
##### Translation
|
||||
[](https://weblate.jfa-go.com/engage/jfa-go/)
|
||||
|
||||
|
||||
255
activitysort.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
ACTIVITY_DEFAULT_SORT_FIELD = "Time"
|
||||
// This will be default anyway, as the default value of a bool field is false.
|
||||
// ACTIVITY_DEFAULT_SORT_ASCENDING = false
|
||||
)
|
||||
|
||||
func activityDTONameToField(field string) string {
|
||||
// Only "ID" and "Time" of these are actually searched by the UI.
|
||||
// We support the rest though for other consumers of the API.
|
||||
switch field {
|
||||
case "id":
|
||||
return "ID"
|
||||
case "type":
|
||||
return "Type"
|
||||
case "user_id":
|
||||
return "UserID"
|
||||
case "username":
|
||||
return "Username"
|
||||
case "source_type":
|
||||
return "SourceType"
|
||||
case "source":
|
||||
return "Source"
|
||||
case "source_username":
|
||||
return "SourceUsername"
|
||||
case "invite_code":
|
||||
return "InviteCode"
|
||||
case "value":
|
||||
return "Value"
|
||||
case "time":
|
||||
return "Time"
|
||||
case "ip":
|
||||
return "IP"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func activityTypeGetterNameToType(getter string) ActivityType {
|
||||
switch getter {
|
||||
case "accountCreation":
|
||||
return ActivityCreation
|
||||
case "accountDeletion":
|
||||
return ActivityDeletion
|
||||
case "accountDisabled":
|
||||
return ActivityDisabled
|
||||
case "accountEnabled":
|
||||
return ActivityEnabled
|
||||
case "contactLinked":
|
||||
return ActivityContactLinked
|
||||
case "contactUnlinked":
|
||||
return ActivityContactUnlinked
|
||||
case "passwordChange":
|
||||
return ActivityChangePassword
|
||||
case "passwordReset":
|
||||
return ActivityResetPassword
|
||||
case "inviteCreated":
|
||||
return ActivityCreateInvite
|
||||
case "inviteDeleted":
|
||||
return ActivityDeleteInvite
|
||||
}
|
||||
return ActivityUnknown
|
||||
}
|
||||
|
||||
// andField appends to the existing query if not nil, and otherwise creates a new one.
|
||||
func andField(q *badgerhold.Query, field string) *badgerhold.Criterion {
|
||||
if q == nil {
|
||||
return badgerhold.Where(field)
|
||||
}
|
||||
return q.And(field)
|
||||
}
|
||||
|
||||
// AsDBQuery returns a mutated "query" filtering for the conditions in "q".
|
||||
func (q QueryDTO) AsDBQuery(query *badgerhold.Query) *badgerhold.Query {
|
||||
// Special case for activity type:
|
||||
// In the app, there isn't an "activity:<fieldname>" query, but rather "<~fieldname>:true/false" queries.
|
||||
// For other API consumers, we also handle the former later.
|
||||
activityType := activityTypeGetterNameToType(q.Field)
|
||||
if activityType != ActivityUnknown {
|
||||
criterion := andField(query, "Type")
|
||||
if q.Operator != EqualOperator {
|
||||
panic(fmt.Errorf("impossible operator for activity type: %v", q.Operator))
|
||||
}
|
||||
if q.Value.(bool) == true {
|
||||
query = criterion.Eq(activityType)
|
||||
} else {
|
||||
query = criterion.Ne(activityType)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
fieldName := activityDTONameToField(q.Field)
|
||||
// Fail if unrecognized, or recognized as time (we handle this with DateAttempt.Compare separately).
|
||||
if fieldName == "unknown" || fieldName == "Time" {
|
||||
// Caller is expected to fall back to ActivityDBQueryFromSpecialField after this.
|
||||
return nil
|
||||
}
|
||||
criterion := andField(query, fieldName)
|
||||
|
||||
switch q.Operator {
|
||||
case LesserOperator:
|
||||
query = criterion.Lt(q.Value)
|
||||
case EqualOperator:
|
||||
query = criterion.Eq(q.Value)
|
||||
case GreaterOperator:
|
||||
query = criterion.Gt(q.Value)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// ActivityMatchesSearchAsDBBaseQuery returns a base query (which you should then apply other mutations to) matching the search "term" to Activities by searching all fields. Does not search the generated title like the web app.
|
||||
func ActivityMatchesSearchAsDBBaseQuery(terms []string) *badgerhold.Query {
|
||||
var baseQuery *badgerhold.Query = nil
|
||||
// I don't believe you can just do Where("*"), so instead run for each field.
|
||||
// FIXME: Match username and source_username and source_type and type
|
||||
for _, fieldName := range []string{"ID", "UserID", "Source", "InviteCode", "Value", "IP"} {
|
||||
criterion := badgerhold.Where(fieldName)
|
||||
// No case-insentive Contains method, so we use MatchFunc instead
|
||||
f := criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
|
||||
field := ra.Field()
|
||||
// _, ok := field.(string)
|
||||
// if !ok {
|
||||
// return false, fmt.Errorf("field not string: %s", fieldName)
|
||||
// }
|
||||
lower := strings.ToLower(field.(string))
|
||||
for _, term := range terms {
|
||||
if strings.Contains(lower, term) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if baseQuery == nil {
|
||||
baseQuery = f
|
||||
} else {
|
||||
baseQuery = baseQuery.Or(f)
|
||||
}
|
||||
}
|
||||
|
||||
return baseQuery
|
||||
}
|
||||
|
||||
func (act Activity) SourceIsUser() bool {
|
||||
return (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != ""
|
||||
}
|
||||
|
||||
func (act Activity) MustGetUsername(jf *mediabrowser.MediaBrowser) string {
|
||||
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
|
||||
return act.Value
|
||||
}
|
||||
if act.UserID == "" {
|
||||
return ""
|
||||
}
|
||||
// Don't care abt errors, user.Name will be blank in that case anyway
|
||||
user, _ := jf.UserByID(act.UserID, false)
|
||||
return user.Name
|
||||
}
|
||||
|
||||
func (act Activity) MustGetSourceUsername(jf *mediabrowser.MediaBrowser) string {
|
||||
if !act.SourceIsUser() {
|
||||
return ""
|
||||
}
|
||||
// Don't care abt errors, user.Name will be blank in that case anyway
|
||||
user, _ := jf.UserByID(act.Source, false)
|
||||
return user.Name
|
||||
}
|
||||
|
||||
func ActivityDBQueryFromSpecialField(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query {
|
||||
switch q.Field {
|
||||
case "mentionedUsers":
|
||||
query = matchMentionedUsersAsQuery(jf, query, q)
|
||||
case "actor":
|
||||
query = matchActorAsQuery(jf, query, q)
|
||||
case "referrer":
|
||||
query = matchReferrerAsQuery(jf, query, q)
|
||||
case "time":
|
||||
query = matchTimeAsQuery(query, q)
|
||||
default:
|
||||
panic(fmt.Errorf("unknown activity query field %s", q.Field))
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// matchMentionedUsersAsQuery is a custom match function for the "mentionedUsers" getter/query type.
|
||||
func matchMentionedUsersAsQuery(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query {
|
||||
criterion := andField(query, "UserID")
|
||||
query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
|
||||
act := ra.Record().(*Activity)
|
||||
usernames := act.MustGetUsername(jf) + " " + act.MustGetSourceUsername(jf)
|
||||
return strings.Contains(strings.ToLower(usernames), strings.ToLower(q.Value.(string))), nil
|
||||
})
|
||||
return query
|
||||
}
|
||||
|
||||
// matchActorAsQuery is a custom match function for the "actor" getter/query type.
|
||||
func matchActorAsQuery(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query {
|
||||
criterion := andField(query, "SourceType")
|
||||
query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
|
||||
act := ra.Record().(*Activity)
|
||||
matchString := activitySourceToString(act.SourceType)
|
||||
if act.SourceType == ActivityAdmin || act.SourceType == ActivityUser && act.SourceIsUser() {
|
||||
matchString += " " + act.MustGetSourceUsername(jf)
|
||||
}
|
||||
return strings.Contains(strings.ToLower(matchString), strings.ToLower(q.Value.(string))), nil
|
||||
})
|
||||
return query
|
||||
}
|
||||
|
||||
// matchReferrerAsQuery is a custom match function for the "referrer" getter/query type.
|
||||
func matchReferrerAsQuery(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query {
|
||||
criterion := andField(query, "Type")
|
||||
query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
|
||||
act := ra.Record().(*Activity)
|
||||
if act.Type != ActivityCreation || act.SourceType != ActivityUser || !act.SourceIsUser() {
|
||||
return false, nil
|
||||
}
|
||||
sourceUsername := act.MustGetSourceUsername(jf)
|
||||
if q.Class == BoolQuery {
|
||||
val := sourceUsername != ""
|
||||
if q.Value.(bool) == false {
|
||||
val = !val
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
return strings.Contains(strings.ToLower(sourceUsername), strings.ToLower(q.Value.(string))), nil
|
||||
})
|
||||
return query
|
||||
}
|
||||
|
||||
// mathcTimeAsQuery is a custom match function for the "time" getter/query type. Roughly matches the same way as the web app, and in usercache.go.
|
||||
func matchTimeAsQuery(query *badgerhold.Query, q QueryDTO) *badgerhold.Query {
|
||||
operator := Equal
|
||||
switch q.Operator {
|
||||
case LesserOperator:
|
||||
operator = Lesser
|
||||
case EqualOperator:
|
||||
operator = Equal
|
||||
case GreaterOperator:
|
||||
operator = Greater
|
||||
}
|
||||
criterion := andField(query, "Time")
|
||||
query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
|
||||
return q.Value.(DateAttempt).CompareWithOperator(ra.Field().(time.Time), operator), nil
|
||||
})
|
||||
return query
|
||||
}
|
||||
220
api-activities.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
func activityTypeToString(v ActivityType) string {
|
||||
switch v {
|
||||
case ActivityCreation:
|
||||
return "creation"
|
||||
case ActivityDeletion:
|
||||
return "deletion"
|
||||
case ActivityDisabled:
|
||||
return "disabled"
|
||||
case ActivityEnabled:
|
||||
return "enabled"
|
||||
case ActivityContactLinked:
|
||||
return "contactLinked"
|
||||
case ActivityContactUnlinked:
|
||||
return "contactUnlinked"
|
||||
case ActivityChangePassword:
|
||||
return "changePassword"
|
||||
case ActivityResetPassword:
|
||||
return "resetPassword"
|
||||
case ActivityCreateInvite:
|
||||
return "createInvite"
|
||||
case ActivityDeleteInvite:
|
||||
return "deleteInvite"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func stringToActivityType(v string) ActivityType {
|
||||
switch v {
|
||||
case "creation":
|
||||
return ActivityCreation
|
||||
case "deletion":
|
||||
return ActivityDeletion
|
||||
case "disabled":
|
||||
return ActivityDisabled
|
||||
case "enabled":
|
||||
return ActivityEnabled
|
||||
case "contactLinked":
|
||||
return ActivityContactLinked
|
||||
case "contactUnlinked":
|
||||
return ActivityContactUnlinked
|
||||
case "changePassword":
|
||||
return ActivityChangePassword
|
||||
case "resetPassword":
|
||||
return ActivityResetPassword
|
||||
case "createInvite":
|
||||
return ActivityCreateInvite
|
||||
case "deleteInvite":
|
||||
return ActivityDeleteInvite
|
||||
}
|
||||
return ActivityUnknown
|
||||
}
|
||||
|
||||
func stringToActivitySource(v string) ActivitySource {
|
||||
switch v {
|
||||
case "user":
|
||||
return ActivityUser
|
||||
case "admin":
|
||||
return ActivityAdmin
|
||||
case "anon":
|
||||
return ActivityAnon
|
||||
case "daemon":
|
||||
return ActivityDaemon
|
||||
}
|
||||
return ActivityAnon
|
||||
}
|
||||
|
||||
func activitySourceToString(v ActivitySource) string {
|
||||
switch v {
|
||||
case ActivityUser:
|
||||
return "user"
|
||||
case ActivityAdmin:
|
||||
return "admin"
|
||||
case ActivityAnon:
|
||||
return "anon"
|
||||
case ActivityDaemon:
|
||||
return "daemon"
|
||||
}
|
||||
return "anon"
|
||||
}
|
||||
|
||||
// generateActivitiesQuery generates a badgerhold query from QueryDTOs and search terms, which can then be searched, counted, or whatever you want.
|
||||
func (app *appContext) generateActivitiesQuery(req ServerFilterReqDTO) *badgerhold.Query {
|
||||
|
||||
var query *badgerhold.Query
|
||||
if len(req.SearchTerms) != 0 {
|
||||
query = ActivityMatchesSearchAsDBBaseQuery(req.SearchTerms)
|
||||
} else {
|
||||
query = nil
|
||||
}
|
||||
|
||||
for _, q := range req.Queries {
|
||||
nq := q.AsDBQuery(query)
|
||||
if nq == nil {
|
||||
nq = ActivityDBQueryFromSpecialField(app.jf.MediaBrowser, query, q)
|
||||
}
|
||||
query = nq
|
||||
}
|
||||
|
||||
if query == nil {
|
||||
query = &badgerhold.Query{}
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// @Summary Get the requested set of activities, Paginated, filtered and sorted. Is a POST because of some issues I was having, ideally should be a GET.
|
||||
// @Produce json
|
||||
// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search parameters"
|
||||
// @Success 200 {object} GetActivitiesRespDTO
|
||||
// @Router /activity [post]
|
||||
// @Security Bearer
|
||||
// @tags Activity,Statistics
|
||||
func (app *appContext) GetActivities(gc *gin.Context) {
|
||||
req := ServerSearchReqDTO{}
|
||||
gc.BindJSON(&req)
|
||||
if req.SortByField == "" {
|
||||
req.SortByField = USER_DEFAULT_SORT_FIELD
|
||||
} else {
|
||||
req.SortByField = activityDTONameToField(req.SortByField)
|
||||
}
|
||||
|
||||
query := app.generateActivitiesQuery(req.ServerFilterReqDTO)
|
||||
|
||||
query = query.SortBy(req.SortByField)
|
||||
if !req.Ascending {
|
||||
query = query.Reverse()
|
||||
}
|
||||
|
||||
query = query.Skip(req.Page * req.Limit).Limit(req.Limit)
|
||||
|
||||
var results []Activity
|
||||
err := app.storage.db.Find(&results, query)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedDBReadActivities, err)
|
||||
}
|
||||
|
||||
resp := GetActivitiesRespDTO{
|
||||
Activities: make([]ActivityDTO, len(results)),
|
||||
}
|
||||
resp.LastPage = len(results) != req.Limit
|
||||
for i, act := range results {
|
||||
resp.Activities[i] = ActivityDTO{
|
||||
ID: act.ID,
|
||||
Type: activityTypeToString(act.Type),
|
||||
UserID: act.UserID,
|
||||
SourceType: activitySourceToString(act.SourceType),
|
||||
Source: act.Source,
|
||||
InviteCode: act.InviteCode,
|
||||
Value: act.Value,
|
||||
Time: act.Time.Unix(),
|
||||
IP: act.IP,
|
||||
Username: act.MustGetUsername(app.jf.MediaBrowser),
|
||||
SourceUsername: act.MustGetSourceUsername(app.jf.MediaBrowser),
|
||||
}
|
||||
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
|
||||
// Username would've been in here, clear it to avoid confusion to the consumer
|
||||
resp.Activities[i].Value = ""
|
||||
}
|
||||
}
|
||||
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
// @Summary Delete the activity with the given ID. No-op if non-existent, always succeeds.
|
||||
// @Produce json
|
||||
// @Param id path string true "ID of activity to delete"
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Router /activity/{id} [delete]
|
||||
// @Security Bearer
|
||||
// @tags Activity
|
||||
func (app *appContext) DeleteActivity(gc *gin.Context) {
|
||||
app.storage.DeleteActivityKey(gc.Param("id"))
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Returns the total number of activities stored in the database.
|
||||
// @Produce json
|
||||
// @Success 200 {object} PageCountDTO
|
||||
// @Router /activity/count [get]
|
||||
// @Security Bearer
|
||||
// @tags Activity,Statistics
|
||||
func (app *appContext) GetActivityCount(gc *gin.Context) {
|
||||
resp := PageCountDTO{}
|
||||
var err error
|
||||
resp.Count, err = app.storage.db.Count(&Activity{}, &badgerhold.Query{})
|
||||
if err != nil {
|
||||
resp.Count = 0
|
||||
}
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
// @Summary Returns the total number of activities matching the given filtering. Fails silently.
|
||||
// @Produce json
|
||||
// @Param ServerFilterReqDTO body ServerFilterReqDTO true "search parameters"
|
||||
// @Success 200 {object} PageCountDTO
|
||||
// @Router /activity/count [post]
|
||||
// @Security Bearer
|
||||
// @tags Activity,Statistics
|
||||
func (app *appContext) GetFilteredActivityCount(gc *gin.Context) {
|
||||
resp := PageCountDTO{}
|
||||
req := ServerFilterReqDTO{}
|
||||
gc.BindJSON(&req)
|
||||
|
||||
query := app.generateActivitiesQuery(req)
|
||||
|
||||
var err error
|
||||
resp.Count, err = app.storage.db.Count(&Activity{}, query)
|
||||
if err != nil {
|
||||
// app.err.Printf(lm.FailedDBReadActivities, err)
|
||||
resp.Count = 0
|
||||
}
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
124
api-backups.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
)
|
||||
|
||||
// @Summary Creates a backup of the database.
|
||||
// @Router /backups [post]
|
||||
// @Success 200 {object} CreateBackupDTO
|
||||
// @Security Bearer
|
||||
// @tags Backups
|
||||
func (app *appContext) CreateBackup(gc *gin.Context) {
|
||||
backup := app.makeBackup()
|
||||
gc.JSON(200, backup)
|
||||
}
|
||||
|
||||
// @Summary Download a specific backup file. Requires auth, so can't be accessed plainly in the browser.
|
||||
// @Param fname path string true "backup filename"
|
||||
// @Router /backups/{fname} [get]
|
||||
// @Produce octet-stream
|
||||
// @Produce json
|
||||
// @Success 200 {body} file
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @Security Bearer
|
||||
// @tags Backups
|
||||
func (app *appContext) GetBackup(gc *gin.Context) {
|
||||
escapedFName := gc.Param("fname")
|
||||
fname, err := url.QueryUnescape(escapedFName)
|
||||
if err != nil {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
// Hopefully this is enough to ensure the path isn't malicious. Hidden behind bearer auth anyway so shouldn't matter too much I guess.
|
||||
b := Backup{}
|
||||
err = b.FromString(fname)
|
||||
if err != nil || b.Date.IsZero() {
|
||||
app.debug.Printf(lm.IgnoreInvalidFilename, fname, err)
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
path := app.config.Section("backups").Key("path").String()
|
||||
fullpath := filepath.Join(path, fname)
|
||||
gc.FileAttachment(fullpath, fname)
|
||||
}
|
||||
|
||||
// @Summary Get a list of backups.
|
||||
// @Router /backups [get]
|
||||
// @Produce json
|
||||
// @Success 200 {object} GetBackupsDTO
|
||||
// @Security Bearer
|
||||
// @tags Backups
|
||||
func (app *appContext) GetBackups(gc *gin.Context) {
|
||||
path := app.config.Section("backups").Key("path").String()
|
||||
backups := app.getBackups()
|
||||
sort.Sort(backups)
|
||||
resp := GetBackupsDTO{}
|
||||
resp.Backups = make([]CreateBackupDTO, backups.count)
|
||||
|
||||
for i, item := range backups.files[:backups.count] {
|
||||
resp.Backups[i].Name = item.Name()
|
||||
fullpath := filepath.Join(path, item.Name())
|
||||
resp.Backups[i].Path = fullpath
|
||||
resp.Backups[i].Date = backups.info[i].Date.Unix()
|
||||
resp.Backups[i].Commit = backups.info[i].Commit
|
||||
fstat, err := os.Stat(fullpath)
|
||||
if err == nil {
|
||||
resp.Backups[i].Size = fileSize(fstat.Size())
|
||||
}
|
||||
}
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
// @Summary Restore a backup file stored locally to the server.
|
||||
// @Param fname path string true "backup filename"
|
||||
// @Router /backups/restore/{fname} [post]
|
||||
// @Produce json
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @Security Bearer
|
||||
// @tags Backups
|
||||
func (app *appContext) RestoreLocalBackup(gc *gin.Context) {
|
||||
fname := gc.Param("fname")
|
||||
// Hopefully this is enough to ensure the path isn't malicious. Hidden behind bearer auth anyway so shouldn't matter too much I guess.
|
||||
b := Backup{}
|
||||
err := b.FromString(fname)
|
||||
if err != nil || b.Date.IsZero() {
|
||||
app.debug.Printf(lm.IgnoreInvalidFilename, fname, err)
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
path := app.config.Section("backups").Key("path").String()
|
||||
fullpath := filepath.Join(path, fname)
|
||||
LOADBAK = fullpath
|
||||
app.restart(gc)
|
||||
}
|
||||
|
||||
// @Summary Restore a backup file uploaded by the user.
|
||||
// @Param file formData file true ".bak file"
|
||||
// @Router /backups/restore [post]
|
||||
// @Produce json
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @Security Bearer
|
||||
// @tags Backups
|
||||
func (app *appContext) RestoreBackup(gc *gin.Context) {
|
||||
file, err := gc.FormFile("backups-file")
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUpload, err)
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
app.debug.Printf(lm.GetUpload, file.Filename)
|
||||
path := app.config.Section("backups").Key("path").String()
|
||||
b := Backup{Upload: true}
|
||||
fullpath := filepath.Join(path, b.String())
|
||||
gc.SaveUploadedFile(file, fullpath)
|
||||
app.debug.Printf(lm.Write, fullpath)
|
||||
LOADBAK = fullpath
|
||||
app.restart(gc)
|
||||
}
|
||||
705
api-invites.go
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -8,111 +9,80 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
"github.com/itchyny/timefmt-go"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
CAPTCHA_VALIDITY = 20 * 60 // Seconds
|
||||
)
|
||||
|
||||
// GenerateInviteCode generates an invite code in the correct format.
|
||||
func GenerateInviteCode() string {
|
||||
// make sure code doesn't begin with number
|
||||
inviteCode := shortuuid.New()
|
||||
_, err := strconv.Atoi(string(inviteCode[0]))
|
||||
for err == nil {
|
||||
inviteCode = shortuuid.New()
|
||||
_, err = strconv.Atoi(string(inviteCode[0]))
|
||||
}
|
||||
return inviteCode
|
||||
}
|
||||
|
||||
// checkInvites performs general housekeeping on invites, i.e. deleting expired ones and cleaning captcha data.
|
||||
func (app *appContext) checkInvites() {
|
||||
currentTime := time.Now()
|
||||
app.storage.loadInvites()
|
||||
changed := false
|
||||
for code, data := range app.storage.GetInvites() {
|
||||
for _, data := range app.storage.GetInvites() {
|
||||
captchas := data.Captchas
|
||||
captchasExpired := false
|
||||
for key, capt := range data.Captchas {
|
||||
if time.Now().After(capt.Generated.Add(CAPTCHA_VALIDITY * time.Second)) {
|
||||
delete(captchas, key)
|
||||
captchasExpired = true
|
||||
}
|
||||
}
|
||||
if captchasExpired {
|
||||
data.Captchas = captchas
|
||||
app.storage.SetInvitesKey(data.Code, data)
|
||||
}
|
||||
|
||||
if data.IsReferral && (!data.UseReferralExpiry || data.ReferrerJellyfinID == "") {
|
||||
continue
|
||||
}
|
||||
expiry := data.ValidTill
|
||||
if !currentTime.After(expiry) {
|
||||
continue
|
||||
}
|
||||
app.debug.Printf("Housekeeping: Deleting old invite %s", code)
|
||||
notify := data.Notify
|
||||
if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
|
||||
app.debug.Printf("%s: Expiry notification", code)
|
||||
var wait sync.WaitGroup
|
||||
for address, settings := range notify {
|
||||
if !settings["notify-expiry"] {
|
||||
continue
|
||||
}
|
||||
wait.Add(1)
|
||||
go func(addr string) {
|
||||
defer wait.Done()
|
||||
msg, err := app.email.constructExpiry(code, data, app, false)
|
||||
if err != nil {
|
||||
app.err.Printf("%s: Failed to construct expiry notification: %v", code, err)
|
||||
} else {
|
||||
// Check whether notify "address" is an email address of Jellyfin ID
|
||||
if strings.Contains(addr, "@") {
|
||||
err = app.email.send(msg, addr)
|
||||
} else {
|
||||
err = app.sendByID(msg, addr)
|
||||
}
|
||||
if err != nil {
|
||||
app.err.Printf("%s: Failed to send expiry notification: %v", code, err)
|
||||
} else {
|
||||
app.info.Printf("Sent expiry notification to %s", addr)
|
||||
}
|
||||
}
|
||||
}(address)
|
||||
}
|
||||
wait.Wait()
|
||||
}
|
||||
changed = true
|
||||
app.storage.DeleteInvitesKey(code)
|
||||
}
|
||||
if changed {
|
||||
app.storage.storeInvites()
|
||||
app.deleteExpiredInvite(data)
|
||||
}
|
||||
}
|
||||
|
||||
// checkInvite checks the validity of a specific invite, optionally removing it if invalid(ated).
|
||||
func (app *appContext) checkInvite(code string, used bool, username string) bool {
|
||||
currentTime := time.Now()
|
||||
app.storage.loadInvites()
|
||||
changed := false
|
||||
inv, match := app.storage.GetInvitesKey(code)
|
||||
if !match {
|
||||
return false
|
||||
}
|
||||
expiry := inv.ValidTill
|
||||
if currentTime.After(expiry) {
|
||||
app.debug.Printf("Housekeeping: Deleting old invite %s", code)
|
||||
notify := inv.Notify
|
||||
if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
|
||||
app.debug.Printf("%s: Expiry notification", code)
|
||||
var wait sync.WaitGroup
|
||||
for address, settings := range notify {
|
||||
if !settings["notify-expiry"] {
|
||||
continue
|
||||
}
|
||||
wait.Add(1)
|
||||
go func(addr string) {
|
||||
defer wait.Done()
|
||||
msg, err := app.email.constructExpiry(code, inv, app, false)
|
||||
if err != nil {
|
||||
app.err.Printf("%s: Failed to construct expiry notification: %v", code, err)
|
||||
} else {
|
||||
// Check whether notify "address" is an email address of Jellyfin ID
|
||||
if strings.Contains(addr, "@") {
|
||||
err = app.email.send(msg, addr)
|
||||
} else {
|
||||
err = app.sendByID(msg, addr)
|
||||
}
|
||||
if err != nil {
|
||||
app.err.Printf("%s: Failed to send expiry notification: %v", code, err)
|
||||
} else {
|
||||
app.info.Printf("Sent expiry notification to %s", addr)
|
||||
}
|
||||
}
|
||||
}(address)
|
||||
}
|
||||
wait.Wait()
|
||||
}
|
||||
changed = true
|
||||
app.deleteExpiredInvite(inv)
|
||||
match = false
|
||||
app.storage.DeleteInvitesKey(code)
|
||||
} else if used {
|
||||
changed = true
|
||||
del := false
|
||||
newInv := inv
|
||||
if newInv.RemainingUses == 1 {
|
||||
del = true
|
||||
app.storage.DeleteInvitesKey(code)
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityDeleteInvite,
|
||||
SourceType: ActivityDaemon,
|
||||
InviteCode: code,
|
||||
Value: inv.Label,
|
||||
Time: time.Now(),
|
||||
}, nil, false)
|
||||
} else if newInv.RemainingUses != 0 {
|
||||
// 0 means infinite i guess?
|
||||
newInv.RemainingUses--
|
||||
@@ -122,12 +92,249 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
|
||||
app.storage.SetInvitesKey(code, newInv)
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
app.storage.storeInvites()
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
func (app *appContext) deleteExpiredInvite(data Invite) {
|
||||
app.debug.Printf(lm.DeleteOldInvite, data.Code)
|
||||
|
||||
// Disable referrals for the user if UseReferralExpiry is enabled, so no new ones are made.
|
||||
if data.IsReferral && data.UseReferralExpiry && data.ReferrerJellyfinID != "" {
|
||||
user, ok := app.storage.GetEmailsKey(data.ReferrerJellyfinID)
|
||||
if ok {
|
||||
user.ReferralTemplateKey = ""
|
||||
app.storage.SetEmailsKey(data.ReferrerJellyfinID, user)
|
||||
app.InvalidateWebUserCache()
|
||||
}
|
||||
}
|
||||
wait := app.sendAdminExpiryNotification(data)
|
||||
app.storage.DeleteInvitesKey(data.Code)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityDeleteInvite,
|
||||
SourceType: ActivityDaemon,
|
||||
InviteCode: data.Code,
|
||||
Value: data.Label,
|
||||
Time: time.Now(),
|
||||
}, nil, false)
|
||||
|
||||
if wait != nil {
|
||||
wait.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup {
|
||||
notify := data.Notify
|
||||
if !emailEnabled || !app.config.Section("notifications").Key("enabled").MustBool(false) || len(notify) == 0 {
|
||||
return nil
|
||||
}
|
||||
var wait sync.WaitGroup
|
||||
for address, settings := range notify {
|
||||
if !settings["notify-expiry"] {
|
||||
continue
|
||||
}
|
||||
wait.Add(1)
|
||||
go func(addr string) {
|
||||
defer wait.Done()
|
||||
msg, err := app.email.constructExpiry(data, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructExpiryAdmin, data.Code, err)
|
||||
} else {
|
||||
// Check whether notify "address" is an email address or Jellyfin ID
|
||||
if strings.Contains(addr, "@") {
|
||||
err = app.email.send(msg, addr)
|
||||
} else {
|
||||
err = app.sendByID(msg, addr)
|
||||
}
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedSendExpiryAdmin, data.Code, addr, err)
|
||||
} else {
|
||||
app.info.Printf(lm.SentExpiryAdmin, data.Code, addr)
|
||||
}
|
||||
}
|
||||
}(address)
|
||||
}
|
||||
return &wait
|
||||
}
|
||||
|
||||
// @Summary Send an existing invite to an email address or discord user.
|
||||
// @Produce json
|
||||
// @Param SendInviteDTO body SendInviteDTO true "Email address or Discord username"
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @Router /invites/send [post]
|
||||
// @Security Bearer
|
||||
// @tags Invites
|
||||
func (app *appContext) SendInvite(gc *gin.Context) {
|
||||
var req SendInviteDTO
|
||||
gc.BindJSON(&req)
|
||||
inv, ok := app.storage.GetInvitesKey(req.Invite)
|
||||
if !ok {
|
||||
app.err.Printf(lm.FailedGetInvite, req.Invite, lm.NotFound)
|
||||
respond(500, "Invite not found", gc)
|
||||
return
|
||||
}
|
||||
err := app.sendInvite(req.sendInviteDTO, &inv)
|
||||
// Even if failed, some error info might have been stored in the invite.
|
||||
app.storage.SetInvitesKey(req.Invite, inv)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedSendInviteMessage, req.Invite, req.SendTo, err)
|
||||
respond(500, err.Error(), gc)
|
||||
return
|
||||
}
|
||||
app.info.Printf(lm.SentInviteMessage, req.Invite, req.SendTo)
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Edit an existing invite. Not all fields are modifiable.
|
||||
// @Produce json
|
||||
// @Param EditableInviteDTO body EditableInviteDTO true "Email address or Discord username"
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @Failure 400 {object} stringResponse
|
||||
// @Router /invites/edit [patch]
|
||||
// @Security Bearer
|
||||
// @tags Invites
|
||||
func (app *appContext) EditInvite(gc *gin.Context) {
|
||||
var req EditableInviteDTO
|
||||
gc.BindJSON(&req)
|
||||
inv, ok := app.storage.GetInvitesKey(req.Code)
|
||||
if !ok {
|
||||
msg := fmt.Sprintf(lm.InvalidInviteCode, req.Code)
|
||||
app.err.Println(msg)
|
||||
respond(400, msg, gc)
|
||||
return
|
||||
}
|
||||
changed := false
|
||||
|
||||
if req.NotifyCreation != nil || req.NotifyExpiry != nil {
|
||||
setNotify := map[string]bool{}
|
||||
if req.NotifyExpiry != nil {
|
||||
setNotify["notify-expiry"] = *req.NotifyExpiry
|
||||
}
|
||||
if req.NotifyCreation != nil {
|
||||
setNotify["notify-creation"] = *req.NotifyCreation
|
||||
}
|
||||
ch, ok := app.SetNotify(&inv, setNotify, gc)
|
||||
changed = changed || ch
|
||||
if ch && !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Profile != nil {
|
||||
ch, ok := app.SetProfile(&inv, *req.Profile, gc)
|
||||
changed = changed || ch
|
||||
if ch && !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Label != nil {
|
||||
*req.Label = strings.TrimSpace(*req.Label)
|
||||
changed = changed || (*req.Label != inv.Label)
|
||||
inv.Label = *req.Label
|
||||
}
|
||||
if req.UserLabel != nil {
|
||||
*req.UserLabel = strings.TrimSpace(*req.UserLabel)
|
||||
changed = changed || (*req.UserLabel != inv.UserLabel)
|
||||
inv.UserLabel = *req.UserLabel
|
||||
}
|
||||
if req.UserExpiry != nil {
|
||||
changed = changed || (*req.UserExpiry != inv.UserExpiry)
|
||||
inv.UserExpiry = *req.UserExpiry
|
||||
if !inv.UserExpiry {
|
||||
inv.UserMonths = 0
|
||||
inv.UserDays = 0
|
||||
inv.UserHours = 0
|
||||
inv.UserMinutes = 0
|
||||
}
|
||||
}
|
||||
if req.UserMonths != nil || req.UserDays != nil || req.UserHours != nil || req.UserMinutes != nil {
|
||||
if inv.UserMonths == 0 &&
|
||||
inv.UserDays == 0 &&
|
||||
inv.UserHours == 0 &&
|
||||
inv.UserMinutes == 0 {
|
||||
changed = changed || (inv.UserExpiry != false)
|
||||
inv.UserExpiry = false
|
||||
}
|
||||
if req.UserMonths != nil {
|
||||
changed = changed || (*req.UserMonths != inv.UserMonths)
|
||||
inv.UserMonths = *req.UserMonths
|
||||
}
|
||||
if req.UserDays != nil {
|
||||
changed = changed || (*req.UserDays != inv.UserDays)
|
||||
inv.UserDays = *req.UserDays
|
||||
}
|
||||
if req.UserHours != nil {
|
||||
changed = changed || (*req.UserHours != inv.UserHours)
|
||||
inv.UserHours = *req.UserHours
|
||||
}
|
||||
if req.UserMinutes != nil {
|
||||
changed = changed || (*req.UserMinutes != inv.UserMinutes)
|
||||
inv.UserMinutes = *req.UserMinutes
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
app.storage.SetInvitesKey(inv.Code, inv)
|
||||
}
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// sendInvite attempts to send an invite to the given email address or discord username.
|
||||
func (app *appContext) sendInvite(req sendInviteDTO, invite *Invite) (err error) {
|
||||
if !(app.config.Section("invite_emails").Key("enabled").MustBool(false)) {
|
||||
// app.err.Printf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, errors.New(lm.InviteMessagesDisabled))
|
||||
err = errors.New(lm.InviteMessagesDisabled)
|
||||
return err
|
||||
}
|
||||
discord := ""
|
||||
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
|
||||
users := app.discord.GetUsers(req.SendTo)
|
||||
if len(users) == 0 {
|
||||
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
|
||||
Address: req.SendTo,
|
||||
Reason: NoUser,
|
||||
})
|
||||
err = fmt.Errorf(lm.InvalidAddress, req.SendTo)
|
||||
return err
|
||||
} else if len(users) > 1 {
|
||||
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
|
||||
Address: req.SendTo,
|
||||
Reason: MultiUser,
|
||||
})
|
||||
err = fmt.Errorf(lm.InvalidAddress, req.SendTo)
|
||||
return err
|
||||
}
|
||||
discord = users[0].User.ID
|
||||
}
|
||||
var msg *Message
|
||||
msg, err = app.email.constructInvite(invite, false)
|
||||
if err != nil {
|
||||
// Slight misuse of the template
|
||||
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
|
||||
Address: req.SendTo,
|
||||
Reason: CheckLogs,
|
||||
})
|
||||
// app.err.Printf(lm.FailedConstructInviteMessage, req.SendTo, err)
|
||||
return err
|
||||
}
|
||||
if discord != "" {
|
||||
err = app.discord.SendDM(msg, discord)
|
||||
} else {
|
||||
err = app.email.send(msg, req.SendTo)
|
||||
}
|
||||
if err != nil {
|
||||
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
|
||||
Address: req.SendTo,
|
||||
Reason: CheckLogs,
|
||||
})
|
||||
return err
|
||||
// app.err.Println(invite.SendTo)
|
||||
}
|
||||
invite.SentTo.Success = append(invite.SentTo.Success, req.SendTo)
|
||||
return err
|
||||
}
|
||||
|
||||
// @Summary Create a new invite.
|
||||
// @Produce json
|
||||
// @Param generateInviteDTO body generateInviteDTO true "New invite request object"
|
||||
@@ -137,23 +344,19 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
|
||||
// @tags Invites
|
||||
func (app *appContext) GenerateInvite(gc *gin.Context) {
|
||||
var req generateInviteDTO
|
||||
app.debug.Println("Generating new invite")
|
||||
app.storage.loadInvites()
|
||||
app.debug.Println(lm.GenerateInvite)
|
||||
gc.BindJSON(&req)
|
||||
currentTime := time.Now()
|
||||
validTill := currentTime.AddDate(0, req.Months, req.Days)
|
||||
validTill = validTill.Add(time.Hour*time.Duration(req.Hours) + time.Minute*time.Duration(req.Minutes))
|
||||
// make sure code doesn't begin with number
|
||||
inviteCode := shortuuid.New()
|
||||
_, err := strconv.Atoi(string(inviteCode[0]))
|
||||
for err == nil {
|
||||
inviteCode = shortuuid.New()
|
||||
_, err = strconv.Atoi(string(inviteCode[0]))
|
||||
}
|
||||
var invite Invite
|
||||
invite.Code = GenerateInviteCode()
|
||||
if req.Label != "" {
|
||||
invite.Label = req.Label
|
||||
}
|
||||
if req.UserLabel != "" {
|
||||
invite.UserLabel = req.UserLabel
|
||||
}
|
||||
invite.Created = currentTime
|
||||
if req.MultipleUses {
|
||||
if req.NoLimit {
|
||||
@@ -172,87 +375,112 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
|
||||
invite.UserMinutes = req.UserMinutes
|
||||
}
|
||||
invite.ValidTill = validTill
|
||||
if req.SendTo != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
|
||||
addressValid := false
|
||||
discord := ""
|
||||
app.debug.Printf("%s: Sending invite message", inviteCode)
|
||||
if discordEnabled && !strings.Contains(req.SendTo, "@") {
|
||||
users := app.discord.GetUsers(req.SendTo)
|
||||
if len(users) == 0 {
|
||||
invite.SendTo = fmt.Sprintf("Failed: User not found: \"%s\"", req.SendTo)
|
||||
} else if len(users) > 1 {
|
||||
invite.SendTo = fmt.Sprintf("Failed: Multiple users found: \"%s\"", req.SendTo)
|
||||
} else {
|
||||
invite.SendTo = req.SendTo
|
||||
addressValid = true
|
||||
discord = users[0].User.ID
|
||||
}
|
||||
} else if emailEnabled {
|
||||
addressValid = true
|
||||
invite.SendTo = req.SendTo
|
||||
}
|
||||
if addressValid {
|
||||
msg, err := app.email.constructInvite(inviteCode, invite, app, false)
|
||||
if err != nil {
|
||||
invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo)
|
||||
app.err.Printf("%s: Failed to construct invite message: %v", inviteCode, err)
|
||||
} else {
|
||||
var err error
|
||||
if discord != "" {
|
||||
err = app.discord.SendDM(msg, discord)
|
||||
} else {
|
||||
err = app.email.send(msg, req.SendTo)
|
||||
}
|
||||
if err != nil {
|
||||
invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo)
|
||||
app.err.Printf("%s: %s: %v", inviteCode, invite.SendTo, err)
|
||||
} else {
|
||||
app.info.Printf("%s: Sent invite email to \"%s\"", inviteCode, req.SendTo)
|
||||
}
|
||||
}
|
||||
if req.SendTo != "" {
|
||||
err := app.sendInvite(req.sendInviteDTO, &invite)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, err)
|
||||
} else {
|
||||
app.info.Printf(lm.SentInviteMessage, invite.Code, req.SendTo)
|
||||
}
|
||||
}
|
||||
if req.Profile != "" {
|
||||
if _, ok := app.storage.profiles[req.Profile]; ok {
|
||||
if _, ok := app.storage.GetProfileKey(req.Profile); ok {
|
||||
invite.Profile = req.Profile
|
||||
} else {
|
||||
invite.Profile = "Default"
|
||||
}
|
||||
}
|
||||
app.storage.SetInvitesKey(inviteCode, invite)
|
||||
app.storage.storeInvites()
|
||||
app.storage.SetInvitesKey(invite.Code, invite)
|
||||
|
||||
// Record activity
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityCreateInvite,
|
||||
UserID: "",
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
InviteCode: invite.Code,
|
||||
Value: invite.Label,
|
||||
Time: time.Now(),
|
||||
}, gc, false)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Get the number of invites stored in the database.
|
||||
// @Produce json
|
||||
// @Success 200 {object} PageCountDTO
|
||||
// @Router /invites/count [get]
|
||||
// @Security Bearer
|
||||
// @tags Invites,Statistics
|
||||
func (app *appContext) GetInviteCount(gc *gin.Context) {
|
||||
resp := PageCountDTO{}
|
||||
var err error
|
||||
resp.Count, err = app.storage.db.Count(&Invite{}, badgerhold.Where("IsReferral").Eq(false))
|
||||
if err != nil {
|
||||
resp.Count = 0
|
||||
}
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
// @Summary Get the number of invites stored in the database that have been used (but are still valid).
|
||||
// @Produce json
|
||||
// @Success 200 {object} PageCountDTO
|
||||
// @Router /invites/count/used [get]
|
||||
// @Security Bearer
|
||||
// @tags Invites,Statistics
|
||||
func (app *appContext) GetInviteUsedCount(gc *gin.Context) {
|
||||
resp := PageCountDTO{}
|
||||
var err error
|
||||
resp.Count, err = app.storage.db.Count(&Invite{}, badgerhold.Where("IsReferral").Eq(false).And("UsedBy").MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
|
||||
field := ra.Field()
|
||||
switch usedBy := field.(type) {
|
||||
case [][]string:
|
||||
return len(usedBy) > 0, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}))
|
||||
if err != nil {
|
||||
resp.Count = 0
|
||||
}
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
// @Summary Get invites.
|
||||
// @Produce json
|
||||
// @Success 200 {object} getInvitesDTO
|
||||
// @Router /invites [get]
|
||||
// @Security Bearer
|
||||
// @tags Invites
|
||||
// @tags Invites,Statistics
|
||||
func (app *appContext) GetInvites(gc *gin.Context) {
|
||||
app.debug.Println("Invites requested")
|
||||
currentTime := time.Now()
|
||||
app.storage.loadInvites()
|
||||
// currentTime := time.Now()
|
||||
app.checkInvites()
|
||||
var invites []inviteDTO
|
||||
for code, inv := range app.storage.GetInvites() {
|
||||
_, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
|
||||
for _, inv := range app.storage.GetInvites() {
|
||||
if inv.IsReferral {
|
||||
continue
|
||||
}
|
||||
// years, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
|
||||
// months += years * 12
|
||||
invite := inviteDTO{
|
||||
Code: code,
|
||||
Months: months,
|
||||
Days: days,
|
||||
Hours: hours,
|
||||
Minutes: minutes,
|
||||
UserExpiry: inv.UserExpiry,
|
||||
UserMonths: inv.UserMonths,
|
||||
UserDays: inv.UserDays,
|
||||
UserHours: inv.UserHours,
|
||||
UserMinutes: inv.UserMinutes,
|
||||
Created: inv.Created.Unix(),
|
||||
Profile: inv.Profile,
|
||||
NoLimit: inv.NoLimit,
|
||||
Label: inv.Label,
|
||||
EditableInviteDTO: EditableInviteDTO{
|
||||
Code: inv.Code,
|
||||
Label: &inv.Label,
|
||||
UserLabel: &inv.UserLabel,
|
||||
Profile: &inv.Profile,
|
||||
UserExpiry: &inv.UserExpiry,
|
||||
UserMonths: &inv.UserMonths,
|
||||
UserDays: &inv.UserDays,
|
||||
UserHours: &inv.UserHours,
|
||||
UserMinutes: &inv.UserMinutes,
|
||||
},
|
||||
ValidTill: inv.ValidTill.Unix(),
|
||||
// Months: months,
|
||||
// Days: days,
|
||||
// Hours: hours,
|
||||
// Minutes: minutes,
|
||||
Created: inv.Created.Unix(),
|
||||
NoLimit: inv.NoLimit,
|
||||
}
|
||||
if len(inv.UsedBy) != 0 {
|
||||
invite.UsedBy = map[string]int64{}
|
||||
@@ -260,9 +488,9 @@ func (app *appContext) GetInvites(gc *gin.Context) {
|
||||
// These used to be stored formatted instead of as a unix timestamp.
|
||||
unix, err := strconv.ParseInt(pair[1], 10, 64)
|
||||
if err != nil {
|
||||
date, err := timefmt.Parse(pair[1], app.datePattern+" "+app.timePattern)
|
||||
date, err := timefmt.Parse(pair[1], datePattern+" "+timePattern)
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to parse usedBy time: %v", err)
|
||||
app.err.Printf(lm.FailedParseTime, err)
|
||||
}
|
||||
unix = date.Unix()
|
||||
}
|
||||
@@ -273,137 +501,86 @@ func (app *appContext) GetInvites(gc *gin.Context) {
|
||||
if inv.RemainingUses != 0 {
|
||||
invite.RemainingUses = inv.RemainingUses
|
||||
}
|
||||
if len(inv.SentTo.Success) != 0 || len(inv.SentTo.Failed) != 0 {
|
||||
invite.SentTo = inv.SentTo
|
||||
}
|
||||
if inv.SendTo != "" {
|
||||
invite.SendTo = inv.SendTo
|
||||
}
|
||||
if len(inv.Notify) != 0 {
|
||||
var address string
|
||||
var addressOrID string
|
||||
if app.config.Section("ui").Key("jellyfin_login").MustBool(false) {
|
||||
app.storage.loadEmails()
|
||||
if addr, ok := app.storage.GetEmailsKey(gc.GetString("jfId")); ok && addr.Addr != "" {
|
||||
address = addr.Addr
|
||||
}
|
||||
addressOrID = gc.GetString("jfId")
|
||||
} else {
|
||||
address = app.config.Section("ui").Key("email").String()
|
||||
addressOrID = app.config.Section("ui").Key("email").String()
|
||||
}
|
||||
if _, ok := inv.Notify[address]; ok {
|
||||
if _, ok = inv.Notify[address]["notify-expiry"]; ok {
|
||||
invite.NotifyExpiry = inv.Notify[address]["notify-expiry"]
|
||||
if _, ok := inv.Notify[addressOrID]; ok {
|
||||
if _, ok = inv.Notify[addressOrID]["notify-expiry"]; ok {
|
||||
notifyExpiry := inv.Notify[addressOrID]["notify-expiry"]
|
||||
invite.NotifyExpiry = ¬ifyExpiry
|
||||
}
|
||||
if _, ok = inv.Notify[address]["notify-creation"]; ok {
|
||||
invite.NotifyCreation = inv.Notify[address]["notify-creation"]
|
||||
if _, ok = inv.Notify[addressOrID]["notify-creation"]; ok {
|
||||
notifyCreation := inv.Notify[addressOrID]["notify-creation"]
|
||||
invite.NotifyCreation = ¬ifyCreation
|
||||
}
|
||||
}
|
||||
}
|
||||
invites = append(invites, invite)
|
||||
}
|
||||
profiles := make([]string, len(app.storage.profiles))
|
||||
if len(app.storage.profiles) != 0 {
|
||||
profiles[0] = app.storage.defaultProfile
|
||||
i := 1
|
||||
if len(app.storage.profiles) > 1 {
|
||||
for p := range app.storage.profiles {
|
||||
if p != app.storage.defaultProfile {
|
||||
profiles[i] = p
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
resp := getInvitesDTO{
|
||||
Profiles: profiles,
|
||||
Invites: invites,
|
||||
Invites: invites,
|
||||
}
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
// @Summary Set profile for an invite
|
||||
// @Produce json
|
||||
// @Param inviteProfileDTO body inviteProfileDTO true "Invite profile object"
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @Router /invites/profile [post]
|
||||
// @Security Bearer
|
||||
// @tags Profiles & Settings
|
||||
func (app *appContext) SetProfile(gc *gin.Context) {
|
||||
var req inviteProfileDTO
|
||||
gc.BindJSON(&req)
|
||||
app.debug.Printf("%s: Setting profile to \"%s\"", req.Invite, req.Profile)
|
||||
func (app *appContext) SetProfile(inv *Invite, name string, gc *gin.Context) (changed, ok bool) {
|
||||
changed = false
|
||||
ok = false
|
||||
// "" means "Don't apply profile"
|
||||
if _, ok := app.storage.profiles[req.Profile]; !ok && req.Profile != "" {
|
||||
app.err.Printf("%s: Profile \"%s\" not found", req.Invite, req.Profile)
|
||||
if _, profileExists := app.storage.GetProfileKey(name); !profileExists && name != "" {
|
||||
app.err.Printf(lm.FailedGetProfile, name)
|
||||
respond(500, "Profile not found", gc)
|
||||
return
|
||||
}
|
||||
inv, _ := app.storage.GetInvitesKey(req.Invite)
|
||||
inv.Profile = req.Profile
|
||||
app.storage.SetInvitesKey(req.Invite, inv)
|
||||
app.storage.storeInvites()
|
||||
respondBool(200, true, gc)
|
||||
changed = name != inv.Profile
|
||||
inv.Profile = name
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
// @Summary Set notification preferences for an invite.
|
||||
// @Produce json
|
||||
// @Param setNotifyDTO body setNotifyDTO true "Map of invite codes to notification settings objects"
|
||||
// @Success 200
|
||||
// @Failure 400 {object} stringResponse
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @Router /invites/notify [post]
|
||||
// @Security Bearer
|
||||
// @tags Other
|
||||
func (app *appContext) SetNotify(gc *gin.Context) {
|
||||
var req map[string]map[string]bool
|
||||
gc.BindJSON(&req)
|
||||
changed := false
|
||||
for code, settings := range req {
|
||||
app.debug.Printf("%s: Notification settings change requested", code)
|
||||
app.storage.loadInvites()
|
||||
app.storage.loadEmails()
|
||||
invite, ok := app.storage.GetInvitesKey(code)
|
||||
if !ok {
|
||||
app.err.Printf("%s Notification setting change failed: Invalid code", code)
|
||||
respond(400, "Invalid invite code", gc)
|
||||
func (app *appContext) SetNotify(inv *Invite, settings map[string]bool, gc *gin.Context) (changed, ok bool) {
|
||||
changed = false
|
||||
ok = false
|
||||
var address string
|
||||
jellyfinLogin := app.config.Section("ui").Key("jellyfin_login").MustBool(false)
|
||||
if jellyfinLogin {
|
||||
var addressAvailable bool = app.getAddressOrName(gc.GetString("jfId")) != ""
|
||||
if !addressAvailable {
|
||||
app.err.Printf(lm.FailedGetContactMethod, gc.GetString("jfId"))
|
||||
respond(500, fmt.Sprintf(lm.FailedGetContactMethod, "admin"), gc)
|
||||
return
|
||||
}
|
||||
var address string
|
||||
jellyfinLogin := app.config.Section("ui").Key("jellyfin_login").MustBool(false)
|
||||
if jellyfinLogin {
|
||||
var addressAvailable bool = app.getAddressOrName(gc.GetString("jfId")) != ""
|
||||
if !addressAvailable {
|
||||
app.err.Printf("%s: Couldn't find contact method for admin. Make sure one is set.", code)
|
||||
app.debug.Printf("%s: User ID \"%s\"", code, gc.GetString("jfId"))
|
||||
respond(500, "Missing user contact method", gc)
|
||||
return
|
||||
}
|
||||
address = gc.GetString("jfId")
|
||||
} else {
|
||||
address = app.config.Section("ui").Key("email").String()
|
||||
}
|
||||
if invite.Notify == nil {
|
||||
invite.Notify = map[string]map[string]bool{}
|
||||
}
|
||||
if _, ok := invite.Notify[address]; !ok {
|
||||
invite.Notify[address] = map[string]bool{}
|
||||
} /*else {
|
||||
if _, ok := invite.Notify[address]["notify-expiry"]; !ok {
|
||||
*/
|
||||
if _, ok := settings["notify-expiry"]; ok && invite.Notify[address]["notify-expiry"] != settings["notify-expiry"] {
|
||||
invite.Notify[address]["notify-expiry"] = settings["notify-expiry"]
|
||||
app.debug.Printf("%s: Set \"notify-expiry\" to %t for %s", code, settings["notify-expiry"], address)
|
||||
address = gc.GetString("jfId")
|
||||
} else {
|
||||
address = app.config.Section("ui").Key("email").String()
|
||||
}
|
||||
if inv.Notify == nil {
|
||||
inv.Notify = map[string]map[string]bool{}
|
||||
}
|
||||
if _, ok := inv.Notify[address]; !ok {
|
||||
inv.Notify[address] = map[string]bool{}
|
||||
} /*else {
|
||||
if _, ok := invite.Notify[address]["notify-expiry"]; !ok {
|
||||
*/
|
||||
for _, notifyType := range []string{"notify-expiry", "notify-creation"} {
|
||||
if _, ok := settings[notifyType]; ok && inv.Notify[address][notifyType] != settings[notifyType] {
|
||||
inv.Notify[address][notifyType] = settings[notifyType]
|
||||
app.debug.Printf(lm.SetAdminNotify, notifyType, settings[notifyType], address)
|
||||
changed = true
|
||||
}
|
||||
if _, ok := settings["notify-creation"]; ok && invite.Notify[address]["notify-creation"] != settings["notify-creation"] {
|
||||
invite.Notify[address]["notify-creation"] = settings["notify-creation"]
|
||||
app.debug.Printf("%s: Set \"notify-creation\" to %t for %s", code, settings["notify-creation"], address)
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
app.storage.SetInvitesKey(code, invite)
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
app.storage.storeInvites()
|
||||
}
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
// @Summary Delete an invite.
|
||||
@@ -417,16 +594,24 @@ func (app *appContext) SetNotify(gc *gin.Context) {
|
||||
func (app *appContext) DeleteInvite(gc *gin.Context) {
|
||||
var req deleteInviteDTO
|
||||
gc.BindJSON(&req)
|
||||
app.debug.Printf("%s: Deletion requested", req.Code)
|
||||
var ok bool
|
||||
_, ok = app.storage.GetInvitesKey(req.Code)
|
||||
inv, ok := app.storage.GetInvitesKey(req.Code)
|
||||
if ok {
|
||||
app.storage.DeleteInvitesKey(req.Code)
|
||||
app.storage.storeInvites()
|
||||
app.info.Printf("%s: Invite deleted", req.Code)
|
||||
|
||||
// Record activity
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityDeleteInvite,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
InviteCode: req.Code,
|
||||
Value: inv.Label,
|
||||
Time: time.Now(),
|
||||
}, gc, false)
|
||||
|
||||
app.info.Printf(lm.DeleteInvite, req.Code)
|
||||
respondBool(200, true, gc)
|
||||
return
|
||||
}
|
||||
app.err.Printf("%s: Deletion failed: Invalid code", req.Code)
|
||||
app.err.Printf(lm.FailedDeleteInvite, req.Code, "invalid code")
|
||||
respond(400, "Code doesn't exist", gc)
|
||||
}
|
||||
|
||||
202
api-jellyseerr.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hrfee/jfa-go/common"
|
||||
"github.com/hrfee/jfa-go/jellyseerr"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
)
|
||||
|
||||
// @Summary Get a list of Jellyseerr users.
|
||||
// @Produce json
|
||||
// @Success 200 {object} ombiUsersDTO
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @Router /jellyseerr/users [get]
|
||||
// @Security Bearer
|
||||
// @tags Jellyseerr
|
||||
func (app *appContext) JellyseerrUsers(gc *gin.Context) {
|
||||
users, err := app.js.GetUsers()
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUsers, lm.Jellyseerr, err)
|
||||
respond(500, "Couldn't get users", gc)
|
||||
return
|
||||
}
|
||||
userlist := make([]ombiUser, len(users))
|
||||
i := 0
|
||||
for _, u := range users {
|
||||
userlist[i] = ombiUser{
|
||||
Name: u.Name(),
|
||||
ID: strconv.FormatInt(u.ID, 10),
|
||||
}
|
||||
i++
|
||||
}
|
||||
gc.JSON(200, ombiUsersDTO{Users: userlist})
|
||||
}
|
||||
|
||||
// @Summary Store Jellyseerr user template in an existing profile.
|
||||
// @Produce json
|
||||
// @Param id path string true "Jellyseerr ID of user to source from"
|
||||
// @Param profile path string true "Name of profile to store in"
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @Router /profiles/jellyseerr/{profile}/{id} [post]
|
||||
// @Security Bearer
|
||||
// @tags Jellyseerr
|
||||
func (app *appContext) SetJellyseerrProfile(gc *gin.Context) {
|
||||
jellyseerrID, err := strconv.ParseInt(gc.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
escapedProfileName := gc.Param("profile")
|
||||
profileName, _ := url.QueryUnescape(escapedProfileName)
|
||||
profile, ok := app.storage.GetProfileKey(profileName)
|
||||
if !ok {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
u, err := app.js.UserByID(jellyseerrID)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUser, strconv.FormatInt(jellyseerrID, 10), lm.Jellyseerr, err)
|
||||
respond(500, "Couldn't get user", gc)
|
||||
return
|
||||
}
|
||||
profile.Jellyseerr.User = u.UserTemplate
|
||||
n, err := app.js.GetNotificationPreferencesByID(jellyseerrID)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetJellyseerrNotificationPrefs, gc.Param("id"), err)
|
||||
respond(500, "Couldn't get user notification prefs", gc)
|
||||
return
|
||||
}
|
||||
profile.Jellyseerr.Notifications = n.NotificationsTemplate
|
||||
profile.Jellyseerr.Enabled = true
|
||||
app.storage.SetProfileKey(profileName, profile)
|
||||
respondBool(204, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Remove jellyseerr user template from a profile.
|
||||
// @Produce json
|
||||
// @Param profile path string true "Name of profile to store in"
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @Router /profiles/jellyseerr/{profile} [delete]
|
||||
// @Security Bearer
|
||||
// @tags Jellyseerr
|
||||
func (app *appContext) DeleteJellyseerrProfile(gc *gin.Context) {
|
||||
escapedProfileName := gc.Param("profile")
|
||||
profileName, _ := url.QueryUnescape(escapedProfileName)
|
||||
profile, ok := app.storage.GetProfileKey(profileName)
|
||||
if !ok {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
profile.Jellyseerr.Enabled = false
|
||||
app.storage.SetProfileKey(profileName, profile)
|
||||
respondBool(204, true, gc)
|
||||
}
|
||||
|
||||
type JellyseerrWrapper struct {
|
||||
*jellyseerr.Jellyseerr
|
||||
}
|
||||
|
||||
func (js *JellyseerrWrapper) ImportUser(jellyfinID string, req newUserDTO, profile Profile) (err error, ok bool) {
|
||||
// Gets existing user (not possible) or imports the given user.
|
||||
_, err = js.MustGetUser(jellyfinID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ok = true
|
||||
if !profile.Jellyseerr.Enabled {
|
||||
return
|
||||
}
|
||||
err = js.ApplyTemplateToUser(jellyfinID, profile.Jellyseerr.User)
|
||||
if err != nil {
|
||||
err = fmt.Errorf(lm.FailedApplyTemplate, "user", lm.Jellyseerr, jellyfinID, err)
|
||||
return
|
||||
}
|
||||
err = js.ApplyNotificationsTemplateToUser(jellyfinID, profile.Jellyseerr.Notifications)
|
||||
if err != nil {
|
||||
err = fmt.Errorf(lm.FailedApplyTemplate, "notifications", lm.Jellyseerr, jellyfinID, err)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (js *JellyseerrWrapper) SetContactMethods(jellyfinID string, email *string, discord *DiscordUser, telegram *TelegramUser, contactPrefs *common.ContactPreferences) (err error) {
|
||||
_, err = js.MustGetUser(jellyfinID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if contactPrefs == nil {
|
||||
contactPrefs = &common.ContactPreferences{
|
||||
Email: nil,
|
||||
Discord: nil,
|
||||
Telegram: nil,
|
||||
Matrix: nil,
|
||||
}
|
||||
}
|
||||
contactMethods := map[jellyseerr.NotificationsField]any{}
|
||||
if emailEnabled {
|
||||
if contactPrefs.Email != nil {
|
||||
contactMethods[jellyseerr.FieldEmailEnabled] = *(contactPrefs.Email)
|
||||
} else if email != nil && *email != "" {
|
||||
contactMethods[jellyseerr.FieldEmailEnabled] = true
|
||||
}
|
||||
if email != nil {
|
||||
err = js.ModifyMainUserSettings(jellyfinID, jellyseerr.MainUserSettings{Email: *email})
|
||||
if err != nil {
|
||||
// FIXME: This is a little ugly, considering all other errors are unformatted
|
||||
err = fmt.Errorf(lm.FailedSetEmailAddress, lm.Jellyseerr, jellyfinID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if discordEnabled {
|
||||
if contactPrefs.Discord != nil {
|
||||
contactMethods[jellyseerr.FieldDiscordEnabled] = *(contactPrefs.Discord)
|
||||
} else if discord != nil && discord.ID != "" {
|
||||
contactMethods[jellyseerr.FieldDiscordEnabled] = true
|
||||
}
|
||||
if discord != nil {
|
||||
contactMethods[jellyseerr.FieldDiscord] = discord.ID
|
||||
// Whether this is still necessary or not, i don't know.
|
||||
if discord.ID == "" {
|
||||
contactMethods[jellyseerr.FieldDiscord] = jellyseerr.BogusIdentifier
|
||||
}
|
||||
}
|
||||
}
|
||||
if telegramEnabled {
|
||||
if contactPrefs.Telegram != nil {
|
||||
contactMethods[jellyseerr.FieldTelegramEnabled] = *(contactPrefs.Telegram)
|
||||
} else if telegram != nil && telegram.ChatID != 0 {
|
||||
contactMethods[jellyseerr.FieldTelegramEnabled] = true
|
||||
}
|
||||
if telegram != nil {
|
||||
contactMethods[jellyseerr.FieldTelegram] = strconv.FormatInt(telegram.ChatID, 10)
|
||||
// Whether this is still necessary or not, i don't know.
|
||||
if telegram.ChatID == 0 {
|
||||
contactMethods[jellyseerr.FieldTelegram] = jellyseerr.BogusIdentifier
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(contactMethods) > 0 {
|
||||
err = js.ModifyNotifications(jellyfinID, contactMethods)
|
||||
if err != nil {
|
||||
// app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (js *JellyseerrWrapper) Name() string { return lm.Jellyseerr }
|
||||
|
||||
func (js *JellyseerrWrapper) Enabled(app *appContext, profile *Profile) bool {
|
||||
return profile != nil && profile.Jellyseerr.Enabled && app.config.Section("jellyseerr").Key("enabled").MustBool(false)
|
||||
}
|
||||
529
api-messages.go
@@ -1,10 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hrfee/jfa-go/common"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
@@ -20,23 +23,16 @@ func (app *appContext) GetCustomContent(gc *gin.Context) {
|
||||
if _, ok := app.storage.lang.Email[lang]; !ok {
|
||||
lang = app.storage.lang.chosenEmailLang
|
||||
}
|
||||
adminLang := lang
|
||||
if _, ok := app.storage.lang.Admin[lang]; !ok {
|
||||
adminLang = app.storage.lang.chosenAdminLang
|
||||
}
|
||||
list := emailListDTO{
|
||||
"UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.customEmails.UserCreated.Enabled},
|
||||
"InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.customEmails.InviteExpiry.Enabled},
|
||||
"PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.customEmails.PasswordReset.Enabled},
|
||||
"UserDeleted": {Name: app.storage.lang.Email[lang].UserDeleted["name"], Enabled: app.storage.customEmails.UserDeleted.Enabled},
|
||||
"UserDisabled": {Name: app.storage.lang.Email[lang].UserDisabled["name"], Enabled: app.storage.customEmails.UserDisabled.Enabled},
|
||||
"UserEnabled": {Name: app.storage.lang.Email[lang].UserEnabled["name"], Enabled: app.storage.customEmails.UserEnabled.Enabled},
|
||||
"InviteEmail": {Name: app.storage.lang.Email[lang].InviteEmail["name"], Enabled: app.storage.customEmails.InviteEmail.Enabled},
|
||||
"WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.customEmails.WelcomeEmail.Enabled},
|
||||
"EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.customEmails.EmailConfirmation.Enabled},
|
||||
"UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.customEmails.UserExpired.Enabled},
|
||||
"UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.userPage.Login.Enabled},
|
||||
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.userPage.Page.Enabled},
|
||||
list := emailListDTO{}
|
||||
for _, cc := range customContent {
|
||||
if cc.ContentType == CustomTemplate {
|
||||
continue
|
||||
}
|
||||
ccDescription := emailListEl{Name: cc.DisplayName(&app.storage.lang, lang), Enabled: app.storage.MustGetCustomContentKey(cc.Name).Enabled}
|
||||
if cc.Description != nil {
|
||||
ccDescription.Description = cc.Description(&app.storage.lang, lang)
|
||||
}
|
||||
list[cc.Name] = ccDescription
|
||||
}
|
||||
|
||||
filter := gc.Query("filter")
|
||||
@@ -50,75 +46,37 @@ func (app *appContext) GetCustomContent(gc *gin.Context) {
|
||||
gc.JSON(200, list)
|
||||
}
|
||||
|
||||
func (app *appContext) getCustomMessage(id string) *customContent {
|
||||
switch id {
|
||||
case "Announcement":
|
||||
return &customContent{}
|
||||
case "UserCreated":
|
||||
return &app.storage.customEmails.UserCreated
|
||||
case "InviteExpiry":
|
||||
return &app.storage.customEmails.InviteExpiry
|
||||
case "PasswordReset":
|
||||
return &app.storage.customEmails.PasswordReset
|
||||
case "UserDeleted":
|
||||
return &app.storage.customEmails.UserDeleted
|
||||
case "UserDisabled":
|
||||
return &app.storage.customEmails.UserDisabled
|
||||
case "UserEnabled":
|
||||
return &app.storage.customEmails.UserEnabled
|
||||
case "InviteEmail":
|
||||
return &app.storage.customEmails.InviteEmail
|
||||
case "WelcomeEmail":
|
||||
return &app.storage.customEmails.WelcomeEmail
|
||||
case "EmailConfirmation":
|
||||
return &app.storage.customEmails.EmailConfirmation
|
||||
case "UserExpired":
|
||||
return &app.storage.customEmails.UserExpired
|
||||
case "UserLogin":
|
||||
return &app.storage.userPage.Login
|
||||
case "UserPage":
|
||||
return &app.storage.userPage.Page
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// @Summary Sets the corresponding custom email.
|
||||
// @Summary Sets the corresponding custom content.
|
||||
// @Produce json
|
||||
// @Param customEmails body customEmails true "Content = email (in markdown)."
|
||||
// @Param CustomContent body CustomContent true "Content = email (in markdown)."
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Param id path string true "ID of email"
|
||||
// @Param id path string true "ID of content"
|
||||
// @Router /config/emails/{id} [post]
|
||||
// @Security Bearer
|
||||
// @tags Configuration
|
||||
func (app *appContext) SetCustomMessage(gc *gin.Context) {
|
||||
var req customContent
|
||||
var req CustomContent
|
||||
gc.BindJSON(&req)
|
||||
id := gc.Param("id")
|
||||
if req.Content == "" {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
message := app.getCustomMessage(id)
|
||||
if message == nil {
|
||||
_, ok := customContent[id]
|
||||
if !ok {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
message, ok := app.storage.GetCustomContentKey(id)
|
||||
message.Content = req.Content
|
||||
message.Enabled = true
|
||||
if app.storage.storeCustomEmails() != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
if app.storage.storeUserPageContent() != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
app.storage.SetCustomContentKey(id, message)
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Enable/Disable custom email.
|
||||
// @Summary Enable/Disable custom content.
|
||||
// @Produce json
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 400 {object} boolResponse
|
||||
@@ -137,24 +95,17 @@ func (app *appContext) SetCustomMessageState(gc *gin.Context) {
|
||||
} else if s != "disable" {
|
||||
respondBool(400, false, gc)
|
||||
}
|
||||
message := app.getCustomMessage(id)
|
||||
if message == nil {
|
||||
message, ok := app.storage.GetCustomContentKey(id)
|
||||
if !ok {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
message.Enabled = enabled
|
||||
if app.storage.storeCustomEmails() != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
if app.storage.storeUserPageContent() != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
app.storage.SetCustomContentKey(id, message)
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Returns the custom email/message (generating it if not set) and list of used variables in it.
|
||||
// @Summary Returns the custom content/message (generating it if not set) and list of used variables in it.
|
||||
// @Produce json
|
||||
// @Success 200 {object} customEmailDTO
|
||||
// @Failure 400 {object} boolResponse
|
||||
@@ -164,143 +115,109 @@ func (app *appContext) SetCustomMessageState(gc *gin.Context) {
|
||||
// @Security Bearer
|
||||
// @tags Configuration
|
||||
func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
|
||||
lang := app.storage.lang.chosenEmailLang
|
||||
id := gc.Param("id")
|
||||
var content string
|
||||
var err error
|
||||
var msg *Message
|
||||
var variables []string
|
||||
var conditionals []string
|
||||
var values map[string]interface{}
|
||||
username := app.storage.lang.Email[lang].Strings.get("username")
|
||||
emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress")
|
||||
customMessage := app.getCustomMessage(id)
|
||||
if customMessage == nil {
|
||||
app.err.Printf("Failed to get custom message with ID \"%s\"", id)
|
||||
contentInfo, ok := customContent[id]
|
||||
// FIXME: Add announcement to customContent
|
||||
if !ok && id != "Announcement" {
|
||||
app.err.Printf(lm.FailedGetCustomMessage, id)
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
if id == "WelcomeEmail" {
|
||||
conditionals = []string{"{yourAccountWillExpire}"}
|
||||
customMessage.Conditionals = conditionals
|
||||
} else if id == "UserPage" {
|
||||
variables = []string{"{username}"}
|
||||
customMessage.Variables = variables
|
||||
} else if id == "UserLogin" {
|
||||
variables = []string{}
|
||||
customMessage.Variables = variables
|
||||
|
||||
content, ok := app.storage.GetCustomContentKey(id)
|
||||
|
||||
if contentInfo.Variables == nil {
|
||||
contentInfo.Variables = []string{}
|
||||
}
|
||||
content = customMessage.Content
|
||||
noContent := content == ""
|
||||
if !noContent {
|
||||
variables = customMessage.Variables
|
||||
if contentInfo.Conditionals == nil {
|
||||
contentInfo.Conditionals = []string{}
|
||||
}
|
||||
switch id {
|
||||
case "Announcement":
|
||||
// Just send the email html
|
||||
content = ""
|
||||
case "UserCreated":
|
||||
if noContent {
|
||||
msg, err = app.email.constructCreated("", "", "", Invite{}, app, true)
|
||||
}
|
||||
values = app.email.createdValues("xxxxxx", username, emailAddress, Invite{}, app, false)
|
||||
case "InviteExpiry":
|
||||
if noContent {
|
||||
msg, err = app.email.constructExpiry("", Invite{}, app, true)
|
||||
}
|
||||
values = app.email.expiryValues("xxxxxx", Invite{}, app, false)
|
||||
case "PasswordReset":
|
||||
if noContent {
|
||||
msg, err = app.email.constructReset(PasswordReset{}, app, true)
|
||||
}
|
||||
values = app.email.resetValues(PasswordReset{Pin: "12-34-56", Username: username}, app, false)
|
||||
case "UserDeleted":
|
||||
if noContent {
|
||||
msg, err = app.email.constructDeleted("", app, true)
|
||||
}
|
||||
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
|
||||
case "UserDisabled":
|
||||
if noContent {
|
||||
msg, err = app.email.constructDisabled("", app, true)
|
||||
}
|
||||
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
|
||||
case "UserEnabled":
|
||||
if noContent {
|
||||
msg, err = app.email.constructEnabled("", app, true)
|
||||
}
|
||||
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
|
||||
case "InviteEmail":
|
||||
if noContent {
|
||||
msg, err = app.email.constructInvite("", Invite{}, app, true)
|
||||
}
|
||||
values = app.email.inviteValues("xxxxxx", Invite{}, app, false)
|
||||
case "WelcomeEmail":
|
||||
if noContent {
|
||||
msg, err = app.email.constructWelcome("", time.Time{}, app, true)
|
||||
}
|
||||
values = app.email.welcomeValues(username, time.Now(), app, false, true)
|
||||
case "EmailConfirmation":
|
||||
if noContent {
|
||||
msg, err = app.email.constructConfirmation("", "", "", app, true)
|
||||
}
|
||||
values = app.email.confirmationValues("xxxxxx", username, "xxxxxx", app, false)
|
||||
case "UserExpired":
|
||||
if noContent {
|
||||
msg, err = app.email.constructUserExpired(app, true)
|
||||
}
|
||||
values = app.email.userExpiredValues(app, false)
|
||||
case "UserLogin", "UserPage":
|
||||
values = map[string]interface{}{}
|
||||
if contentInfo.Placeholders == nil {
|
||||
contentInfo.Placeholders = map[string]any{}
|
||||
}
|
||||
if err != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" {
|
||||
content = msg.Text
|
||||
variables = make([]string, strings.Count(content, "{"))
|
||||
i := 0
|
||||
found := false
|
||||
buf := ""
|
||||
for _, c := range content {
|
||||
if !found && c != '{' && c != '}' {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
buf += string(c)
|
||||
if c == '}' {
|
||||
found = false
|
||||
variables[i] = buf
|
||||
buf = ""
|
||||
i++
|
||||
}
|
||||
|
||||
// Generate content from real email, if the user hasn't already customised this message.
|
||||
if content.Content == "" {
|
||||
var msg *Message
|
||||
switch id {
|
||||
// FIXME: Add announcement to customContent
|
||||
case "UserCreated":
|
||||
msg, err = app.email.constructCreated("", "", time.Time{}, Invite{}, true)
|
||||
case "InviteExpiry":
|
||||
msg, err = app.email.constructExpiry(Invite{}, true)
|
||||
case "PasswordReset":
|
||||
msg, err = app.email.constructReset(PasswordReset{}, true)
|
||||
case "UserDeleted":
|
||||
msg, err = app.email.constructDeleted("", "", true)
|
||||
case "UserDisabled":
|
||||
msg, err = app.email.constructDisabled("", "", true)
|
||||
case "UserEnabled":
|
||||
msg, err = app.email.constructEnabled("", "", true)
|
||||
case "UserExpiryAdjusted":
|
||||
msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", true)
|
||||
case "ExpiryReminder":
|
||||
msg, err = app.email.constructExpiryReminder("", time.Now().AddDate(0, 0, 3), true)
|
||||
case "InviteEmail":
|
||||
msg, err = app.email.constructInvite(&Invite{Code: ""}, true)
|
||||
case "WelcomeEmail":
|
||||
msg, err = app.email.constructWelcome("", time.Time{}, true)
|
||||
case "EmailConfirmation":
|
||||
msg, err = app.email.constructConfirmation("", "", "", true)
|
||||
case "UserExpired":
|
||||
msg, err = app.email.constructUserExpired("", true)
|
||||
case "Announcement":
|
||||
case "UserPage":
|
||||
case "UserLogin":
|
||||
case "PostSignupCard":
|
||||
case "PreSignupCard":
|
||||
// These don't have any example content
|
||||
msg = nil
|
||||
}
|
||||
customMessage.Variables = variables
|
||||
}
|
||||
if variables == nil {
|
||||
variables = []string{}
|
||||
}
|
||||
if app.storage.storeCustomEmails() != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
if app.storage.storeUserPageContent() != nil {
|
||||
respondBool(500, false, gc)
|
||||
}
|
||||
var mail *Message
|
||||
if id != "UserLogin" && id != "UserPage" {
|
||||
mail, err = app.email.constructTemplate("", "<div class=\"preview-content\"></div>", app)
|
||||
if err != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
mail = &Message{
|
||||
HTML: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
|
||||
Markdown: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
|
||||
if msg != nil {
|
||||
content.Content = msg.Text
|
||||
}
|
||||
}
|
||||
gc.JSON(200, customEmailDTO{Content: content, Variables: variables, Conditionals: conditionals, Values: values, HTML: mail.HTML, Plaintext: mail.Text})
|
||||
|
||||
var mail *Message = nil
|
||||
if contentInfo.ContentType == CustomMessage {
|
||||
mail, err = app.email.construct(EmptyCustomContent, CustomContent{
|
||||
Name: EmptyCustomContent.Name,
|
||||
Enabled: true,
|
||||
Content: "<div class=\"preview-content\"></div>",
|
||||
}, map[string]any{})
|
||||
if err != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
} else if id == "PostSignupCard" {
|
||||
// Specific workaround for the currently-unique "Post signup card".
|
||||
// Source content from "Success Message" setting.
|
||||
if content.Content == "" {
|
||||
content.Content = "# " + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("successHeader") + "\n" + app.config.Section("ui").Key("success_message").String()
|
||||
if app.config.Section("user_page").Key("enabled").MustBool(false) {
|
||||
content.Content += "\n\n<br>\n" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.template("userPageSuccessMessage", tmpl{
|
||||
"myAccount": "[" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("myAccount") + "]({myAccountURL})",
|
||||
})
|
||||
}
|
||||
}
|
||||
mail = &Message{
|
||||
HTML: "<div class=\"card ~neutral dark:~d_neutral @low\"><div class=\"preview-content\"></div><br><button class=\"button ~urge dark:~d_urge @low full-width center supra submit\">" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("continue") + "</a></div>",
|
||||
}
|
||||
mail.Markdown = mail.HTML
|
||||
} else if contentInfo.ContentType == CustomCard {
|
||||
mail = &Message{
|
||||
HTML: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
|
||||
}
|
||||
mail.Markdown = mail.HTML
|
||||
} else {
|
||||
app.err.Printf("unknown custom content type %d", contentInfo.ContentType)
|
||||
}
|
||||
gc.JSON(200, customEmailDTO{Content: content.Content, Variables: contentInfo.Variables, Conditionals: contentInfo.Conditionals, Values: contentInfo.Placeholders, HTML: mail.HTML, Plaintext: mail.Text})
|
||||
}
|
||||
|
||||
// @Summary Returns a new Telegram verification PIN, and the bot username.
|
||||
@@ -339,21 +256,32 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
|
||||
return
|
||||
}
|
||||
tgUser := TelegramUser{
|
||||
ChatID: tgToken.ChatID,
|
||||
Username: tgToken.Username,
|
||||
Contact: true,
|
||||
TelegramVerifiedToken: TelegramVerifiedToken{
|
||||
ChatID: tgToken.ChatID,
|
||||
Username: tgToken.Username,
|
||||
},
|
||||
Contact: true,
|
||||
}
|
||||
if lang, ok := app.telegram.languages[tgToken.ChatID]; ok {
|
||||
tgUser.Lang = lang
|
||||
}
|
||||
app.storage.SetTelegramKey(req.ID, tgUser)
|
||||
linkExistingOmbiDiscordTelegram(app)
|
||||
|
||||
for _, tps := range app.thirdPartyServices {
|
||||
if err := tps.SetContactMethods(req.ID, nil, nil, &tgUser, &common.ContactPreferences{
|
||||
Telegram: &tgUser.Contact,
|
||||
}); err != nil {
|
||||
app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
app.InvalidateWebUserCache()
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Sets whether to notify a user through telegram/discord/matrix/email or not.
|
||||
// @Produce json
|
||||
// @Param SetContactMethodsDTO body SetContactMethodsDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
|
||||
// @Param SetContactPreferencesDTO body SetContactPreferencesDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Success 400 {object} boolResponse
|
||||
// @Success 500 {object} boolResponse
|
||||
@@ -361,79 +289,60 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
|
||||
// @Security Bearer
|
||||
// @tags Other
|
||||
func (app *appContext) SetContactMethods(gc *gin.Context) {
|
||||
var req SetContactMethodsDTO
|
||||
var req SetContactPreferencesDTO
|
||||
gc.BindJSON(&req)
|
||||
if req.ID == "" {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
app.setContactMethods(req, gc)
|
||||
app.setContactPreferences(req, gc)
|
||||
}
|
||||
|
||||
func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Context) {
|
||||
func (app *appContext) setContactPreferences(req SetContactPreferencesDTO, gc *gin.Context) {
|
||||
contactPrefs := common.ContactPreferences{}
|
||||
if tgUser, ok := app.storage.GetTelegramKey(req.ID); ok {
|
||||
change := tgUser.Contact != req.Telegram
|
||||
tgUser.Contact = req.Telegram
|
||||
app.storage.SetTelegramKey(req.ID, tgUser)
|
||||
if change {
|
||||
msg := ""
|
||||
if !req.Telegram {
|
||||
msg = " not"
|
||||
}
|
||||
app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg)
|
||||
app.debug.Printf(lm.SetContactPrefForService, lm.Telegram, tgUser.Username, req.Telegram)
|
||||
contactPrefs.Telegram = &req.Telegram
|
||||
}
|
||||
}
|
||||
if dcUser, ok := app.storage.GetDiscordKey(req.ID); ok {
|
||||
change := dcUser.Contact != req.Discord
|
||||
dcUser.Contact = req.Discord
|
||||
app.storage.SetDiscordKey(req.ID, dcUser)
|
||||
if err := app.storage.storeDiscordUsers(); err != nil {
|
||||
respondBool(500, false, gc)
|
||||
app.err.Printf("Discord: Failed to store users: %v", err)
|
||||
return
|
||||
}
|
||||
if change {
|
||||
msg := ""
|
||||
if !req.Discord {
|
||||
msg = " not"
|
||||
}
|
||||
app.debug.Printf("Discord: User \"%s\" will%s be notified through Discord.", dcUser.Username, msg)
|
||||
app.debug.Printf(lm.SetContactPrefForService, lm.Discord, dcUser.Username, req.Discord)
|
||||
contactPrefs.Discord = &req.Discord
|
||||
}
|
||||
}
|
||||
if mxUser, ok := app.storage.GetMatrixKey(req.ID); ok {
|
||||
change := mxUser.Contact != req.Matrix
|
||||
mxUser.Contact = req.Matrix
|
||||
app.storage.SetMatrixKey(req.ID, mxUser)
|
||||
if err := app.storage.storeMatrixUsers(); err != nil {
|
||||
respondBool(500, false, gc)
|
||||
app.err.Printf("Matrix: Failed to store users: %v", err)
|
||||
return
|
||||
}
|
||||
if change {
|
||||
msg := ""
|
||||
if !req.Matrix {
|
||||
msg = " not"
|
||||
}
|
||||
app.debug.Printf("Matrix: User \"%s\" will%s be notified through Matrix.", mxUser.UserID, msg)
|
||||
app.debug.Printf(lm.SetContactPrefForService, lm.Matrix, mxUser.UserID, req.Matrix)
|
||||
contactPrefs.Matrix = &req.Matrix
|
||||
}
|
||||
}
|
||||
if email, ok := app.storage.GetEmailsKey(req.ID); ok {
|
||||
change := email.Contact != req.Email
|
||||
email.Contact = req.Email
|
||||
app.storage.SetEmailsKey(req.ID, email)
|
||||
if err := app.storage.storeEmails(); err != nil {
|
||||
respondBool(500, false, gc)
|
||||
app.err.Printf("Failed to store emails: %v", err)
|
||||
return
|
||||
}
|
||||
if change {
|
||||
msg := ""
|
||||
if !req.Email {
|
||||
msg = " not"
|
||||
}
|
||||
app.debug.Printf("\"%s\" will%s be notified via Email.", email.Addr, msg)
|
||||
app.debug.Printf(lm.SetContactPrefForService, lm.Email, email.Addr, req.Email)
|
||||
contactPrefs.Email = &req.Email
|
||||
}
|
||||
}
|
||||
|
||||
for _, tps := range app.thirdPartyServices {
|
||||
if err := tps.SetContactMethods(req.ID, nil, nil, nil, &contactPrefs); err != nil {
|
||||
app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
|
||||
}
|
||||
}
|
||||
app.InvalidateWebUserCache()
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -450,7 +359,7 @@ func (app *appContext) TelegramVerified(gc *gin.Context) {
|
||||
respondBool(200, ok, gc)
|
||||
}
|
||||
|
||||
// @Summary Returns true/false on whether or not a telegram PIN was verified. Requires invite code.
|
||||
// @Summary Returns true/false on whether or not a telegram PIN was verified. Requires invite code. NOTE: "/invite" might have been changed in Settings > URL Paths.
|
||||
// @Produce json
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Success 401 {object} boolResponse
|
||||
@@ -467,14 +376,14 @@ func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) {
|
||||
pin := gc.Param("pin")
|
||||
token, ok := app.telegram.TokenVerified(pin)
|
||||
if ok && app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(token.Username) {
|
||||
app.discord.DeleteVerifiedUser(pin)
|
||||
app.discord.DeleteVerifiedToken(pin)
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
respondBool(200, ok, gc)
|
||||
}
|
||||
|
||||
// @Summary Returns true/false on whether or not a discord PIN was verified. Requires invite code.
|
||||
// @Summary Returns true/false on whether or not a discord PIN was verified. Requires invite code. NOTE: "/invite" might have been changed in Settings > URL Paths.
|
||||
// @Produce json
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 401 {object} boolResponse
|
||||
@@ -490,7 +399,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
|
||||
}
|
||||
pin := gc.Param("pin")
|
||||
user, ok := app.discord.UserVerified(pin)
|
||||
if ok && app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(user.ID) {
|
||||
if ok && app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(user.MethodID().(string)) {
|
||||
delete(app.discord.verifiedTokens, pin)
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
@@ -498,7 +407,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
|
||||
respondBool(200, ok, gc)
|
||||
}
|
||||
|
||||
// @Summary Returns a 10-minute, one-use Discord server invite
|
||||
// @Summary Returns a 10-minute, one-use Discord server invite. NOTE: "/invite" might have been changed in Settings > URL Paths.
|
||||
// @Produce json
|
||||
// @Success 200 {object} DiscordInviteDTO
|
||||
// @Failure 400 {object} boolResponse
|
||||
@@ -508,7 +417,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
|
||||
// @Router /invite/{invCode}/discord/invite [get]
|
||||
// @tags Other
|
||||
func (app *appContext) DiscordServerInvite(gc *gin.Context) {
|
||||
if app.discord.inviteChannelName == "" {
|
||||
if app.discord.InviteChannel.Name == "" {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
@@ -525,7 +434,7 @@ func (app *appContext) DiscordServerInvite(gc *gin.Context) {
|
||||
gc.JSON(200, DiscordInviteDTO{invURL, iconURL})
|
||||
}
|
||||
|
||||
// @Summary Generate and send a new PIN to a specified Matrix user.
|
||||
// @Summary Generate and send a new PIN to a specified Matrix user. NOTE: "/invite" might have been changed in Settings > URL Paths.
|
||||
// @Produce json
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 400 {object} stringResponse
|
||||
@@ -564,7 +473,7 @@ func (app *appContext) MatrixSendPIN(gc *gin.Context) {
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Check whether a matrix PIN is valid, and mark the token as verified if so. Requires invite code.
|
||||
// @Summary Check whether a matrix PIN is valid, and mark the token as verified if so. Requires invite code. NOTE: "/invite" might have been changed in Settings > URL Paths.
|
||||
// @Produce json
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 401 {object} boolResponse
|
||||
@@ -576,7 +485,7 @@ func (app *appContext) MatrixSendPIN(gc *gin.Context) {
|
||||
func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
|
||||
code := gc.Param("invCode")
|
||||
if _, ok := app.storage.GetInvitesKey(code); !ok {
|
||||
app.debug.Println("Matrix: Invite code was invalid")
|
||||
app.debug.Printf(lm.InvalidInviteCode, code)
|
||||
respondBool(401, false, gc)
|
||||
return
|
||||
}
|
||||
@@ -584,12 +493,12 @@ func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
|
||||
pin := gc.Param("pin")
|
||||
user, ok := app.matrix.tokens[pin]
|
||||
if !ok {
|
||||
app.debug.Println("Matrix: PIN not found")
|
||||
app.debug.Printf(lm.InvalidPIN, pin)
|
||||
respondBool(200, false, gc)
|
||||
return
|
||||
}
|
||||
if user.User.UserID != userID {
|
||||
app.debug.Println("Matrix: User ID of PIN didn't match")
|
||||
app.debug.Printf(lm.UnauthorizedPIN, pin)
|
||||
respondBool(200, false, gc)
|
||||
return
|
||||
}
|
||||
@@ -606,6 +515,7 @@ func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Param MatrixLoginDTO body MatrixLoginDTO true "Username & password."
|
||||
// @Router /matrix/login [post]
|
||||
// @Security Bearer
|
||||
// @tags Other
|
||||
func (app *appContext) MatrixLogin(gc *gin.Context) {
|
||||
var req MatrixLoginDTO
|
||||
@@ -616,18 +526,18 @@ func (app *appContext) MatrixLogin(gc *gin.Context) {
|
||||
}
|
||||
token, err := app.matrix.generateAccessToken(req.Homeserver, req.Username, req.Password)
|
||||
if err != nil {
|
||||
app.err.Printf("Matrix: Failed to generate token: %v", err)
|
||||
app.err.Printf(lm.FailedGenerateToken, err)
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
tempConfig, _ := ini.Load(app.configPath)
|
||||
tempConfig, _ := ini.ShadowLoad(app.configPath)
|
||||
matrix := tempConfig.Section("matrix")
|
||||
matrix.Key("enabled").SetValue("true")
|
||||
matrix.Key("homeserver").SetValue(req.Homeserver)
|
||||
matrix.Key("token").SetValue(token)
|
||||
matrix.Key("user_id").SetValue(req.Username)
|
||||
if err := tempConfig.SaveTo(app.configPath); err != nil {
|
||||
app.err.Printf("Failed to save config to \"%s\": %v", app.configPath, err)
|
||||
app.err.Printf(lm.FailedWriting, app.configPath, err)
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
@@ -641,32 +551,27 @@ func (app *appContext) MatrixLogin(gc *gin.Context) {
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Param MatrixConnectUserDTO body MatrixConnectUserDTO true "User's Jellyfin ID & Matrix user ID."
|
||||
// @Router /users/matrix [post]
|
||||
// @Security Bearer
|
||||
// @tags Other
|
||||
func (app *appContext) MatrixConnect(gc *gin.Context) {
|
||||
var req MatrixConnectUserDTO
|
||||
gc.BindJSON(&req)
|
||||
if app.storage.GetMatrix() == nil {
|
||||
app.storage.matrix = matrixStore{}
|
||||
app.storage.deprecatedMatrix = matrixStore{}
|
||||
}
|
||||
roomID, encrypted, err := app.matrix.CreateRoom(req.UserID)
|
||||
roomID, err := app.matrix.CreateRoom(req.UserID)
|
||||
if err != nil {
|
||||
app.err.Printf("Matrix: Failed to create room: %v", err)
|
||||
app.err.Printf(lm.FailedCreateRoom, err)
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
app.storage.SetMatrixKey(req.JellyfinID, MatrixUser{
|
||||
UserID: req.UserID,
|
||||
RoomID: string(roomID),
|
||||
Lang: "en-us",
|
||||
Contact: true,
|
||||
Encrypted: encrypted,
|
||||
UserID: req.UserID,
|
||||
RoomID: string(roomID),
|
||||
Lang: "en-us",
|
||||
Contact: true,
|
||||
})
|
||||
app.matrix.isEncrypted[roomID] = encrypted
|
||||
if err := app.storage.storeMatrixUsers(); err != nil {
|
||||
app.err.Printf("Failed to store Matrix users: %v", err)
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
app.InvalidateWebUserCache()
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -677,10 +582,12 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Param username path string true "username to search."
|
||||
// @Router /users/discord/{username} [get]
|
||||
// @Security Bearer
|
||||
// @tags Other
|
||||
func (app *appContext) DiscordGetUsers(gc *gin.Context) {
|
||||
name := gc.Param("username")
|
||||
if name == "" {
|
||||
escapedName := gc.Param("username")
|
||||
name, err := url.QueryUnescape(escapedName)
|
||||
if err != nil || name == "" {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
@@ -703,6 +610,7 @@ func (app *appContext) DiscordGetUsers(gc *gin.Context) {
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Param DiscordConnectUserDTO body DiscordConnectUserDTO true "User's Jellyfin ID & Discord ID."
|
||||
// @Router /users/discord [post]
|
||||
// @Security Bearer
|
||||
// @tags Other
|
||||
func (app *appContext) DiscordConnect(gc *gin.Context) {
|
||||
var req DiscordConnectUserDTO
|
||||
@@ -716,13 +624,28 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
|
||||
app.storage.SetDiscordKey(req.JellyfinID, user)
|
||||
if err := app.storage.storeDiscordUsers(); err != nil {
|
||||
app.err.Printf("Failed to store Discord users: %v", err)
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
|
||||
for _, tps := range app.thirdPartyServices {
|
||||
if err := tps.SetContactMethods(req.JellyfinID, nil, &user, nil, &common.ContactPreferences{
|
||||
Discord: &user.Contact,
|
||||
}); err != nil {
|
||||
app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactLinked,
|
||||
UserID: req.JellyfinID,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "discord",
|
||||
Time: time.Now(),
|
||||
}, gc, false)
|
||||
|
||||
linkExistingOmbiDiscordTelegram(app)
|
||||
app.InvalidateWebUserCache()
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -731,6 +654,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
|
||||
// @Router /users/discord [delete]
|
||||
// @Security Bearer
|
||||
// @Tags Users
|
||||
func (app *appContext) UnlinkDiscord(gc *gin.Context) {
|
||||
var req forUserDTO
|
||||
@@ -741,6 +665,27 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
|
||||
return
|
||||
} */
|
||||
app.storage.DeleteDiscordKey(req.ID)
|
||||
|
||||
contact := false
|
||||
|
||||
for _, tps := range app.thirdPartyServices {
|
||||
if err := tps.SetContactMethods(req.ID, nil, EmptyDiscordUser(), nil, &common.ContactPreferences{
|
||||
Discord: &contact,
|
||||
}); err != nil {
|
||||
app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: req.ID,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "discord",
|
||||
Time: time.Now(),
|
||||
}, gc, false)
|
||||
|
||||
app.InvalidateWebUserCache()
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -749,6 +694,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
|
||||
// @Router /users/telegram [delete]
|
||||
// @Security Bearer
|
||||
// @Tags Users
|
||||
func (app *appContext) UnlinkTelegram(gc *gin.Context) {
|
||||
var req forUserDTO
|
||||
@@ -759,6 +705,27 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
|
||||
return
|
||||
} */
|
||||
app.storage.DeleteTelegramKey(req.ID)
|
||||
|
||||
contact := false
|
||||
|
||||
for _, tps := range app.thirdPartyServices {
|
||||
if err := tps.SetContactMethods(req.ID, nil, nil, EmptyTelegramUser(), &common.ContactPreferences{
|
||||
Telegram: &contact,
|
||||
}); err != nil {
|
||||
app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: req.ID,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "telegram",
|
||||
Time: time.Now(),
|
||||
}, gc, false)
|
||||
|
||||
app.InvalidateWebUserCache()
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -767,6 +734,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
|
||||
// @Router /users/matrix [delete]
|
||||
// @Security Bearer
|
||||
// @Tags Users
|
||||
func (app *appContext) UnlinkMatrix(gc *gin.Context) {
|
||||
var req forUserDTO
|
||||
@@ -777,5 +745,16 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) {
|
||||
return
|
||||
} */
|
||||
app.storage.DeleteMatrixKey(req.ID)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: req.ID,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "matrix",
|
||||
Time: time.Now(),
|
||||
}, gc, false)
|
||||
|
||||
app.InvalidateWebUserCache()
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
228
api-ombi.go
@@ -2,23 +2,39 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hrfee/jfa-go/common"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
ombiLib "github.com/hrfee/jfa-go/ombi"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
)
|
||||
|
||||
func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, error) {
|
||||
ombiUsers, code, err := app.ombi.GetUsers()
|
||||
if err != nil || code != 200 {
|
||||
return nil, code, err
|
||||
}
|
||||
jfUser, code, err := app.jf.UserByID(jfID, false)
|
||||
if err != nil || code != 200 {
|
||||
return nil, code, err
|
||||
// getOmbiUser searches for an ombi user given a Jellyfin user ID. It looks for matching username or matching email address.
|
||||
// If "email"=nil, an email address will be acquired from the DB instead. Passing it manually is useful when changing email address.
|
||||
func (app *appContext) getOmbiUser(jfID string, email *string) (map[string]interface{}, error) {
|
||||
jfUser, err := app.jf.UserByID(jfID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
username := jfUser.Name
|
||||
email := ""
|
||||
if e, ok := app.storage.GetEmailsKey(jfID); ok {
|
||||
email = e.Addr
|
||||
if email == nil {
|
||||
addr := ""
|
||||
if e, ok := app.storage.GetEmailsKey(jfID); ok {
|
||||
addr = e.Addr
|
||||
}
|
||||
email = &addr
|
||||
}
|
||||
user, err := app.ombi.getUser(username, *email)
|
||||
return user, err
|
||||
}
|
||||
|
||||
func (ombi *OmbiWrapper) getUser(username string, email string) (map[string]interface{}, error) {
|
||||
ombiUsers, err := ombi.GetUsers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, ombiUser := range ombiUsers {
|
||||
ombiAddr := ""
|
||||
@@ -26,10 +42,36 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er
|
||||
ombiAddr = a.(string)
|
||||
}
|
||||
if ombiUser["userName"].(string) == username || (ombiAddr == email && email != "") {
|
||||
return ombiUser, code, err
|
||||
return ombiUser, err
|
||||
}
|
||||
}
|
||||
return nil, 400, fmt.Errorf("Couldn't find user")
|
||||
// Gets a generic "not found" type error
|
||||
return nil, common.GenericErr(404, err)
|
||||
}
|
||||
|
||||
// Returns a user with the given name who has been imported from Jellyfin/Emby by Ombi
|
||||
func (ombi *OmbiWrapper) getImportedUser(name string) (map[string]interface{}, error) {
|
||||
// Ombi User Types: 3/4 = Emby, 5 = Jellyfin
|
||||
ombiUsers, err := ombi.GetUsers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, ombiUser := range ombiUsers {
|
||||
if ombiUser["userName"].(string) == name {
|
||||
uType, ok := ombiUser["userType"].(int)
|
||||
if !ok { // Don't know if Ombi somehow allows duplicate usernames
|
||||
continue
|
||||
}
|
||||
if serverType == mediabrowser.JellyfinServer && uType != 5 { // Jellyfin
|
||||
continue
|
||||
} else if uType != 3 && uType != 4 { // Emby
|
||||
continue
|
||||
}
|
||||
return ombiUser, err
|
||||
}
|
||||
}
|
||||
// Gets a generic "not found" type error
|
||||
return nil, common.GenericErr(404, err)
|
||||
}
|
||||
|
||||
// @Summary Get a list of Ombi users.
|
||||
@@ -40,10 +82,9 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er
|
||||
// @Security Bearer
|
||||
// @tags Ombi
|
||||
func (app *appContext) OmbiUsers(gc *gin.Context) {
|
||||
app.debug.Println("Ombi users requested")
|
||||
users, status, err := app.ombi.GetUsers()
|
||||
if err != nil || status != 200 {
|
||||
app.err.Printf("Failed to get users from Ombi (%d): %v", status, err)
|
||||
users, err := app.ombi.GetUsers()
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUsers, lm.Ombi, err)
|
||||
respond(500, "Couldn't get users", gc)
|
||||
return
|
||||
}
|
||||
@@ -70,25 +111,21 @@ func (app *appContext) OmbiUsers(gc *gin.Context) {
|
||||
func (app *appContext) SetOmbiProfile(gc *gin.Context) {
|
||||
var req ombiUser
|
||||
gc.BindJSON(&req)
|
||||
profileName := gc.Param("profile")
|
||||
profile, ok := app.storage.profiles[profileName]
|
||||
escapedProfileName := gc.Param("profile")
|
||||
profileName, _ := url.QueryUnescape(escapedProfileName)
|
||||
profile, ok := app.storage.GetProfileKey(profileName)
|
||||
if !ok {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
template, code, err := app.ombi.TemplateByID(req.ID)
|
||||
if err != nil || code != 200 || len(template) == 0 {
|
||||
app.err.Printf("Couldn't get user from Ombi (%d): %v", code, err)
|
||||
template, err := app.ombi.TemplateByID(req.ID)
|
||||
if err != nil || len(template) == 0 {
|
||||
app.err.Printf(lm.FailedGetUsers, lm.Ombi, err)
|
||||
respond(500, "Couldn't get user", gc)
|
||||
return
|
||||
}
|
||||
profile.Ombi = template
|
||||
app.storage.profiles[profileName] = profile
|
||||
if err := app.storage.storeProfiles(); err != nil {
|
||||
respond(500, "Failed to store profile", gc)
|
||||
app.err.Printf("Failed to store profiles: %v", err)
|
||||
return
|
||||
}
|
||||
app.storage.SetProfileKey(profileName, profile)
|
||||
respondBool(204, true, gc)
|
||||
}
|
||||
|
||||
@@ -102,18 +139,137 @@ func (app *appContext) SetOmbiProfile(gc *gin.Context) {
|
||||
// @Security Bearer
|
||||
// @tags Ombi
|
||||
func (app *appContext) DeleteOmbiProfile(gc *gin.Context) {
|
||||
profileName := gc.Param("profile")
|
||||
profile, ok := app.storage.profiles[profileName]
|
||||
escapedProfileName := gc.Param("profile")
|
||||
profileName, _ := url.QueryUnescape(escapedProfileName)
|
||||
profile, ok := app.storage.GetProfileKey(profileName)
|
||||
if !ok {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
profile.Ombi = nil
|
||||
app.storage.profiles[profileName] = profile
|
||||
if err := app.storage.storeProfiles(); err != nil {
|
||||
respond(500, "Failed to store profile", gc)
|
||||
app.err.Printf("Failed to store profiles: %v", err)
|
||||
return
|
||||
}
|
||||
app.storage.SetProfileKey(profileName, profile)
|
||||
respondBool(204, true, gc)
|
||||
}
|
||||
|
||||
type OmbiWrapper struct {
|
||||
OmbiUserByJfID func(jfID string, email *string) (map[string]interface{}, error)
|
||||
*ombiLib.Ombi
|
||||
}
|
||||
|
||||
func (ombi *OmbiWrapper) applyProfile(user map[string]interface{}, profile map[string]interface{}) (err error) {
|
||||
for k, v := range profile {
|
||||
switch v.(type) {
|
||||
case map[string]interface{}, []interface{}:
|
||||
user[k] = v
|
||||
default:
|
||||
if v != user[k] {
|
||||
user[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
err = ombi.ModifyUser(user)
|
||||
return
|
||||
}
|
||||
|
||||
func (ombi *OmbiWrapper) ImportUser(jellyfinID string, req newUserDTO, profile Profile) (err error, ok bool) {
|
||||
errors, err := ombi.NewUser(req.Username, req.Password, req.Email, profile.Ombi)
|
||||
var ombiUser map[string]interface{}
|
||||
if err != nil {
|
||||
// Check if on the off chance, Ombi's user importer has already added the account.
|
||||
ombiUser, err = ombi.getImportedUser(req.Username)
|
||||
if err == nil {
|
||||
// app.info.Println(lm.Ombi + " " + lm.UserExists)
|
||||
profile.Ombi["password"] = req.Password
|
||||
err = ombi.applyProfile(ombiUser, profile.Ombi)
|
||||
if err != nil {
|
||||
err = fmt.Errorf(lm.FailedApplyProfile, lm.Ombi, req.Username, err)
|
||||
}
|
||||
} else {
|
||||
if len(errors) != 0 {
|
||||
err = fmt.Errorf("%v, %s", err, strings.Join(errors, ", "))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
func (ombi *OmbiWrapper) SetContactMethods(jellyfinID string, email *string, discord *DiscordUser, telegram *TelegramUser, contactPrefs *common.ContactPreferences) (err error) {
|
||||
ombiUser, err := ombi.OmbiUserByJfID(jellyfinID, email)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if contactPrefs == nil {
|
||||
contactPrefs = &common.ContactPreferences{
|
||||
Email: nil,
|
||||
Discord: nil,
|
||||
Telegram: nil,
|
||||
Matrix: nil,
|
||||
}
|
||||
}
|
||||
if emailEnabled && email != nil {
|
||||
ombiUser["emailAddress"] = *email
|
||||
err = ombi.ModifyUser(ombiUser)
|
||||
if err != nil {
|
||||
// FIXME: This is a little ugly, considering all other errors are unformatted
|
||||
err = fmt.Errorf(lm.FailedSetEmailAddress, lm.Ombi, jellyfinID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data := make([]ombiLib.NotificationPref, 0, 2)
|
||||
if discordEnabled {
|
||||
pref := ombiLib.NotificationPref{
|
||||
Agent: ombiLib.NotifAgentDiscord,
|
||||
UserID: ombiUser["id"].(string),
|
||||
}
|
||||
valid := false
|
||||
if contactPrefs.Discord != nil {
|
||||
pref.Enabled = *(contactPrefs.Discord)
|
||||
valid = true
|
||||
} else if discord != nil && discord.ID != "" {
|
||||
pref.Enabled = true
|
||||
valid = true
|
||||
}
|
||||
if discord != nil {
|
||||
pref.Value = discord.ID
|
||||
valid = true
|
||||
}
|
||||
if valid {
|
||||
data = append(data, pref)
|
||||
}
|
||||
}
|
||||
if telegramEnabled && telegram != nil {
|
||||
pref := ombiLib.NotificationPref{
|
||||
Agent: ombiLib.NotifAgentTelegram,
|
||||
UserID: ombiUser["id"].(string),
|
||||
}
|
||||
if contactPrefs.Telegram != nil {
|
||||
pref.Enabled = *(contactPrefs.Telegram)
|
||||
} else if telegram != nil && telegram.Username != "" {
|
||||
pref.Enabled = true
|
||||
}
|
||||
if telegram != nil {
|
||||
pref.Value = telegram.Username
|
||||
}
|
||||
data = append(data, pref)
|
||||
}
|
||||
if len(data) > 0 {
|
||||
var resp string
|
||||
resp, err = ombi.SetNotificationPrefs(ombiUser, data)
|
||||
if err != nil {
|
||||
if resp != "" {
|
||||
err = fmt.Errorf("%v, %s", err, resp)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (ombi *OmbiWrapper) Name() string { return lm.Ombi }
|
||||
|
||||
func (ombi *OmbiWrapper) Enabled(app *appContext, profile *Profile) bool {
|
||||
return profile != nil && profile.Ombi != nil && len(profile.Ombi) != 0 && app.config.Section("ombi").Key("enabled").MustBool(false)
|
||||
}
|
||||
|
||||
288
api-profiles.go
@@ -1,35 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
// @Summary Get a list of profiles
|
||||
// @Summary Get the names of all available profile.
|
||||
// @Produce json
|
||||
// @Success 200 {object} getProfileNamesDTO
|
||||
// @Router /profiles/names [get]
|
||||
// @Security Bearer
|
||||
// @tags Profiles & Settings
|
||||
func (app *appContext) GetProfileNames(gc *gin.Context) {
|
||||
fullProfileList := app.storage.GetProfiles()
|
||||
profiles := make([]string, len(fullProfileList))
|
||||
if len(profiles) != 0 {
|
||||
defaultProfile := app.storage.GetDefaultProfile()
|
||||
profiles[0] = defaultProfile.Name
|
||||
i := 1
|
||||
if len(fullProfileList) > 1 {
|
||||
app.storage.db.ForEach(badgerhold.Where("Name").Ne(profiles[0]), func(p *Profile) error {
|
||||
profiles[i] = p.Name
|
||||
i++
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
resp := getProfileNamesDTO{
|
||||
Profiles: profiles,
|
||||
}
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
// @Summary Get all available profiles, indexed by their names.
|
||||
// @Produce json
|
||||
// @Success 200 {object} getProfilesDTO
|
||||
// @Router /profiles [get]
|
||||
// @Security Bearer
|
||||
// @tags Profiles & Settings
|
||||
func (app *appContext) GetProfiles(gc *gin.Context) {
|
||||
app.storage.loadProfiles()
|
||||
app.debug.Println("Profiles requested")
|
||||
out := getProfilesDTO{
|
||||
DefaultProfile: app.storage.defaultProfile,
|
||||
DefaultProfile: app.storage.GetDefaultProfile().Name,
|
||||
Profiles: map[string]profileDTO{},
|
||||
}
|
||||
for name, p := range app.storage.profiles {
|
||||
out.Profiles[name] = profileDTO{
|
||||
Admin: p.Admin,
|
||||
LibraryAccess: p.LibraryAccess,
|
||||
FromUser: p.FromUser,
|
||||
Ombi: p.Ombi != nil,
|
||||
referralsEnabled := app.config.Section("user_page").Key("referrals").MustBool(false)
|
||||
baseInv := Invite{}
|
||||
for _, p := range app.storage.GetProfiles() {
|
||||
pdto := profileDTO{
|
||||
Admin: p.Admin,
|
||||
LibraryAccess: p.LibraryAccess,
|
||||
FromUser: p.FromUser,
|
||||
Ombi: p.Ombi != nil,
|
||||
Jellyseerr: p.Jellyseerr.Enabled,
|
||||
ReferralsEnabled: false,
|
||||
}
|
||||
if referralsEnabled {
|
||||
err := app.storage.db.Get(p.ReferralTemplateKey, &baseInv)
|
||||
if p.ReferralTemplateKey != "" && err == nil {
|
||||
pdto.ReferralsEnabled = true
|
||||
}
|
||||
}
|
||||
out.Profiles[p.Name] = pdto
|
||||
}
|
||||
gc.JSON(200, out)
|
||||
}
|
||||
|
||||
// @Summary Get the raw values stored in a profile (Configuration, Policy, Jellyseerr/Ombi if applicable, etc.).
|
||||
// @Produce json
|
||||
// @Success 200 {object} ProfileDTO
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @Param name path string true "name of profile (url encoded if necessary)"
|
||||
// @Router /profiles/raw/{name} [get]
|
||||
// @Security Bearer
|
||||
// @tags Profiles & Settings
|
||||
func (app *appContext) GetRawProfile(gc *gin.Context) {
|
||||
escapedName := gc.Param("name")
|
||||
name, err := url.QueryUnescape(escapedName)
|
||||
if err != nil {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
if profile, ok := app.storage.GetProfileKey(name); ok {
|
||||
gc.JSON(200, profile.ProfileDTO)
|
||||
return
|
||||
}
|
||||
respondBool(400, false, gc)
|
||||
}
|
||||
|
||||
// @Summary Update the raw data of a profile (Configuration, Policy, Jellyseerr/Ombi if applicable, etc.).
|
||||
// @Produce json
|
||||
// @Param ProfileDTO body ProfileDTO true "Raw profile data (all of it, do not omit anything)"
|
||||
// @Success 204 {object} boolResponse
|
||||
// @Success 201 {object} boolResponse
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @Router /profiles/raw/{name} [put]
|
||||
// @Security Bearer
|
||||
// @tags Profiles & Settings
|
||||
func (app *appContext) ReplaceRawProfile(gc *gin.Context) {
|
||||
escapedName := gc.Param("name")
|
||||
name, err := url.QueryUnescape(escapedName)
|
||||
if err != nil {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
existingProfile, ok := app.storage.GetProfileKey(name)
|
||||
if !ok {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
var req ProfileDTO
|
||||
gc.BindJSON(&req)
|
||||
existingProfile.ProfileDTO = req
|
||||
if req.Name == "" {
|
||||
req.Name = name
|
||||
}
|
||||
status := http.StatusNoContent
|
||||
app.storage.SetProfileKey(req.Name, existingProfile)
|
||||
if req.Name != name {
|
||||
// Name change
|
||||
app.storage.DeleteProfileKey(name)
|
||||
if discordEnabled {
|
||||
app.discord.UpdateCommands()
|
||||
}
|
||||
status = http.StatusCreated
|
||||
}
|
||||
respondBool(status, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Set the default profile to use.
|
||||
// @Produce json
|
||||
// @Param profileChangeDTO body profileChangeDTO true "Default profile object"
|
||||
@@ -41,21 +145,22 @@ func (app *appContext) GetProfiles(gc *gin.Context) {
|
||||
func (app *appContext) SetDefaultProfile(gc *gin.Context) {
|
||||
req := profileChangeDTO{}
|
||||
gc.BindJSON(&req)
|
||||
app.info.Printf("Setting default profile to \"%s\"", req.Name)
|
||||
if _, ok := app.storage.profiles[req.Name]; !ok {
|
||||
app.err.Printf("Profile not found: \"%s\"", req.Name)
|
||||
respond(500, "Profile not found", gc)
|
||||
app.info.Printf(lm.SetDefaultProfile, req.Name)
|
||||
if _, ok := app.storage.GetProfileKey(req.Name); !ok {
|
||||
msg := fmt.Sprintf(lm.FailedGetProfile, req.Name)
|
||||
app.err.Println(msg)
|
||||
respond(500, msg, gc)
|
||||
return
|
||||
}
|
||||
for name, profile := range app.storage.profiles {
|
||||
if name == req.Name {
|
||||
profile.Admin = true
|
||||
app.storage.profiles[name] = profile
|
||||
app.storage.db.ForEach(&badgerhold.Query{}, func(profile *Profile) error {
|
||||
if profile.Name == req.Name {
|
||||
profile.Default = true
|
||||
} else {
|
||||
profile.Admin = false
|
||||
profile.Default = false
|
||||
}
|
||||
}
|
||||
app.storage.defaultProfile = req.Name
|
||||
app.storage.SetProfileKey(profile.Name, *profile)
|
||||
return nil
|
||||
})
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -68,34 +173,50 @@ func (app *appContext) SetDefaultProfile(gc *gin.Context) {
|
||||
// @Security Bearer
|
||||
// @tags Profiles & Settings
|
||||
func (app *appContext) CreateProfile(gc *gin.Context) {
|
||||
app.info.Println("Profile creation requested")
|
||||
var req newProfileDTO
|
||||
gc.BindJSON(&req)
|
||||
app.jf.CacheExpiry = time.Now()
|
||||
user, status, err := app.jf.UserByID(req.ID, false)
|
||||
if !(status == 200 || status == 204) || err != nil {
|
||||
app.err.Printf("Failed to get user from Jellyfin (%d): %v", status, err)
|
||||
app.InvalidateJellyfinCache()
|
||||
user, err := app.jf.UserByID(req.ID, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
|
||||
respond(500, "Couldn't get user", gc)
|
||||
return
|
||||
}
|
||||
profile := Profile{
|
||||
FromUser: user.Name,
|
||||
Policy: user.Policy,
|
||||
FromUser: user.Name,
|
||||
ProfileDTO: ProfileDTO{Policy: user.Policy},
|
||||
Homescreen: req.Homescreen,
|
||||
}
|
||||
app.debug.Printf("Creating profile from user \"%s\"", user.Name)
|
||||
app.debug.Printf(lm.CreateProfileFromUser, user.Name)
|
||||
if req.Homescreen {
|
||||
profile.Configuration = user.Configuration
|
||||
profile.Displayprefs, status, err = app.jf.GetDisplayPreferences(req.ID)
|
||||
if !(status == 200 || status == 204) || err != nil {
|
||||
app.err.Printf("Failed to get DisplayPrefs (%d): %v", status, err)
|
||||
profile.Displayprefs, err = app.jf.GetDisplayPreferences(req.ID)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetJellyfinDisplayPrefs, req.ID, err)
|
||||
respond(500, "Couldn't get displayprefs", gc)
|
||||
return
|
||||
}
|
||||
}
|
||||
app.storage.loadProfiles()
|
||||
app.storage.profiles[req.Name] = profile
|
||||
app.storage.storeProfiles()
|
||||
app.storage.loadProfiles()
|
||||
if req.Jellyseerr && app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
|
||||
user, err := app.js.MustGetUser(req.ID)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUser, user.Name(), lm.Jellyseerr, err)
|
||||
} else {
|
||||
profile.Jellyseerr.User = user.UserTemplate
|
||||
n, err := app.js.GetNotificationPreferencesByID(user.ID)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetJellyseerrNotificationPrefs, strconv.FormatInt(user.ID, 10), err)
|
||||
} else {
|
||||
profile.Jellyseerr.Notifications = n.NotificationsTemplate
|
||||
profile.Jellyseerr.Enabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
app.storage.SetProfileKey(req.Name, profile)
|
||||
// Refresh discord bots, profile list
|
||||
if discordEnabled {
|
||||
app.discord.UpdateCommands()
|
||||
}
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -110,12 +231,93 @@ func (app *appContext) DeleteProfile(gc *gin.Context) {
|
||||
req := profileChangeDTO{}
|
||||
gc.BindJSON(&req)
|
||||
name := req.Name
|
||||
if _, ok := app.storage.profiles[name]; ok {
|
||||
if app.storage.defaultProfile == name {
|
||||
app.storage.defaultProfile = ""
|
||||
}
|
||||
delete(app.storage.profiles, name)
|
||||
}
|
||||
app.storage.storeProfiles()
|
||||
app.storage.DeleteProfileKey(name)
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Enable referrals for a profile, sourced from the given invite by its code.
|
||||
// @Produce json
|
||||
// @Param profile path string true "name of profile to enable referrals for."
|
||||
// @Param invite path string true "invite code to create referral template from."
|
||||
// @Param useExpiry path string true "with-expiry or none."
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 400 {object} stringResponse
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @Router /profiles/referral/{profile}/{invite}/{useExpiry} [post]
|
||||
// @Security Bearer
|
||||
// @tags Profiles & Settings
|
||||
func (app *appContext) EnableReferralForProfile(gc *gin.Context) {
|
||||
escapedProfileName := gc.Param("profile")
|
||||
profileName, err := url.QueryUnescape(escapedProfileName)
|
||||
if err != nil {
|
||||
respond(400, "Invalid profile", gc)
|
||||
app.err.Printf(lm.FailedGetProfile, profileName)
|
||||
return
|
||||
}
|
||||
invCode := gc.Param("invite")
|
||||
useExpiry := gc.Param("useExpiry") == "with-expiry"
|
||||
inv, ok := app.storage.GetInvitesKey(invCode)
|
||||
if !ok {
|
||||
respond(400, "Invalid invite code", gc)
|
||||
app.err.Printf(lm.InvalidInviteCode, invCode)
|
||||
return
|
||||
}
|
||||
profile, ok := app.storage.GetProfileKey(profileName)
|
||||
if !ok {
|
||||
respond(400, "Invalid profile", gc)
|
||||
app.err.Printf(lm.FailedGetProfile, profileName)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new code for referral template
|
||||
inv.Code = GenerateInviteCode()
|
||||
expiryDelta := inv.ValidTill.Sub(inv.Created)
|
||||
inv.Created = time.Now()
|
||||
if useExpiry {
|
||||
inv.ValidTill = inv.Created.Add(expiryDelta)
|
||||
} else {
|
||||
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
|
||||
}
|
||||
inv.IsReferral = true
|
||||
inv.UseReferralExpiry = useExpiry
|
||||
// Since this is a template for multiple users, ReferrerJellyfinID is not set.
|
||||
// inv.ReferrerJellyfinID = ...
|
||||
|
||||
app.storage.SetInvitesKey(inv.Code, inv)
|
||||
|
||||
profile.ReferralTemplateKey = inv.Code
|
||||
|
||||
app.storage.SetProfileKey(profile.Name, profile)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Disable referrals for a profile, and removes the referral template. no-op if not enabled.
|
||||
// @Produce json
|
||||
// @Param profile path string true "name of profile to enable referrals for."
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Router /profiles/referral/{profile} [delete]
|
||||
// @Security Bearer
|
||||
// @tags Profiles & Settings
|
||||
func (app *appContext) DisableReferralForProfile(gc *gin.Context) {
|
||||
escapedProfileName := gc.Param("profile")
|
||||
profileName, err := url.QueryUnescape(escapedProfileName)
|
||||
if err != nil {
|
||||
respond(400, "Invalid profile", gc)
|
||||
app.err.Printf(lm.FailedGetProfile, profileName)
|
||||
return
|
||||
}
|
||||
profile, ok := app.storage.GetProfileKey(profileName)
|
||||
if !ok {
|
||||
respondBool(200, true, gc)
|
||||
return
|
||||
}
|
||||
|
||||
app.storage.DeleteInvitesKey(profile.ReferralTemplateKey)
|
||||
|
||||
profile.ReferralTemplateKey = ""
|
||||
|
||||
app.storage.SetProfileKey(profileName, profile)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
352
api-userpage.go
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -8,6 +9,14 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/hrfee/jfa-go/jellyseerr"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
REFERRAL_EXPIRY_DAYS = 90
|
||||
)
|
||||
|
||||
// @Summary Returns the logged-in user's Jellyfin ID & Username, and other details.
|
||||
@@ -20,9 +29,9 @@ func (app *appContext) MyDetails(gc *gin.Context) {
|
||||
Id: gc.GetString("jfId"),
|
||||
}
|
||||
|
||||
user, status, err := app.jf.UserByID(resp.Id, false)
|
||||
if status != 200 || err != nil {
|
||||
app.err.Printf("Failed to get Jellyfin user (%d): %+v\n", status, err)
|
||||
user, err := app.jf.UserByID(resp.Id, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
|
||||
respond(500, "Failed to get user", gc)
|
||||
return
|
||||
}
|
||||
@@ -38,15 +47,10 @@ func (app *appContext) MyDetails(gc *gin.Context) {
|
||||
}
|
||||
resp.Disabled = user.Policy.IsDisabled
|
||||
|
||||
if exp, ok := app.storage.users[user.ID]; ok {
|
||||
resp.Expiry = exp.Unix()
|
||||
if exp, ok := app.storage.GetUserExpiryKey(user.ID); ok {
|
||||
resp.Expiry = exp.Expiry.Unix()
|
||||
}
|
||||
|
||||
app.storage.loadEmails()
|
||||
app.storage.loadDiscordUsers()
|
||||
app.storage.loadMatrixUsers()
|
||||
app.storage.loadTelegramUsers()
|
||||
|
||||
if emailEnabled {
|
||||
resp.Email = &MyDetailsContactMethodsDTO{}
|
||||
if email, ok := app.storage.GetEmailsKey(user.ID); ok && email.Addr != "" {
|
||||
@@ -79,12 +83,31 @@ func (app *appContext) MyDetails(gc *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if app.config.Section("user_page").Key("referrals").MustBool(false) {
|
||||
// 1. Look for existing template bound to this Jellyfin ID
|
||||
// If one exists, that means its just for us and so we
|
||||
// can use it directly.
|
||||
inv := Invite{}
|
||||
err := app.storage.db.FindOne(&inv, badgerhold.Where("ReferrerJellyfinID").Eq(resp.Id))
|
||||
if err == nil {
|
||||
resp.HasReferrals = true
|
||||
} else {
|
||||
// 2. Look for a template matching the key found in the user storage
|
||||
// Since this key is shared between users in a profile, we make a copy.
|
||||
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
|
||||
err = app.storage.db.Get(user.ReferralTemplateKey, &inv)
|
||||
if ok && err == nil {
|
||||
resp.HasReferrals = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
// @Summary Sets whether to notify yourself through telegram/discord/matrix/email or not.
|
||||
// @Produce json
|
||||
// @Param SetContactMethodsDTO body SetContactMethodsDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
|
||||
// @Param SetContactPreferencesDTO body SetContactPreferencesDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Success 400 {object} boolResponse
|
||||
// @Success 500 {object} boolResponse
|
||||
@@ -92,14 +115,14 @@ func (app *appContext) MyDetails(gc *gin.Context) {
|
||||
// @Security Bearer
|
||||
// @tags User Page
|
||||
func (app *appContext) SetMyContactMethods(gc *gin.Context) {
|
||||
var req SetContactMethodsDTO
|
||||
var req SetContactPreferencesDTO
|
||||
gc.BindJSON(&req)
|
||||
req.ID = gc.GetString("jfId")
|
||||
if req.ID == "" {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
app.setContactMethods(req, gc)
|
||||
app.setContactPreferences(req, gc)
|
||||
}
|
||||
|
||||
// @Summary Logout by deleting refresh token from cookies.
|
||||
@@ -112,8 +135,9 @@ func (app *appContext) SetMyContactMethods(gc *gin.Context) {
|
||||
func (app *appContext) LogoutUser(gc *gin.Context) {
|
||||
cookie, err := gc.Cookie("user-refresh")
|
||||
if err != nil {
|
||||
app.debug.Printf("Couldn't get cookies: %s", err)
|
||||
respond(500, "Couldn't fetch cookies", gc)
|
||||
msg := fmt.Sprintf(lm.FailedGetCookies, "user-refresh", err)
|
||||
app.debug.Println(msg)
|
||||
respond(500, msg, gc)
|
||||
return
|
||||
}
|
||||
app.invalidTokens = append(app.invalidTokens, cookie)
|
||||
@@ -140,9 +164,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
|
||||
var target ConfirmationTarget
|
||||
var id string
|
||||
fail := func() {
|
||||
gcHTML(gc, 404, "404.html", gin.H{
|
||||
"cssClass": app.cssClass,
|
||||
"cssVersion": cssVersion,
|
||||
app.gcHTML(gc, 404, "404.html", OtherPage, "en-us", gin.H{
|
||||
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
|
||||
})
|
||||
}
|
||||
@@ -153,21 +175,21 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
|
||||
}
|
||||
token, err := jwt.Parse(key, checkToken)
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to parse key: %s", err)
|
||||
app.err.Printf(lm.FailedParseJWT, err)
|
||||
fail()
|
||||
// respond(500, "unknownError", gc)
|
||||
return
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
app.err.Printf("Failed to parse key: %s", err)
|
||||
app.err.Println(lm.FailedCastJWT)
|
||||
fail()
|
||||
// respond(500, "unknownError", gc)
|
||||
return
|
||||
}
|
||||
expiry := time.Unix(int64(claims["exp"].(float64)), 0)
|
||||
if !(ok && token.Valid && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) {
|
||||
app.err.Printf("Invalid key")
|
||||
app.err.Println(lm.InvalidJWT)
|
||||
fail()
|
||||
// respond(400, "invalidKey", gc)
|
||||
return
|
||||
@@ -177,31 +199,22 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
|
||||
|
||||
// Perform an Action
|
||||
if target == NoOp {
|
||||
gc.Redirect(http.StatusSeeOther, "/my/account")
|
||||
gc.Redirect(http.StatusSeeOther, MustGetNonEmptyURL(PAGES.MyAccount))
|
||||
return
|
||||
} else if target == UserEmailChange {
|
||||
emailStore, ok := app.storage.GetEmailsKey(id)
|
||||
if !ok {
|
||||
emailStore = EmailAddress{
|
||||
Contact: true,
|
||||
}
|
||||
}
|
||||
emailStore.Addr = claims["email"].(string)
|
||||
app.storage.SetEmailsKey(id, emailStore)
|
||||
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
||||
ombiUser, code, err := app.getOmbiUser(id)
|
||||
if code == 200 && err == nil {
|
||||
ombiUser["emailAddress"] = claims["email"].(string)
|
||||
code, err = app.ombi.ModifyUser(ombiUser)
|
||||
if code != 200 || err != nil {
|
||||
app.err.Printf("%s: Failed to change ombi email address (%d): %v", ombiUser["userName"].(string), code, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
app.modifyEmail(id, claims["email"].(string))
|
||||
|
||||
app.storage.storeEmails()
|
||||
app.info.Println("Email list modified")
|
||||
gc.Redirect(http.StatusSeeOther, "/my/account")
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactLinked,
|
||||
UserID: id,
|
||||
SourceType: ActivityUser,
|
||||
Source: id,
|
||||
Value: "email",
|
||||
Time: time.Now(),
|
||||
}, gc, true)
|
||||
|
||||
app.info.Printf(lm.UserEmailAdjusted, id)
|
||||
gc.Redirect(http.StatusSeeOther, MustGetNonEmptyURL(PAGES.MyAccount))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -219,7 +232,6 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
|
||||
func (app *appContext) ModifyMyEmail(gc *gin.Context) {
|
||||
var req ModifyMyEmailDTO
|
||||
gc.BindJSON(&req)
|
||||
app.debug.Println("Email modification requested")
|
||||
if !strings.ContainsRune(req.Email, '@') {
|
||||
respond(400, "Invalid Email Address", gc)
|
||||
return
|
||||
@@ -239,26 +251,26 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
|
||||
key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET")))
|
||||
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to generate confirmation token: %v", err)
|
||||
app.err.Printf(lm.FailedSignJWT, err)
|
||||
respond(500, "errorUnknown", gc)
|
||||
return
|
||||
}
|
||||
|
||||
if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) {
|
||||
user, status, err := app.jf.UserByID(id, false)
|
||||
user, err := app.jf.UserByID(id, false)
|
||||
name := ""
|
||||
if status == 200 && err == nil {
|
||||
if err == nil {
|
||||
name = user.Name
|
||||
}
|
||||
app.debug.Printf("%s: Email confirmation required", id)
|
||||
app.debug.Printf(lm.EmailConfirmationRequired, id)
|
||||
respond(401, "confirmEmail", gc)
|
||||
msg, err := app.email.constructConfirmation("", name, key, app, false)
|
||||
msg, err := app.email.constructConfirmation("", name, key, false)
|
||||
if err != nil {
|
||||
app.err.Printf("%s: Failed to construct confirmation email: %v", name, err)
|
||||
app.err.Printf(lm.FailedConstructConfirmationEmail, id, err)
|
||||
} else if err := app.email.send(msg, req.Email); err != nil {
|
||||
app.err.Printf("%s: Failed to send user confirmation email: %v", name, err)
|
||||
app.err.Printf(lm.FailedSendConfirmationEmail, id, req.Email, err)
|
||||
} else {
|
||||
app.info.Printf("%s: Sent user confirmation email to \"%s\"", name, req.Email)
|
||||
app.info.Printf(lm.SentConfirmationEmail, id, req.Email)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -278,7 +290,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
|
||||
// @Security Bearer
|
||||
// @tags User Page
|
||||
func (app *appContext) MyDiscordServerInvite(gc *gin.Context) {
|
||||
if app.discord.inviteChannelName == "" {
|
||||
if app.discord.InviteChannel.Name == "" {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
@@ -326,7 +338,7 @@ func (app *appContext) GetMyPIN(gc *gin.Context) {
|
||||
func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
|
||||
pin := gc.Param("pin")
|
||||
dcUser, ok := app.discord.AssignedUserVerified(pin, gc.GetString("jfId"))
|
||||
app.discord.DeleteVerifiedUser(pin)
|
||||
app.discord.DeleteVerifiedToken(pin)
|
||||
if !ok {
|
||||
respondBool(200, false, gc)
|
||||
return
|
||||
@@ -341,6 +353,23 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
|
||||
dcUser.Contact = existingUser.Contact
|
||||
}
|
||||
app.storage.SetDiscordKey(gc.GetString("jfId"), dcUser)
|
||||
|
||||
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
|
||||
jellyseerr.FieldDiscord: dcUser.ID,
|
||||
jellyseerr.FieldDiscordEnabled: dcUser.Contact,
|
||||
}); err != nil {
|
||||
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactLinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "discord",
|
||||
Time: time.Now(),
|
||||
}, gc, true)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -365,9 +394,11 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
|
||||
return
|
||||
}
|
||||
tgUser := TelegramUser{
|
||||
ChatID: token.ChatID,
|
||||
Username: token.Username,
|
||||
Contact: true,
|
||||
TelegramVerifiedToken: TelegramVerifiedToken{
|
||||
ChatID: token.ChatID,
|
||||
Username: token.Username,
|
||||
},
|
||||
Contact: true,
|
||||
}
|
||||
if lang, ok := app.telegram.languages[tgUser.ChatID]; ok {
|
||||
tgUser.Lang = lang
|
||||
@@ -379,6 +410,23 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
|
||||
tgUser.Contact = existingUser.Contact
|
||||
}
|
||||
app.storage.SetTelegramKey(gc.GetString("jfId"), tgUser)
|
||||
|
||||
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
|
||||
jellyseerr.FieldTelegram: tgUser.ChatID,
|
||||
jellyseerr.FieldTelegramEnabled: tgUser.Contact,
|
||||
}); err != nil {
|
||||
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactLinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "telegram",
|
||||
Time: time.Now(),
|
||||
}, gc, true)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -431,12 +479,12 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
|
||||
pin := gc.Param("pin")
|
||||
user, ok := app.matrix.tokens[pin]
|
||||
if !ok {
|
||||
app.debug.Println("Matrix: PIN not found")
|
||||
app.debug.Printf(lm.InvalidPIN, pin)
|
||||
respondBool(200, false, gc)
|
||||
return
|
||||
}
|
||||
if user.User.UserID != userID {
|
||||
app.debug.Println("Matrix: User ID of PIN didn't match")
|
||||
app.debug.Printf(lm.UnauthorizedPIN, pin)
|
||||
respondBool(200, false, gc)
|
||||
return
|
||||
}
|
||||
@@ -450,6 +498,16 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
|
||||
}
|
||||
|
||||
app.storage.SetMatrixKey(gc.GetString("jfId"), mxUser)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactLinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "matrix",
|
||||
Time: time.Now(),
|
||||
}, gc, true)
|
||||
|
||||
delete(app.matrix.tokens, pin)
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
@@ -462,6 +520,23 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
|
||||
// @Tags User Page
|
||||
func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
|
||||
app.storage.DeleteDiscordKey(gc.GetString("jfId"))
|
||||
|
||||
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
|
||||
jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier,
|
||||
jellyseerr.FieldDiscordEnabled: false,
|
||||
}); err != nil {
|
||||
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "discord",
|
||||
Time: time.Now(),
|
||||
}, gc, true)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -473,6 +548,23 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
|
||||
// @Tags User Page
|
||||
func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
|
||||
app.storage.DeleteTelegramKey(gc.GetString("jfId"))
|
||||
|
||||
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
|
||||
jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier,
|
||||
jellyseerr.FieldTelegramEnabled: false,
|
||||
}); err != nil {
|
||||
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "telegram",
|
||||
Time: time.Now(),
|
||||
}, gc, true)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -484,6 +576,16 @@ func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
|
||||
// @Tags User Page
|
||||
func (app *appContext) UnlinkMyMatrix(gc *gin.Context) {
|
||||
app.storage.DeleteMatrixKey(gc.GetString("jfId"))
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "matrix",
|
||||
Time: time.Now(),
|
||||
}, gc, true)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -501,9 +603,11 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
|
||||
cancel := time.AfterFunc(1*time.Second, func() {
|
||||
timerWait <- true
|
||||
})
|
||||
usernameAllowed := app.config.Section("user_page").Key("allow_pwr_username").MustBool(true)
|
||||
emailAllowed := app.config.Section("user_page").Key("allow_pwr_email").MustBool(true)
|
||||
contactMethodAllowed := app.config.Section("user_page").Key("allow_pwr_contact_method").MustBool(true)
|
||||
address := gc.Param("address")
|
||||
if address == "" {
|
||||
app.debug.Println("Ignoring empty request for PWR")
|
||||
cancel.Stop()
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
@@ -511,9 +615,9 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
|
||||
var pwr InternalPWR
|
||||
var err error
|
||||
|
||||
jfID := app.ReverseUserSearch(address)
|
||||
if jfID == "" {
|
||||
app.debug.Printf("Ignoring PWR request: User not found")
|
||||
jfUser, ok := app.ReverseUserSearch(address, usernameAllowed, emailAllowed, contactMethodAllowed)
|
||||
if !ok {
|
||||
app.debug.Printf(lm.FailedGetUsers, lm.Jellyfin, "no results")
|
||||
|
||||
for range timerWait {
|
||||
respondBool(204, true, gc)
|
||||
@@ -521,9 +625,9 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
|
||||
}
|
||||
return
|
||||
}
|
||||
pwr, err = app.GenInternalReset(jfID)
|
||||
pwr, err = app.GenInternalReset(jfUser.ID)
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to get user from Jellyfin: %v", err)
|
||||
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
|
||||
for range timerWait {
|
||||
respondBool(204, true, gc)
|
||||
return
|
||||
@@ -541,19 +645,19 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
|
||||
Username: pwr.Username,
|
||||
Expiry: pwr.Expiry,
|
||||
Internal: true,
|
||||
}, app, false,
|
||||
}, false,
|
||||
)
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to construct password reset message for \"%s\": %v", pwr.Username, err)
|
||||
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
|
||||
for range timerWait {
|
||||
respondBool(204, true, gc)
|
||||
return
|
||||
}
|
||||
return
|
||||
} else if err := app.sendByID(msg, jfID); err != nil {
|
||||
app.err.Printf("Failed to send password reset message to \"%s\": %v", address, err)
|
||||
} else if err := app.sendByID(msg, jfUser.ID); err != nil {
|
||||
app.err.Printf(lm.FailedSendPWRMessage, pwr.Username, "?", err)
|
||||
} else {
|
||||
app.info.Printf("Sent password reset message to \"%s\"", address)
|
||||
app.info.Printf(lm.SentPWRMessage, pwr.Username, "?")
|
||||
}
|
||||
for range timerWait {
|
||||
respondBool(204, true, gc)
|
||||
@@ -580,42 +684,50 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
|
||||
validation := app.validator.validate(req.New)
|
||||
for _, val := range validation {
|
||||
if !val {
|
||||
app.debug.Printf("%s: Change password failed: Invalid password", gc.GetString("jfId"))
|
||||
gc.JSON(400, validation)
|
||||
return
|
||||
}
|
||||
}
|
||||
user, status, err := app.jf.UserByID(gc.GetString("jfId"), false)
|
||||
if status != 200 || err != nil {
|
||||
app.err.Printf("Failed to change password: couldn't find user (%d): %+v", status, err)
|
||||
user, err := app.jf.UserByID(gc.GetString("jfId"), false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUser, gc.GetString("jfId"), lm.Jellyfin, err)
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
// Authenticate as user to confirm old password.
|
||||
user, status, err = app.authJf.Authenticate(user.Name, req.Old)
|
||||
if status != 200 || err != nil {
|
||||
user, err = app.authJf.Authenticate(user.Name, req.Old)
|
||||
if err != nil {
|
||||
respondBool(401, false, gc)
|
||||
return
|
||||
}
|
||||
status, err = app.jf.SetPassword(gc.GetString("jfId"), req.Old, req.New)
|
||||
if (status != 200 && status != 204) || err != nil {
|
||||
err = app.jf.SetPassword(gc.GetString("jfId"), req.Old, req.New)
|
||||
if err != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityChangePassword,
|
||||
UserID: user.ID,
|
||||
SourceType: ActivityUser,
|
||||
Source: user.ID,
|
||||
Time: time.Now(),
|
||||
}, gc, true)
|
||||
|
||||
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
||||
func() {
|
||||
ombiUser, status, err := app.getOmbiUser(gc.GetString("jfId"))
|
||||
if status != 200 || err != nil {
|
||||
app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", user.Name, status, err)
|
||||
ombiUser, err := app.getOmbiUser(gc.GetString("jfId"), nil)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUser, user.Name, lm.Ombi, err)
|
||||
return
|
||||
}
|
||||
ombiUser["password"] = req.New
|
||||
status, err = app.ombi.ModifyUser(ombiUser)
|
||||
if status != 200 || err != nil {
|
||||
app.err.Printf("Failed to set password for ombi user \"%s\" (%d): %v", ombiUser["userName"], status, err)
|
||||
err = app.ombi.ModifyUser(ombiUser)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedChangePassword, lm.Ombi, ombiUser["userName"], err)
|
||||
return
|
||||
}
|
||||
app.debug.Printf("Reset password for ombi user \"%s\"", ombiUser["userName"])
|
||||
app.debug.Printf(lm.ChangePassword, lm.Ombi, ombiUser["userName"])
|
||||
}()
|
||||
}
|
||||
cookie, err := gc.Cookie("user-refresh")
|
||||
@@ -623,7 +735,75 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
|
||||
app.invalidTokens = append(app.invalidTokens, cookie)
|
||||
gc.SetCookie("refresh", "invalid", -1, "/my", gc.Request.URL.Hostname(), true, true)
|
||||
} else {
|
||||
app.debug.Printf("Couldn't get cookies: %s", err)
|
||||
app.debug.Printf(lm.FailedGetCookies, "user-refresh", err)
|
||||
}
|
||||
respondBool(204, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Get or generate a new referral code.
|
||||
// @Produce json
|
||||
// @Success 200 {object} GetMyReferralRespDTO
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @Failure 401 {object} boolResponse
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Router /my/referral [get]
|
||||
// @Security Bearer
|
||||
// @Tags User Page
|
||||
func (app *appContext) GetMyReferral(gc *gin.Context) {
|
||||
// 1. Look for existing template bound to this Jellyfin ID
|
||||
// If one exists, that means its just for us and so we
|
||||
// can use it directly.
|
||||
inv := Invite{}
|
||||
err := app.storage.db.FindOne(&inv, badgerhold.Where("ReferrerJellyfinID").Eq(gc.GetString("jfId")))
|
||||
if err != nil {
|
||||
// 2. Look for a template matching the key found in the user storage
|
||||
// Since this key is shared between users in a profile, we make a copy.
|
||||
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
|
||||
err = app.storage.db.Get(user.ReferralTemplateKey, &inv)
|
||||
if !ok || err != nil || user.ReferralTemplateKey == "" {
|
||||
app.debug.Printf(lm.FailedGetReferralTemplate, user.ReferralTemplateKey, err)
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
inv.Code = GenerateInviteCode()
|
||||
expiryDelta := inv.ValidTill.Sub(inv.Created)
|
||||
inv.Created = time.Now()
|
||||
if inv.UseReferralExpiry {
|
||||
inv.ValidTill = inv.Created.Add(expiryDelta)
|
||||
} else {
|
||||
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
|
||||
}
|
||||
inv.IsReferral = true
|
||||
inv.ReferrerJellyfinID = gc.GetString("jfId")
|
||||
app.storage.SetInvitesKey(inv.Code, inv)
|
||||
} else if time.Now().After(inv.ValidTill) {
|
||||
// 3. We found an invite for us, but it's expired.
|
||||
// We delete it from storage, and put it back with a fresh code and expiry.
|
||||
// If UseReferralExpiry is enabled, we delete it and return nothing.
|
||||
app.storage.DeleteInvitesKey(inv.Code)
|
||||
if inv.UseReferralExpiry {
|
||||
app.debug.Printf(lm.DeleteOldReferral, inv.Code)
|
||||
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
|
||||
if ok {
|
||||
user.ReferralTemplateKey = ""
|
||||
app.storage.SetEmailsKey(gc.GetString("jfId"), user)
|
||||
}
|
||||
app.debug.Printf("Ignoring referral request, expired.")
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
app.debug.Printf(lm.RenewOldReferral, inv.Code)
|
||||
inv.Code = GenerateInviteCode()
|
||||
inv.Created = time.Now()
|
||||
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
|
||||
app.storage.SetInvitesKey(inv.Code, inv)
|
||||
}
|
||||
app.InvalidateWebUserCache()
|
||||
gc.JSON(200, GetMyReferralRespDTO{
|
||||
Code: inv.Code,
|
||||
RemainingUses: inv.RemainingUses,
|
||||
NoLimit: inv.NoLimit,
|
||||
Expiry: inv.ValidTill.Unix(),
|
||||
UseExpiry: inv.UseReferralExpiry,
|
||||
})
|
||||
}
|
||||
|
||||
1604
api-users.go
294
api.go
@@ -1,12 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hrfee/jfa-go/common"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/itchyny/timefmt-go"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
@@ -32,23 +36,14 @@ func respondBool(code int, val bool, gc *gin.Context) {
|
||||
gc.Abort()
|
||||
}
|
||||
|
||||
func (app *appContext) loadStrftime() {
|
||||
app.datePattern = app.config.Section("messages").Key("date_format").String()
|
||||
app.timePattern = `%H:%M`
|
||||
if val, _ := app.config.Section("messages").Key("use_24h").Bool(); !val {
|
||||
app.timePattern = `%I:%M %p`
|
||||
}
|
||||
func prettyTime(dt time.Time) (date, time string) {
|
||||
date = timefmt.Format(dt, datePattern)
|
||||
time = timefmt.Format(dt, timePattern)
|
||||
return
|
||||
}
|
||||
|
||||
func (app *appContext) prettyTime(dt time.Time) (date, time string) {
|
||||
date = timefmt.Format(dt, app.datePattern)
|
||||
time = timefmt.Format(dt, app.timePattern)
|
||||
return
|
||||
}
|
||||
|
||||
func (app *appContext) formatDatetime(dt time.Time) string {
|
||||
d, t := app.prettyTime(dt)
|
||||
func formatDatetime(dt time.Time) string {
|
||||
d, t := prettyTime(dt)
|
||||
return d + " " + t
|
||||
}
|
||||
|
||||
@@ -113,6 +108,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
|
||||
var req ResetPasswordDTO
|
||||
gc.BindJSON(&req)
|
||||
validation := app.validator.validate(req.Password)
|
||||
captcha := app.config.Section("captcha").Key("enabled").MustBool(false)
|
||||
valid := true
|
||||
for _, val := range validation {
|
||||
if !val {
|
||||
@@ -120,35 +116,42 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
|
||||
}
|
||||
}
|
||||
if !valid || req.PIN == "" {
|
||||
// 200 bcs idk what i did in js
|
||||
app.info.Printf("%s: Password reset failed: Invalid password", req.PIN)
|
||||
app.info.Printf(lm.FailedChangePassword, lm.Jellyfin, "?", lm.InvalidPassword)
|
||||
gc.JSON(400, validation)
|
||||
return
|
||||
}
|
||||
isInternal := false
|
||||
|
||||
if captcha && !app.verifyCaptcha(req.PIN, req.PIN, req.CaptchaText, true) {
|
||||
app.info.Printf(lm.FailedChangePassword, lm.Jellyfin, "?", lm.IncorrectCaptcha)
|
||||
respond(400, "errorCaptcha", gc)
|
||||
return
|
||||
}
|
||||
|
||||
var userID, username string
|
||||
if reset, ok := app.internalPWRs[req.PIN]; ok {
|
||||
isInternal = true
|
||||
if time.Now().After(reset.Expiry) {
|
||||
app.info.Printf("Password reset failed: PIN \"%s\" has expired", reset.PIN)
|
||||
app.info.Printf(lm.FailedChangePassword, lm.Jellyfin, "?", fmt.Sprintf(lm.ExpiredPIN, reset.PIN))
|
||||
respondBool(401, false, gc)
|
||||
delete(app.internalPWRs, req.PIN)
|
||||
return
|
||||
}
|
||||
userID = reset.ID
|
||||
username = reset.Username
|
||||
status, err := app.jf.ResetPasswordAdmin(userID)
|
||||
if !(status == 200 || status == 204) || err != nil {
|
||||
app.err.Printf("Password Reset failed (%d): %v", status, err)
|
||||
respondBool(status, false, gc)
|
||||
|
||||
err := app.jf.ResetPasswordAdmin(userID)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedChangePassword, lm.Jellyfin, userID, err)
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
delete(app.internalPWRs, req.PIN)
|
||||
} else {
|
||||
resp, status, err := app.jf.ResetPassword(req.PIN)
|
||||
if status != 200 || err != nil || !resp.Success {
|
||||
app.err.Printf("Password Reset failed (%d): %v", status, err)
|
||||
respondBool(status, false, gc)
|
||||
resp, err := app.jf.ResetPassword(req.PIN)
|
||||
if err != nil || !resp.Success {
|
||||
app.err.Printf(lm.FailedChangePassword, lm.Jellyfin, userID, err)
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
if req.Password == "" || len(resp.UsersReset) == 0 {
|
||||
@@ -157,216 +160,152 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
|
||||
}
|
||||
username = resp.UsersReset[0]
|
||||
}
|
||||
|
||||
var user mediabrowser.User
|
||||
var status int
|
||||
var err error
|
||||
if isInternal {
|
||||
user, status, err = app.jf.UserByID(userID, false)
|
||||
user, err = app.jf.UserByID(userID, false)
|
||||
} else {
|
||||
user, status, err = app.jf.UserByName(username, false)
|
||||
user, err = app.jf.UserByName(username, false)
|
||||
}
|
||||
if status != 200 || err != nil {
|
||||
app.err.Printf("Failed to get user \"%s\" (%d): %v", username, status, err)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUser, userID, lm.Jellyfin, err)
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityResetPassword,
|
||||
UserID: user.ID,
|
||||
SourceType: ActivityUser,
|
||||
Source: user.ID,
|
||||
Time: time.Now(),
|
||||
}, gc, true)
|
||||
|
||||
prevPassword := req.PIN
|
||||
if isInternal {
|
||||
prevPassword = ""
|
||||
}
|
||||
status, err = app.jf.SetPassword(user.ID, prevPassword, req.Password)
|
||||
if !(status == 200 || status == 204) || err != nil {
|
||||
app.err.Printf("Failed to change password for \"%s\" (%d): %v", username, status, err)
|
||||
err = app.jf.SetPassword(user.ID, prevPassword, req.Password)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedChangePassword, lm.Jellyfin, user.ID, err)
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
||||
// Silently fail for changing ombi passwords
|
||||
if status != 200 || err != nil {
|
||||
app.err.Printf("Failed to get user \"%s\" from jellyfin/emby (%d): %v", username, status, err)
|
||||
// This makes no sense so has been commented out.
|
||||
// It probably did at some point in the past.
|
||||
/* Silently fail for changing ombi passwords
|
||||
if (status != 200 && status != 204) || err != nil {
|
||||
app.err.Printf(lm.FailedGetUser, user.ID, lm.Jellyfin, err)
|
||||
respondBool(200, true, gc)
|
||||
return
|
||||
}
|
||||
ombiUser, status, err := app.getOmbiUser(user.ID)
|
||||
if status != 200 || err != nil {
|
||||
app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", username, status, err)
|
||||
} */
|
||||
ombiUser, err := app.getOmbiUser(user.ID, nil)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUser, user.ID, lm.Ombi, err)
|
||||
respondBool(200, true, gc)
|
||||
return
|
||||
}
|
||||
ombiUser["password"] = req.Password
|
||||
status, err = app.ombi.ModifyUser(ombiUser)
|
||||
if status != 200 || err != nil {
|
||||
app.err.Printf("Failed to set password for ombi user \"%s\" (%d): %v", ombiUser["userName"], status, err)
|
||||
err = app.ombi.ModifyUser(ombiUser)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedChangePassword, lm.Ombi, user.ID, err)
|
||||
respondBool(200, true, gc)
|
||||
return
|
||||
}
|
||||
app.debug.Printf("Reset password for ombi user \"%s\"", ombiUser["userName"])
|
||||
app.debug.Printf(lm.ChangePassword, lm.Ombi, user.ID)
|
||||
}
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Get jfa-go configuration.
|
||||
// @Produce json
|
||||
// @Success 200 {object} settings "Uses the same format as config-base.json"
|
||||
// @Success 200 {object} common.Config "Uses the same format as config-base.json"
|
||||
// @Router /config [get]
|
||||
// @Security Bearer
|
||||
// @tags Configuration
|
||||
func (app *appContext) GetConfig(gc *gin.Context) {
|
||||
app.info.Println("Config requested")
|
||||
resp := app.configBase
|
||||
// Load language options
|
||||
formOptions := app.storage.lang.User.getOptions()
|
||||
fl := resp.Sections["ui"].Settings["language-form"]
|
||||
fl.Options = formOptions
|
||||
fl.Value = app.config.Section("ui").Key("language-form").MustString("en-us")
|
||||
pwrOptions := app.storage.lang.PasswordReset.getOptions()
|
||||
pl := resp.Sections["password_resets"].Settings["language"]
|
||||
pl.Options = pwrOptions
|
||||
pl.Value = app.config.Section("password_resets").Key("language").MustString("en-us")
|
||||
adminOptions := app.storage.lang.Admin.getOptions()
|
||||
al := resp.Sections["ui"].Settings["language-admin"]
|
||||
al.Options = adminOptions
|
||||
al.Value = app.config.Section("ui").Key("language-admin").MustString("en-us")
|
||||
emailOptions := app.storage.lang.Email.getOptions()
|
||||
el := resp.Sections["email"].Settings["language"]
|
||||
el.Options = emailOptions
|
||||
el.Value = app.config.Section("email").Key("language").MustString("en-us")
|
||||
telegramOptions := app.storage.lang.Email.getOptions()
|
||||
tl := resp.Sections["telegram"].Settings["language"]
|
||||
tl.Options = telegramOptions
|
||||
tl.Value = app.config.Section("telegram").Key("language").MustString("en-us")
|
||||
if updater == "" {
|
||||
delete(resp.Sections, "updates")
|
||||
for i, v := range resp.Order {
|
||||
if v == "updates" {
|
||||
resp.Order = append(resp.Order[:i], resp.Order[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if PLATFORM == "windows" {
|
||||
delete(resp.Sections["smtp"].Settings, "ssl_cert")
|
||||
for i, v := range resp.Sections["smtp"].Order {
|
||||
if v == "ssl_cert" {
|
||||
sect := resp.Sections["smtp"]
|
||||
sect.Order = append(sect.Order[:i], sect.Order[i+1:]...)
|
||||
resp.Sections["smtp"] = sect
|
||||
}
|
||||
}
|
||||
}
|
||||
if !MatrixE2EE() {
|
||||
delete(resp.Sections["matrix"].Settings, "encryption")
|
||||
for i, v := range resp.Sections["matrix"].Order {
|
||||
if v == "encryption" {
|
||||
sect := resp.Sections["matrix"]
|
||||
sect.Order = append(sect.Order[:i], sect.Order[i+1:]...)
|
||||
resp.Sections["matrix"] = sect
|
||||
}
|
||||
}
|
||||
}
|
||||
for sectName, section := range resp.Sections {
|
||||
for settingName, setting := range section.Settings {
|
||||
val := app.config.Section(sectName).Key(settingName)
|
||||
s := resp.Sections[sectName].Settings[settingName]
|
||||
switch setting.Type {
|
||||
case "text", "email", "select", "password", "note":
|
||||
s.Value = val.MustString("")
|
||||
case "number":
|
||||
s.Value = val.MustInt(0)
|
||||
case "bool":
|
||||
s.Value = val.MustBool(false)
|
||||
}
|
||||
resp.Sections[sectName].Settings[settingName] = s
|
||||
}
|
||||
}
|
||||
if discordEnabled {
|
||||
r, err := app.discord.ListRoles()
|
||||
if err == nil {
|
||||
roles := make([][2]string, len(r)+1)
|
||||
roles[0] = [2]string{"", "None"}
|
||||
for i, role := range r {
|
||||
roles[i+1] = role
|
||||
}
|
||||
s := resp.Sections["discord"].Settings["apply_role"]
|
||||
s.Options = roles
|
||||
resp.Sections["discord"].Settings["apply_role"] = s
|
||||
}
|
||||
app.PatchConfigDiscordRoles()
|
||||
}
|
||||
|
||||
resp.Sections["ui"].Settings["language-form"] = fl
|
||||
resp.Sections["ui"].Settings["language-admin"] = al
|
||||
resp.Sections["email"].Settings["language"] = el
|
||||
resp.Sections["password_resets"].Settings["language"] = pl
|
||||
resp.Sections["telegram"].Settings["language"] = tl
|
||||
resp.Sections["discord"].Settings["language"] = tl
|
||||
resp.Sections["matrix"].Settings["language"] = tl
|
||||
|
||||
// if setting := resp.Sections["invite_emails"].Settings["url_base"]; setting.Value == "" {
|
||||
// setting.Value = strings.TrimSuffix(resp.Sections["password_resets"].Settings["url_base"].Value.(string), "/invite")
|
||||
// resp.Sections["invite_emails"].Settings["url_base"] = setting
|
||||
// }
|
||||
// if setting := resp.Sections["password_resets"].Settings["url_base"]; setting.Value == "" {
|
||||
// setting.Value = strings.TrimSuffix(resp.Sections["invite_emails"].Settings["url_base"].Value.(string), "/invite")
|
||||
// resp.Sections["password_resets"].Settings["url_base"] = setting
|
||||
// }
|
||||
|
||||
gc.JSON(200, resp)
|
||||
gc.JSON(200, app.patchedConfig)
|
||||
}
|
||||
|
||||
// @Summary Modify app config.
|
||||
// @Produce json
|
||||
// @Param appConfig body configDTO true "Config split into sections as in config.ini, all values as strings."
|
||||
// @Param appConfig body configDTO true "Config split into sections as in config.ini, all values as strings (lists split with | delimiter)."
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @Router /config [post]
|
||||
// @Security Bearer
|
||||
// @tags Configuration
|
||||
func (app *appContext) ModifyConfig(gc *gin.Context) {
|
||||
app.info.Println("Config modification requested")
|
||||
var req configDTO
|
||||
gc.BindJSON(&req)
|
||||
// Load a new config, as we set various default values in app.config that shouldn't be stored.
|
||||
tempConfig, _ := ini.Load(app.configPath)
|
||||
for section, settings := range req {
|
||||
if section != "restart-program" {
|
||||
_, err := tempConfig.GetSection(section)
|
||||
tempConfig, _ := ini.ShadowLoad(app.configPath)
|
||||
for _, section := range app.configBase.Sections {
|
||||
ns, ok := req[section.Section]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
newSection := ns.(map[string]any)
|
||||
iniSection, err := tempConfig.GetSection(section.Section)
|
||||
if err != nil {
|
||||
iniSection, err = tempConfig.NewSection(section.Section)
|
||||
if err != nil {
|
||||
tempConfig.NewSection(section)
|
||||
app.err.Printf(lm.FailedModifyConfig, app.configPath, err)
|
||||
respond(500, err.Error(), gc)
|
||||
return
|
||||
}
|
||||
for setting, value := range settings.(map[string]interface{}) {
|
||||
if section == "email" && setting == "method" && value == "disabled" {
|
||||
value = ""
|
||||
}
|
||||
if (section == "discord" || section == "matrix") && setting == "language" {
|
||||
tempConfig.Section("telegram").Key("language").SetValue(value.(string))
|
||||
} else if value.(string) != app.config.Section(section).Key(setting).MustString("") {
|
||||
tempConfig.Section(section).Key(setting).SetValue(value.(string))
|
||||
}
|
||||
for _, setting := range section.Settings {
|
||||
newValue, ok := newSection[setting.Setting]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Patch disabled to actually be an empty string
|
||||
if section.Section == "email" && setting.Setting == "method" && newValue == "disabled" {
|
||||
newValue = ""
|
||||
}
|
||||
// Copy language preference for chatbots to root one in "telegram"
|
||||
if (section.Section == "discord" || section.Section == "matrix") && setting.Setting == "language" {
|
||||
iniSection.Key("language").SetValue(newValue.(string))
|
||||
} else if setting.Type == common.ListType {
|
||||
splitValues := strings.Split(newValue.(string), "|")
|
||||
// Delete the key first to get rid of any shadow values
|
||||
iniSection.DeleteKey(setting.Setting)
|
||||
for i, v := range splitValues {
|
||||
if i == 0 {
|
||||
iniSection.Key(setting.Setting).SetValue(v)
|
||||
} else {
|
||||
iniSection.Key(setting.Setting).AddShadow(v)
|
||||
}
|
||||
}
|
||||
|
||||
} else if newValue.(string) != iniSection.Key(setting.Setting).MustString("") {
|
||||
iniSection.Key(setting.Setting).SetValue(newValue.(string))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tempConfig.Section("").Key("first_run").SetValue("false")
|
||||
if err := tempConfig.SaveTo(app.configPath); err != nil {
|
||||
app.err.Printf("Failed to save config to \"%s\": %v", app.configPath, err)
|
||||
app.err.Printf(lm.FailedWriting, app.configPath, err)
|
||||
respond(500, err.Error(), gc)
|
||||
return
|
||||
}
|
||||
app.debug.Println("Config saved")
|
||||
app.info.Printf(lm.ModifyConfig, app.configPath)
|
||||
gc.JSON(200, map[string]bool{"success": true})
|
||||
if req["restart-program"] != nil && req["restart-program"].(bool) {
|
||||
app.info.Println("Restarting...")
|
||||
if TRAY {
|
||||
TRAYRESTART <- true
|
||||
} else {
|
||||
RESTART <- true
|
||||
}
|
||||
// Safety Sleep (Ensure shutdown tasks get done)
|
||||
time.Sleep(time.Second)
|
||||
app.Restart()
|
||||
}
|
||||
app.loadConfig()
|
||||
app.ReloadConfig()
|
||||
// Patch new settings for next GetConfig
|
||||
app.PatchConfigBase()
|
||||
// Reinitialize password validator on config change, as opposed to every applicable request like in python.
|
||||
if _, ok := req["password_validation"]; ok {
|
||||
app.debug.Println("Reinitializing validator")
|
||||
validatorConf := ValidatorConf{
|
||||
"length": app.config.Section("password_validation").Key("min_length").MustInt(0),
|
||||
"uppercase": app.config.Section("password_validation").Key("upper").MustInt(0),
|
||||
@@ -406,12 +345,13 @@ func (app *appContext) CheckUpdate(gc *gin.Context) {
|
||||
// @tags Configuration
|
||||
func (app *appContext) ApplyUpdate(gc *gin.Context) {
|
||||
if !app.update.CanUpdate {
|
||||
respond(400, "Update is manual", gc)
|
||||
app.info.Printf(lm.FailedApplyUpdate, lm.UpdateManual)
|
||||
respond(400, lm.UpdateManual, gc)
|
||||
return
|
||||
}
|
||||
err := app.update.update()
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to apply update: %v", err)
|
||||
app.err.Printf(lm.FailedApplyUpdate, err)
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
@@ -433,8 +373,9 @@ func (app *appContext) ApplyUpdate(gc *gin.Context) {
|
||||
func (app *appContext) Logout(gc *gin.Context) {
|
||||
cookie, err := gc.Cookie("refresh")
|
||||
if err != nil {
|
||||
app.debug.Printf("Couldn't get cookies: %s", err)
|
||||
respond(500, "Couldn't fetch cookies", gc)
|
||||
msg := fmt.Sprintf(lm.FailedGetCookies, "refresh", err)
|
||||
app.debug.Println(msg)
|
||||
respond(500, msg, gc)
|
||||
return
|
||||
}
|
||||
app.invalidTokens = append(app.invalidTokens, cookie)
|
||||
@@ -507,11 +448,7 @@ func (app *appContext) ServeLang(gc *gin.Context) {
|
||||
// @Security Bearer
|
||||
// @tags Other
|
||||
func (app *appContext) restart(gc *gin.Context) {
|
||||
app.info.Println("Restarting...")
|
||||
err := app.Restart()
|
||||
if err != nil {
|
||||
app.err.Printf("Couldn't restart, try restarting manually: %v", err)
|
||||
}
|
||||
app.Restart()
|
||||
}
|
||||
|
||||
// @Summary Returns the last 100 lines of the log.
|
||||
@@ -525,6 +462,7 @@ func (app *appContext) GetLog(gc *gin.Context) {
|
||||
|
||||
// no need to syscall.exec anymore!
|
||||
func (app *appContext) Restart() error {
|
||||
app.info.Println(lm.Restarting)
|
||||
if TRAY {
|
||||
TRAYRESTART <- true
|
||||
} else {
|
||||
|
||||
22
args.go
@@ -8,6 +8,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
)
|
||||
|
||||
func (app *appContext) loadArgs(firstCall bool) {
|
||||
@@ -23,10 +25,14 @@ func (app *appContext) loadArgs(firstCall bool) {
|
||||
HOST = flag.String("host", "", "alternate address to host web ui on.")
|
||||
PORT = flag.Int("port", 0, "alternate port to host web ui on.")
|
||||
flag.IntVar(PORT, "p", 0, "SHORTHAND")
|
||||
_LOADBAK = flag.String("restore", "", "path to database backup to restore.")
|
||||
DEBUG = flag.Bool("debug", false, "Enables debug logging.")
|
||||
PPROF = flag.Bool("pprof", false, "Exposes pprof profiler on /debug/pprof.")
|
||||
SWAGGER = flag.Bool("swagger", false, "Enable swagger at /swagger/index.html")
|
||||
|
||||
flag.BoolVar(&NO_API_AUTH_DO_NOT_USE, "disable-api-auth-do-not-use", false, "Disables API authentication. DO NOT USE!")
|
||||
flag.StringVar(&NO_API_AUTH_FORCE_JFID, "disable-api-auth-force-jf-id", "", "Assume given JFID when API auth is disabled.")
|
||||
|
||||
flag.Parse()
|
||||
if *help {
|
||||
flag.Usage()
|
||||
@@ -41,6 +47,22 @@ func (app *appContext) loadArgs(firstCall bool) {
|
||||
if *PPROF {
|
||||
os.Setenv("PPROF", "1")
|
||||
}
|
||||
if *_LOADBAK != "" {
|
||||
LOADBAK = *_LOADBAK
|
||||
}
|
||||
|
||||
if NO_API_AUTH_DO_NOT_USE && *DEBUG {
|
||||
NO_API_AUTH_DO_NOT_USE = false
|
||||
forceJfID := NO_API_AUTH_FORCE_JFID
|
||||
NO_API_AUTH_FORCE_JFID = ""
|
||||
buf := bufio.NewReader(os.Stdin)
|
||||
app.err.Print(lm.NoAPIAuthPrompt)
|
||||
sentence, err := buf.ReadBytes('\n')
|
||||
if err == nil && strings.ContainsRune(string(sentence), 'y') {
|
||||
NO_API_AUTH_DO_NOT_USE = true
|
||||
NO_API_AUTH_FORCE_JFID = forceJfID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if os.Getenv("SWAGGER") == "1" {
|
||||
|
||||
167
auth.go
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
)
|
||||
@@ -18,9 +20,34 @@ const (
|
||||
REFRESH_TOKEN_VALIDITY_SEC = 3600 * 24
|
||||
)
|
||||
|
||||
func (app *appContext) webAuth() gin.HandlerFunc {
|
||||
return app.authenticate
|
||||
func (app *appContext) logIpInfo(gc *gin.Context, user bool, out string) {
|
||||
if (user && LOGIPU) || (!user && LOGIP) {
|
||||
out += fmt.Sprintf(" (ip=%s)", gc.ClientIP())
|
||||
}
|
||||
app.info.Println(out)
|
||||
}
|
||||
func (app *appContext) logIpDebug(gc *gin.Context, user bool, out string) {
|
||||
if (user && LOGIPU) || (!user && LOGIP) {
|
||||
out += fmt.Sprintf(" (ip=%s)", gc.ClientIP())
|
||||
}
|
||||
app.debug.Println(out)
|
||||
}
|
||||
func (app *appContext) logIpErr(gc *gin.Context, user bool, out string) {
|
||||
if (user && LOGIPU) || (!user && LOGIP) {
|
||||
out += fmt.Sprintf(" (ip=%s)", gc.ClientIP())
|
||||
}
|
||||
app.err.Println(out)
|
||||
}
|
||||
|
||||
func (app *appContext) webAuth() gin.HandlerFunc {
|
||||
if NO_API_AUTH_DO_NOT_USE {
|
||||
return app.bogusAuthenticate
|
||||
} else {
|
||||
return app.authenticate
|
||||
}
|
||||
}
|
||||
|
||||
func (app *appContext) authLog(v any) { app.debug.PrintfCustomLevel(4, lm.FailedAuthRequest, v) }
|
||||
|
||||
// CreateToken returns a web token as well as a refresh token, which can be used to obtain new tokens.
|
||||
func CreateToken(userId, jfId string, admin bool) (string, string, error) {
|
||||
@@ -53,32 +80,26 @@ func (app *appContext) decodeValidateAuthHeader(gc *gin.Context) (claims jwt.Map
|
||||
ok = false
|
||||
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
|
||||
if header[0] != "Bearer" {
|
||||
app.debug.Println("Invalid authorization header")
|
||||
app.authLog(lm.InvalidAuthHeader)
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
token, err := jwt.Parse(string(header[1]), checkToken)
|
||||
if err != nil {
|
||||
app.debug.Printf("Auth denied: %s", err)
|
||||
app.authLog(fmt.Sprintf(lm.FailedParseJWT, err))
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
claims, ok = token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
app.debug.Println("Invalid JWT")
|
||||
app.authLog(lm.FailedCastJWT)
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
expiryUnix := int64(claims["exp"].(float64))
|
||||
if err != nil {
|
||||
app.debug.Printf("Auth denied: %s", err)
|
||||
respond(401, "Unauthorized", gc)
|
||||
ok = false
|
||||
return
|
||||
}
|
||||
expiry := time.Unix(expiryUnix, 0)
|
||||
if !(ok && token.Valid && claims["type"].(string) == "bearer" && expiry.After(time.Now())) {
|
||||
app.debug.Printf("Auth denied: Invalid token")
|
||||
app.authLog(lm.InvalidJWT)
|
||||
// app.debug.Printf("Expiry: %+v, OK: %t, Valid: %t, ClaimType: %s\n", expiry, ok, token.Valid, claims["type"].(string))
|
||||
respond(401, "Unauthorized", gc)
|
||||
ok = false
|
||||
@@ -96,7 +117,7 @@ func (app *appContext) authenticate(gc *gin.Context) {
|
||||
}
|
||||
isAdminToken := claims["admin"].(bool)
|
||||
if !isAdminToken {
|
||||
app.debug.Printf("Auth denied: Token was not for admin access")
|
||||
app.authLog(lm.NonAdminToken)
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
@@ -111,14 +132,20 @@ func (app *appContext) authenticate(gc *gin.Context) {
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
app.debug.Printf("Couldn't find user ID \"%s\"", userID)
|
||||
app.authLog(fmt.Sprintf(lm.NonAdminUser, userID))
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
gc.Set("jfId", jfID)
|
||||
gc.Set("userId", userID)
|
||||
gc.Set("userMode", false)
|
||||
app.debug.Println("Auth succeeded")
|
||||
gc.Next()
|
||||
}
|
||||
|
||||
// bogusAuthenticate is for use with NO_API_AUTH_DO_NOT_USE, it sets the jfId/userId value from NO_API_AUTH_FORCE_JF_ID.
|
||||
func (app *appContext) bogusAuthenticate(gc *gin.Context) {
|
||||
gc.Set("jfId", NO_API_AUTH_FORCE_JFID)
|
||||
gc.Set("userId", NO_API_AUTH_FORCE_JFID)
|
||||
gc.Next()
|
||||
}
|
||||
|
||||
@@ -133,7 +160,7 @@ type getTokenDTO struct {
|
||||
Token string `json:"token" example:"kjsdklsfdkljfsjsdfklsdfkldsfjdfskjsdfjklsdf"` // API token for use with everything else.
|
||||
}
|
||||
|
||||
func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, password string, ok bool) {
|
||||
func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool) (username, password string, ok bool) {
|
||||
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
|
||||
auth, _ := base64.StdEncoding.DecodeString(header[1])
|
||||
creds := strings.SplitN(string(auth), ":", 2)
|
||||
@@ -141,7 +168,7 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, pas
|
||||
password = creds[1]
|
||||
ok = false
|
||||
if username == "" || password == "" {
|
||||
app.debug.Println("Auth denied: blank username/password")
|
||||
app.logIpDebug(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.EmptyUserOrPass))
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
@@ -149,21 +176,45 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, pas
|
||||
return
|
||||
}
|
||||
|
||||
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context) (user mediabrowser.User, ok bool) {
|
||||
func (app *appContext) canAccessAdminPage(user mediabrowser.User, emailStore EmailAddress) bool {
|
||||
// 1. "Allow all" is enabled, so simply being a user implies access.
|
||||
if app.config.Section("ui").Key("allow_all").MustBool(false) && user.ID != "" {
|
||||
return true
|
||||
}
|
||||
// 2. You've been made an "accounts admin" from the accounts tab.
|
||||
if emailStore.Admin {
|
||||
return true
|
||||
}
|
||||
// 3. (Jellyfin) "Admins only" is enabled, and you're one.
|
||||
if app.config.Section("ui").Key("admin_only").MustBool(true) && user.ID != "" && user.Policy.IsAdministrator {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (app *appContext) canAccessAdminPageByID(jfID string) bool {
|
||||
user, err := app.jf.UserByID(jfID, false)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
emailStore, _ := app.storage.GetEmailsKey(jfID)
|
||||
return app.canAccessAdminPage(user, emailStore)
|
||||
}
|
||||
|
||||
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context, userpage bool) (user mediabrowser.User, ok bool) {
|
||||
ok = false
|
||||
user, status, err := app.authJf.Authenticate(username, password)
|
||||
if status != 200 || err != nil {
|
||||
if status == 401 || status == 400 {
|
||||
app.info.Println("Auth denied: Invalid username/password (Jellyfin)")
|
||||
user, err := app.authJf.Authenticate(username, password)
|
||||
if err != nil {
|
||||
if errors.As(err, &mediabrowser.ErrUnauthorized{}) {
|
||||
app.logIpInfo(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.InvalidUserOrPass))
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
if status == 403 {
|
||||
app.info.Println("Auth denied: Jellyfin account disabled")
|
||||
} else if errors.As(err, &mediabrowser.ErrForbidden{}) {
|
||||
app.logIpInfo(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.UserDisabled))
|
||||
respond(403, "yourAccountWasDisabled", gc)
|
||||
return
|
||||
}
|
||||
app.err.Printf("Auth failed: Couldn't authenticate with Jellyfin (%d/%s)", status, err)
|
||||
app.authLog(fmt.Sprintf(lm.FailedAuthJellyfin, app.jf.Server, 0, err))
|
||||
respond(500, "Jellyfin error", gc)
|
||||
return
|
||||
}
|
||||
@@ -180,8 +231,8 @@ func (app *appContext) validateJellyfinCredentials(username, password string, gc
|
||||
// @tags Auth
|
||||
// @Security getTokenAuth
|
||||
func (app *appContext) getTokenLogin(gc *gin.Context) {
|
||||
app.info.Println("Token requested (login attempt)")
|
||||
username, password, ok := app.decodeValidateLoginHeader(gc)
|
||||
app.logIpInfo(gc, false, fmt.Sprintf(lm.RequestingToken, lm.TokenLoginAttempt))
|
||||
username, password, ok := app.decodeValidateLoginHeader(gc, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -190,50 +241,46 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
|
||||
for _, user := range app.adminUsers {
|
||||
if user.Username == username && user.Password == password {
|
||||
match = true
|
||||
app.debug.Println("Found existing user")
|
||||
userID = user.UserID
|
||||
break
|
||||
}
|
||||
}
|
||||
if !app.jellyfinLogin && !match {
|
||||
app.info.Println("Auth denied: Invalid username/password")
|
||||
app.logIpInfo(gc, false, fmt.Sprintf(lm.FailedAuthRequest, lm.InvalidUserOrPass))
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
if !match {
|
||||
user, ok := app.validateJellyfinCredentials(username, password, gc)
|
||||
user, ok := app.validateJellyfinCredentials(username, password, gc, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
jfID = user.ID
|
||||
if !app.config.Section("ui").Key("allow_all").MustBool(false) {
|
||||
accountsAdmin := false
|
||||
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
|
||||
if emailStore, ok := app.storage.GetEmailsKey(jfID); ok {
|
||||
accountsAdmin = emailStore.Admin
|
||||
}
|
||||
accountsAdmin = accountsAdmin || (adminOnly && user.Policy.IsAdministrator)
|
||||
if !accountsAdmin {
|
||||
app.debug.Printf("Auth denied: Users \"%s\" isn't admin", username)
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
emailStore, _ := app.storage.GetEmailsKey(jfID)
|
||||
accountsAdmin := app.canAccessAdminPage(user, emailStore)
|
||||
if !accountsAdmin {
|
||||
app.authLog(fmt.Sprintf(lm.NonAdminUser, username))
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
// New users are only added when using jellyfinLogin.
|
||||
userID = shortuuid.New()
|
||||
newUser := User{
|
||||
UserID: userID,
|
||||
}
|
||||
app.debug.Printf("Token generated for user \"%s\"", username)
|
||||
app.debug.Printf(lm.GenerateToken, username)
|
||||
app.adminUsers = append(app.adminUsers, newUser)
|
||||
}
|
||||
token, refresh, err := CreateToken(userID, jfID, true)
|
||||
if err != nil {
|
||||
app.err.Printf("getToken failed: Couldn't generate token (%s)", err)
|
||||
app.err.Printf(lm.FailedGenerateToken, err)
|
||||
respond(500, "Couldn't generate token", gc)
|
||||
return
|
||||
}
|
||||
gc.SetCookie("refresh", refresh, (3600 * 24), "/", gc.Request.URL.Hostname(), true, true)
|
||||
host := app.ExternalDomainNoPort(gc)
|
||||
|
||||
// Before you think this is broken: the first "true" arg is for "secure", i.e. only HTTPS!
|
||||
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
|
||||
gc.JSON(200, getTokenDTO{token})
|
||||
}
|
||||
|
||||
@@ -241,35 +288,29 @@ func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName s
|
||||
ok = false
|
||||
cookie, err := gc.Cookie(cookieName)
|
||||
if err != nil || cookie == "" {
|
||||
app.debug.Printf("getTokenRefresh denied: Couldn't get token: %s", err)
|
||||
app.authLog(fmt.Sprintf(lm.FailedGetCookies, cookieName, err))
|
||||
respond(400, "Couldn't get token", gc)
|
||||
return
|
||||
}
|
||||
for _, token := range app.invalidTokens {
|
||||
if cookie == token {
|
||||
app.debug.Println("getTokenRefresh: Invalid token")
|
||||
respond(401, "Invalid token", gc)
|
||||
app.authLog(lm.LocallyInvalidatedJWT)
|
||||
respond(401, lm.InvalidJWT, gc)
|
||||
return
|
||||
}
|
||||
}
|
||||
token, err := jwt.Parse(cookie, checkToken)
|
||||
if err != nil {
|
||||
app.debug.Println("getTokenRefresh: Invalid token")
|
||||
respond(400, "Invalid token", gc)
|
||||
app.authLog(fmt.Sprintf(lm.FailedParseJWT, err))
|
||||
respond(400, lm.InvalidJWT, gc)
|
||||
return
|
||||
}
|
||||
claims, ok = token.Claims.(jwt.MapClaims)
|
||||
expiryUnix := int64(claims["exp"].(float64))
|
||||
if err != nil {
|
||||
app.debug.Printf("getTokenRefresh: Invalid token expiry: %s", err)
|
||||
respond(401, "Invalid token", gc)
|
||||
ok = false
|
||||
return
|
||||
}
|
||||
expiry := time.Unix(expiryUnix, 0)
|
||||
if !(ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now())) {
|
||||
app.debug.Printf("getTokenRefresh: Invalid token: %+v", err)
|
||||
respond(401, "Invalid token", gc)
|
||||
app.authLog(lm.InvalidJWT)
|
||||
respond(401, lm.InvalidJWT, gc)
|
||||
ok = false
|
||||
return
|
||||
}
|
||||
@@ -284,7 +325,7 @@ func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName s
|
||||
// @Router /token/refresh [get]
|
||||
// @tags Auth
|
||||
func (app *appContext) getTokenRefresh(gc *gin.Context) {
|
||||
app.debug.Println("Token requested (refresh token)")
|
||||
app.logIpInfo(gc, false, fmt.Sprintf(lm.RequestingToken, lm.TokenRefresh))
|
||||
claims, ok := app.decodeValidateRefreshCookie(gc, "refresh")
|
||||
if !ok {
|
||||
return
|
||||
@@ -293,10 +334,12 @@ func (app *appContext) getTokenRefresh(gc *gin.Context) {
|
||||
jfID := claims["jfid"].(string)
|
||||
jwt, refresh, err := CreateToken(userID, jfID, true)
|
||||
if err != nil {
|
||||
app.err.Printf("getTokenRefresh failed: Couldn't generate token (%s)", err)
|
||||
app.err.Printf(lm.FailedGenerateToken, err)
|
||||
respond(500, "Couldn't generate token", gc)
|
||||
return
|
||||
}
|
||||
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", gc.Request.URL.Hostname(), true, true)
|
||||
// host := gc.Request.URL.Hostname()
|
||||
host := app.ExternalDomainNoPort(gc)
|
||||
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
|
||||
gc.JSON(200, getTokenDTO{jwt})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//go:build tray
|
||||
// +build tray
|
||||
|
||||
package main
|
||||
@@ -8,7 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/emersion/go-autostart"
|
||||
"github.com/getlantern/systray"
|
||||
"github.com/lutischan-ferenc/systray"
|
||||
)
|
||||
|
||||
type Autostart struct {
|
||||
@@ -48,8 +49,8 @@ func NewAutostart(name, displayname, trayName, trayTooltip string) *Autostart {
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *Autostart) HandleCheck() {
|
||||
for range a.menuitem.ClickedCh {
|
||||
func (a *Autostart) Register() {
|
||||
a.menuitem.Click(func() {
|
||||
if !a.menuitem.Checked() {
|
||||
if err := a.as.Enable(); err != nil {
|
||||
log.Printf("Failed to enable autostart on login: %v", err)
|
||||
@@ -65,5 +66,5 @@ func (a *Autostart) HandleCheck() {
|
||||
log.Printf("Disabled autostart")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
304
backups.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
)
|
||||
|
||||
const (
|
||||
BACKUP_PREFIX = "jfa-go-db"
|
||||
BACKUP_PREFIX_OLD = "jfa-go-db-"
|
||||
BACKUP_COMMIT_PREFIX = "-c-"
|
||||
BACKUP_DATE_PREFIX = "-d-"
|
||||
BACKUP_UPLOAD_PREFIX = "upload-"
|
||||
BACKUP_DATEFMT = "2006-01-02T15-04-05"
|
||||
BACKUP_SUFFIX = ".bak"
|
||||
)
|
||||
|
||||
type Backup struct {
|
||||
Date time.Time
|
||||
Commit string
|
||||
Upload bool
|
||||
}
|
||||
|
||||
func (b Backup) IsZero() bool { return b.Date.IsZero() && b.Commit == "" && b.Upload == false }
|
||||
|
||||
func (b Backup) Equals(a Backup) bool {
|
||||
return a.Date.Equal(b.Date) && a.Commit == b.Commit && a.Upload == b.Upload
|
||||
}
|
||||
|
||||
// Pre 21/03/25 format: "{BACKUP_PREFIX_OLD}{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-2006-01-02T15-04-05.bak"
|
||||
// Post 21/03/25 format: "{BACKUP_PREFIX}-c-{commit}-d-{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-c-0b92060-d-2006-01-02T15-04-05.bak"
|
||||
|
||||
func (b Backup) String() string {
|
||||
t := b.Date
|
||||
if t.IsZero() {
|
||||
t = time.Now()
|
||||
}
|
||||
out := BACKUP_PREFIX
|
||||
if b.Upload {
|
||||
out = BACKUP_UPLOAD_PREFIX + out
|
||||
}
|
||||
if b.Commit != "" {
|
||||
out += BACKUP_COMMIT_PREFIX + b.Commit
|
||||
}
|
||||
out += BACKUP_DATE_PREFIX + t.Local().Format(BACKUP_DATEFMT) + BACKUP_SUFFIX
|
||||
return out
|
||||
}
|
||||
|
||||
func (b *Backup) FromString(f string) error {
|
||||
of := f
|
||||
if strings.HasPrefix(f, BACKUP_UPLOAD_PREFIX) {
|
||||
b.Upload = true
|
||||
f = f[len(BACKUP_UPLOAD_PREFIX):]
|
||||
}
|
||||
if !strings.HasPrefix(f, BACKUP_PREFIX) {
|
||||
return fmt.Errorf("file doesn't have correct prefix (\"%s\")", BACKUP_PREFIX)
|
||||
}
|
||||
f = f[len(BACKUP_PREFIX):]
|
||||
if !strings.HasSuffix(f, BACKUP_SUFFIX) {
|
||||
return fmt.Errorf("file doesn't have correct suffix (\"%s\")", BACKUP_SUFFIX)
|
||||
}
|
||||
for range 2 {
|
||||
if strings.HasPrefix(f, BACKUP_COMMIT_PREFIX) {
|
||||
f = f[len(BACKUP_COMMIT_PREFIX):]
|
||||
commitEnd := strings.Index(f, BACKUP_DATE_PREFIX)
|
||||
if commitEnd == -1 {
|
||||
commitEnd = strings.Index(f, BACKUP_SUFFIX)
|
||||
}
|
||||
if commitEnd == -1 {
|
||||
return fmt.Errorf("end of commit (\"%s\" or \"%s\") not found in \"%s\"", BACKUP_DATE_PREFIX, BACKUP_PREFIX, f)
|
||||
}
|
||||
b.Commit = f[:commitEnd]
|
||||
f = f[commitEnd:]
|
||||
} else if strings.HasPrefix(f, BACKUP_DATE_PREFIX) {
|
||||
f = f[len(BACKUP_DATE_PREFIX):]
|
||||
dateEnd := strings.Index(f, BACKUP_COMMIT_PREFIX)
|
||||
if dateEnd == -1 {
|
||||
dateEnd = strings.Index(f, BACKUP_SUFFIX)
|
||||
}
|
||||
if dateEnd == -1 {
|
||||
return fmt.Errorf("end of date (\"%s\" or \"%s\") not found in \"%s\"", BACKUP_COMMIT_PREFIX, BACKUP_PREFIX, f)
|
||||
}
|
||||
t, err := time.Parse(BACKUP_DATEFMT, f[:dateEnd])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.Date = t
|
||||
f = f[dateEnd:]
|
||||
}
|
||||
}
|
||||
if b.Date.IsZero() {
|
||||
return b.FromOldString(of)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backup) FromOldString(f string) error {
|
||||
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(f, BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX+"-"), BACKUP_SUFFIX))
|
||||
if err != nil {
|
||||
return fmt.Errorf(lm.FailedParseTime, err)
|
||||
}
|
||||
b.Date = t
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
type BackupList struct {
|
||||
files []os.DirEntry
|
||||
info []Backup
|
||||
count int
|
||||
}
|
||||
|
||||
func (bl BackupList) Len() int { return len(bl.files) }
|
||||
func (bl BackupList) Swap(i, j int) {
|
||||
bl.files[i], bl.files[j] = bl.files[j], bl.files[i]
|
||||
bl.info[i], bl.info[j] = bl.info[j], bl.info[i]
|
||||
}
|
||||
|
||||
func (bl BackupList) Less(i, j int) bool {
|
||||
// Push non-backup files to the end of the array,
|
||||
// Since they didn't have a date parsed.
|
||||
if bl.info[i].Date.IsZero() {
|
||||
return false
|
||||
}
|
||||
if bl.info[j].Date.IsZero() {
|
||||
return true
|
||||
}
|
||||
// Sort by oldest first
|
||||
return bl.info[j].Date.After(bl.info[i].Date)
|
||||
}
|
||||
|
||||
// Get human-readable file size from f.Size() result.
|
||||
// https://programming.guide/go/formatting-byte-size-to-human-readable-format.html
|
||||
func fileSize(l int64) string {
|
||||
const unit = 1000
|
||||
if l < unit {
|
||||
return fmt.Sprintf("%dB", l)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := l / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f%c", float64(l)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func (app *appContext) getBackups() *BackupList {
|
||||
path := app.config.Section("backups").Key("path").String()
|
||||
err := os.MkdirAll(path, 0755)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedCreateDir, path, err)
|
||||
return nil
|
||||
}
|
||||
items, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedReading, path, err)
|
||||
return nil
|
||||
}
|
||||
backups := &BackupList{}
|
||||
backups.files = items
|
||||
backups.info = make([]Backup, len(items))
|
||||
backups.count = 0
|
||||
for i, item := range items {
|
||||
// Even though Backup{} can parse and check validity, still check if the file ends in .bak, we don't need to print an error if a file isn't a .bak.
|
||||
if item.IsDir() || !(strings.HasSuffix(item.Name(), BACKUP_SUFFIX)) {
|
||||
continue
|
||||
}
|
||||
b := Backup{}
|
||||
if err := b.FromString(item.Name()); err != nil {
|
||||
app.debug.Printf(lm.FailedParseBackup, item.Name(), err)
|
||||
continue
|
||||
}
|
||||
backups.info[i] = b
|
||||
backups.count++
|
||||
}
|
||||
return backups
|
||||
}
|
||||
|
||||
func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) {
|
||||
toKeep := app.config.Section("backups").Key("keep_n_backups").MustInt(20)
|
||||
keepPreviousVersions := app.config.Section("backups").Key("keep_previous_version_backup").MustBool(true)
|
||||
|
||||
b := Backup{Commit: commit}
|
||||
fname := b.String()
|
||||
path := app.config.Section("backups").Key("path").String()
|
||||
backups := app.getBackups()
|
||||
if backups == nil {
|
||||
return
|
||||
}
|
||||
toDelete := backups.count + 1 - toKeep
|
||||
if toDelete > 0 || keepPreviousVersions {
|
||||
sort.Sort(backups)
|
||||
}
|
||||
backupsByCommit := map[string]int{}
|
||||
if keepPreviousVersions {
|
||||
// Count backups by commit
|
||||
for _, b := range backups.info {
|
||||
if b.IsZero() {
|
||||
continue
|
||||
}
|
||||
// If b.Commit is empty, the backup is pre-versions-in-backup-names.
|
||||
// Still use the empty string as a key, considering these as a single version.
|
||||
count, ok := backupsByCommit[b.Commit]
|
||||
if !ok {
|
||||
count = 0
|
||||
}
|
||||
count += 1
|
||||
backupsByCommit[b.Commit] = count
|
||||
}
|
||||
}
|
||||
// fmt.Printf("toDelete: %d, backCount: %d, keep: %d, length: %d\n", toDelete, backups.count, toKeep, len(backups.files))
|
||||
if toDelete > 0 && toDelete <= backups.count {
|
||||
for i := range toDelete {
|
||||
backupsRemaining, ok := backupsByCommit[backups.info[i].Commit]
|
||||
app.debug.Println("item", backups.files[i], "remaining", backupsRemaining)
|
||||
if keepPreviousVersions && ok && backupsRemaining <= 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
item := backups.files[i]
|
||||
fullpath := filepath.Join(path, item.Name())
|
||||
err := os.Remove(fullpath)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedDeleteOldBackup, fullpath, err)
|
||||
return
|
||||
}
|
||||
app.debug.Printf(lm.DeleteOldBackup, fullpath)
|
||||
if keepPreviousVersions && ok {
|
||||
backupsRemaining -= 1
|
||||
backupsByCommit[backups.info[i].Commit] = backupsRemaining
|
||||
}
|
||||
}
|
||||
}
|
||||
fullpath := filepath.Join(path, fname)
|
||||
f, err := os.Create(fullpath)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedOpen, fullpath, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = app.storage.db.Badger().Backup(f, 0)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedCreateBackup, err)
|
||||
return
|
||||
}
|
||||
|
||||
fstat, err := f.Stat()
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedStat, fullpath, err)
|
||||
return
|
||||
}
|
||||
fileDetails.Size = fileSize(fstat.Size())
|
||||
fileDetails.Name = fname
|
||||
fileDetails.Path = fullpath
|
||||
app.debug.Printf(lm.CreateBackup, fileDetails)
|
||||
return
|
||||
}
|
||||
|
||||
func (app *appContext) loadPendingBackup() {
|
||||
if LOADBAK == "" {
|
||||
return
|
||||
}
|
||||
oldPath := filepath.Join(app.dataPath, "db-"+strconv.FormatInt(time.Now().Unix(), 10)+"-pre-"+filepath.Base(LOADBAK))
|
||||
err := os.Rename(app.storage.db_path, oldPath)
|
||||
if err != nil {
|
||||
app.err.Fatalf(lm.FailedMoveOldDB, oldPath, err)
|
||||
}
|
||||
app.info.Printf(lm.MoveOldDB, oldPath)
|
||||
|
||||
if err := app.storage.Connect(app.config); err != nil {
|
||||
app.err.Fatalf(lm.FailedConnectDB, app.storage.db_path, err)
|
||||
}
|
||||
defer app.storage.Close()
|
||||
|
||||
f, err := os.Open(LOADBAK)
|
||||
if err != nil {
|
||||
app.err.Fatalf(lm.FailedOpen, LOADBAK, err)
|
||||
}
|
||||
err = app.storage.db.Badger().Load(f, 256)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
app.err.Fatalf(lm.FailedRestoreDB, LOADBAK, err)
|
||||
}
|
||||
app.info.Printf(lm.RestoreDB, LOADBAK)
|
||||
LOADBAK = ""
|
||||
}
|
||||
|
||||
func newBackupDaemon(app *appContext) *GenericDaemon {
|
||||
interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute
|
||||
d := NewGenericDaemon(interval, app,
|
||||
func(app *appContext) {
|
||||
app.makeBackup()
|
||||
},
|
||||
)
|
||||
d.Name("Backup")
|
||||
return d
|
||||
}
|
||||
57
backups_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testBackupParse(f string, a Backup, t *testing.T) {
|
||||
b := Backup{}
|
||||
err := b.FromString(f)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %+v", err)
|
||||
}
|
||||
if !b.Equals(a) {
|
||||
t.Fatalf("not equal: %+v != %+v", b, a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupParserOld(t *testing.T) {
|
||||
Q1 := BACKUP_PREFIX_OLD + "2023-12-21T21-08-00" + BACKUP_SUFFIX
|
||||
A1 := Backup{}
|
||||
A1.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
|
||||
testBackupParse(Q1, A1, t)
|
||||
}
|
||||
func TestBackupParserOldUpload(t *testing.T) {
|
||||
Q2 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX_OLD + "2023-12-21T21-08-00" + BACKUP_SUFFIX
|
||||
A2 := Backup{
|
||||
Upload: true,
|
||||
}
|
||||
A2.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
|
||||
testBackupParse(Q2, A2, t)
|
||||
}
|
||||
func TestBackupParserUploadDate(t *testing.T) {
|
||||
Q3 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX + BACKUP_DATE_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX
|
||||
A3 := Backup{
|
||||
Upload: true,
|
||||
}
|
||||
A3.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
|
||||
testBackupParse(Q3, A3, t)
|
||||
}
|
||||
func TestBackupParserUploadCommitDate(t *testing.T) {
|
||||
Q4 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX + BACKUP_COMMIT_PREFIX + "testcommit" + BACKUP_DATE_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX
|
||||
A4 := Backup{
|
||||
Commit: "testcommit",
|
||||
Upload: true,
|
||||
}
|
||||
A4.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
|
||||
testBackupParse(Q4, A4, t)
|
||||
}
|
||||
func TestBackupParserDateCommit(t *testing.T) {
|
||||
Q5 := BACKUP_PREFIX + BACKUP_DATE_PREFIX + "2023-12-21T21-08-00" + BACKUP_COMMIT_PREFIX + "testcommit" + BACKUP_SUFFIX
|
||||
A5 := Backup{
|
||||
Commit: "testcommit",
|
||||
}
|
||||
A5.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
|
||||
testBackupParse(Q5, A5, t)
|
||||
}
|
||||
9
biome.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 4,
|
||||
"formatWithErrors": false,
|
||||
"lineWidth": 120
|
||||
}
|
||||
}
|
||||
155
common/common.go
@@ -1,10 +1,31 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
)
|
||||
|
||||
const (
|
||||
BogusIdentifier = "123412341234123456"
|
||||
)
|
||||
|
||||
// ContactPreferences holds whether or not a user should be contacted through each of the available
|
||||
// methods. If nil, leave setting alone.
|
||||
type ContactPreferences struct {
|
||||
Email, Discord, Telegram, Matrix *bool
|
||||
}
|
||||
|
||||
// TimeoutHandler recovers from an http timeout or panic.
|
||||
type TimeoutHandler func()
|
||||
|
||||
@@ -12,7 +33,7 @@ type TimeoutHandler func()
|
||||
func NewTimeoutHandler(name, addr string, noFail bool) TimeoutHandler {
|
||||
return func() {
|
||||
if r := recover(); r != nil {
|
||||
out := fmt.Sprintf("Failed to authenticate with %s @ \"%s\": Timed out", name, addr)
|
||||
out := fmt.Sprintf(lm.FailedAuth, name, addr, 0, lm.TimedOut)
|
||||
if noFail {
|
||||
log.Print(out)
|
||||
} else {
|
||||
@@ -21,3 +42,135 @@ func NewTimeoutHandler(name, addr string, noFail bool) TimeoutHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// most 404 errors are from UserNotFound, so this generic error doesn't really need any detail.
|
||||
type ErrNotFound error
|
||||
|
||||
type ErrUnauthorized struct{}
|
||||
|
||||
func (err ErrUnauthorized) Error() string {
|
||||
return lm.Unauthorized
|
||||
}
|
||||
|
||||
type ErrForbidden struct{}
|
||||
|
||||
func (err ErrForbidden) Error() string {
|
||||
return lm.Forbidden
|
||||
}
|
||||
|
||||
var (
|
||||
NotFound ErrNotFound = errors.New(lm.NotFound)
|
||||
)
|
||||
|
||||
type ErrUnknown struct {
|
||||
code int
|
||||
}
|
||||
|
||||
func (err ErrUnknown) Error() string {
|
||||
msg := fmt.Sprintf(lm.FailedGenericWithCode, err.code)
|
||||
return msg
|
||||
}
|
||||
|
||||
// GenericErr returns an error appropriate to the given HTTP status (or actual error, if given).
|
||||
func GenericErr(status int, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch status {
|
||||
case 200, 204, 201:
|
||||
return nil
|
||||
case 401, 400:
|
||||
return ErrUnauthorized{}
|
||||
case 404:
|
||||
return NotFound
|
||||
case 403:
|
||||
return ErrForbidden{}
|
||||
default:
|
||||
return ErrUnknown{code: status}
|
||||
}
|
||||
}
|
||||
|
||||
func GenericErrFromResponse(resp *http.Response, err error) error {
|
||||
if resp == nil {
|
||||
return ErrUnknown{code: -2}
|
||||
}
|
||||
return GenericErr(resp.StatusCode, err)
|
||||
}
|
||||
|
||||
type ConfigurableTransport interface {
|
||||
// SetTransport sets the http.Transport to use for requests. Can be used to set a proxy.
|
||||
SetTransport(t *http.Transport)
|
||||
}
|
||||
|
||||
// Stripped down-ish version of rough http request function used in most of the API clients.
|
||||
func Req(httpClient *http.Client, timeoutHandler TimeoutHandler, mode string, uri string, data any, queryParams url.Values, headers map[string]string, response bool) (string, int, error) {
|
||||
var params []byte
|
||||
if data != nil {
|
||||
params, _ = json.Marshal(data)
|
||||
}
|
||||
if qp := queryParams.Encode(); qp != "" {
|
||||
uri += "?" + qp
|
||||
}
|
||||
var req *http.Request
|
||||
if data != nil {
|
||||
req, _ = http.NewRequest(mode, uri, bytes.NewBuffer(params))
|
||||
} else {
|
||||
req, _ = http.NewRequest(mode, uri, nil)
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
for name, value := range headers {
|
||||
req.Header.Add(name, value)
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if resp == nil {
|
||||
return "", 0, err
|
||||
}
|
||||
err = GenericErr(resp.StatusCode, err)
|
||||
if timeoutHandler != nil {
|
||||
defer timeoutHandler()
|
||||
}
|
||||
var responseText string
|
||||
defer resp.Body.Close()
|
||||
if response || err != nil {
|
||||
responseText, err = decodeResp(resp)
|
||||
if err != nil {
|
||||
return responseText, resp.StatusCode, err
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
var msg any
|
||||
err = json.Unmarshal([]byte(responseText), &msg)
|
||||
if err != nil {
|
||||
return responseText, resp.StatusCode, err
|
||||
}
|
||||
if msg != nil {
|
||||
err = fmt.Errorf("got %d: %+v", resp.StatusCode, msg)
|
||||
}
|
||||
return responseText, resp.StatusCode, err
|
||||
}
|
||||
return responseText, resp.StatusCode, err
|
||||
}
|
||||
|
||||
func decodeResp(resp *http.Response) (string, error) {
|
||||
var out io.Reader
|
||||
switch resp.Header.Get("Content-Encoding") {
|
||||
case "gzip":
|
||||
out, _ = gzip.NewReader(resp.Body)
|
||||
default:
|
||||
out = resp.Body
|
||||
}
|
||||
buf := new(strings.Builder)
|
||||
_, err := io.Copy(buf, out)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// MustAuthenticateOptions is used to control the behaviour of the MustAuthenticate-like methods.
|
||||
type MustAuthenticateOptions struct {
|
||||
RetryCount int // Number of Retries before failure.
|
||||
RetryGap time.Duration // Duration to wait between tries.
|
||||
LogFailures bool // Whether or not to print failures to the log.
|
||||
Counter int // The current retry count.
|
||||
}
|
||||
|
||||
81
common/config.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package common
|
||||
|
||||
type SectionMeta struct {
|
||||
Name string `json:"name" yaml:"name" example:"My Section"` // friendly name of the section
|
||||
Description string `json:"description" yaml:"description"`
|
||||
Advanced bool `json:"advanced,omitempty" yaml:"advanced,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty" yaml:"disabled,omitempty"`
|
||||
DependsTrue string `json:"depends_true,omitempty" yaml:"depends_true,omitempty"`
|
||||
DependsFalse string `json:"depends_false,omitempty" yaml:"depends_false,omitempty"`
|
||||
WikiLink string `json:"wiki_link,omitempty" yaml:"wiki_link,omitempty"`
|
||||
Aliases []string `json:"aliases,omitempty" yaml:"aliases,omitempty"`
|
||||
}
|
||||
|
||||
type Option [2]string
|
||||
|
||||
type SettingType string
|
||||
|
||||
var (
|
||||
BoolType SettingType = "bool"
|
||||
SelectType SettingType = "select"
|
||||
TextType SettingType = "text"
|
||||
PasswordType SettingType = "password"
|
||||
NumberType SettingType = "number"
|
||||
NoteType SettingType = "note"
|
||||
EmailType SettingType = "email"
|
||||
ListType SettingType = "list"
|
||||
)
|
||||
|
||||
type Setting struct {
|
||||
Setting string `json:"setting" yaml:"setting" example:"my_setting"`
|
||||
Name string `json:"name" yaml:"name" example:"My Setting"`
|
||||
Description string `json:"description" yaml:"description"`
|
||||
Required bool `json:"required" yaml:"required"`
|
||||
RequiresRestart bool `json:"requires_restart" yaml:"requires_restart"`
|
||||
Advanced bool `json:"advanced,omitempty" yaml:"advanced,omitempty"`
|
||||
Type SettingType `json:"type" yaml:"type"` // Type (string, number, bool, etc.)
|
||||
Value any `json:"value" yaml:"value"`
|
||||
Options []Option `json:"options,omitempty" yaml:"options,omitempty"`
|
||||
DependsTrue string `json:"depends_true,omitempty" yaml:"depends_true,omitempty"` // If specified, this field is enabled when the specified bool setting is enabled.
|
||||
DependsFalse string `json:"depends_false,omitempty" yaml:"depends_false,omitempty"` // If specified, opposite behaviour of DependsTrue.
|
||||
Style string `json:"style,omitempty" yaml:"style,omitempty"`
|
||||
Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"`
|
||||
WikiLink string `json:"wiki_link,omitempty" yaml:"wiki_link,omitempty"`
|
||||
Aliases []string `json:"aliases,omitempty" yaml:"aliases,omitempty"`
|
||||
}
|
||||
|
||||
type Section struct {
|
||||
Section string `json:"section" yaml:"section" example:"my_section"`
|
||||
Meta SectionMeta `json:"meta" yaml:"meta"`
|
||||
Settings []Setting `json:"settings" yaml:"settings"`
|
||||
}
|
||||
|
||||
// Member is a member of a group, and can either reference a Section or another Group, hence the two fields.
|
||||
type Member struct {
|
||||
Group string `json:"group,omitempty", yaml:"group,omitempty"`
|
||||
Section string `json:"section,omitempty", yaml:"section,omitempty"`
|
||||
}
|
||||
|
||||
type Group struct {
|
||||
Group string `json:"group" yaml:"group" example:"messaging_providers"`
|
||||
Name string `json:"name" yaml:"name" example:"Messaging Providers"`
|
||||
Description string `json:"description" yaml:"description" example:"Options for setting up messaging providers."`
|
||||
Members []Member `json:"members" yaml:"members"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Sections []Section `json:"sections" yaml:"sections"`
|
||||
Groups []Group `json:"groups" yaml:"groups"`
|
||||
// Optional order, which can interleave sections and groups.
|
||||
// If unset, falls back to sections in order, then groups in order.
|
||||
Order []Member `json:"order,omitempty" yaml:"order,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Config) removeSection(section string) {
|
||||
for i, v := range c.Sections {
|
||||
if v.Section == section {
|
||||
c.Sections = append(c.Sections[:i], c.Sections[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
module github.com/hrfee/jfa-go/common
|
||||
|
||||
go 1.15
|
||||
replace github.com/hrfee/jfa-go/logmessages => ../logmessages
|
||||
|
||||
go 1.18
|
||||
|
||||
require github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a
|
||||
|
||||
556
config.go
@@ -3,22 +3,40 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hrfee/jfa-go/common"
|
||||
"github.com/hrfee/jfa-go/easyproxy"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
*ini.File
|
||||
proxyTransport *http.Transport
|
||||
proxyConfig *easyproxy.ProxyConfig
|
||||
}
|
||||
|
||||
var emailEnabled = false
|
||||
var messagesEnabled = false
|
||||
var telegramEnabled = false
|
||||
var discordEnabled = false
|
||||
var matrixEnabled = false
|
||||
|
||||
func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
|
||||
val := app.config.Section(sect).Key(key).MustString("")
|
||||
// URL subpaths. Ignore the "Current" field, it's populated when in copies of the struct used for page templating.
|
||||
// IMPORTANT: When linking straight to a page, rather than appending further to the URL (like accessing an API route), append a /.
|
||||
var PAGES = PagePaths{}
|
||||
|
||||
func (config *Config) GetPath(sect, key string) (fs.FS, string) {
|
||||
val := config.Section(sect).Key(key).MustString("")
|
||||
if strings.HasPrefix(val, "jfa-go:") {
|
||||
return localFS, strings.TrimPrefix(val, "jfa-go:")
|
||||
}
|
||||
@@ -26,107 +44,297 @@ func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
|
||||
return os.DirFS(dir), file
|
||||
}
|
||||
|
||||
func (app *appContext) MustSetValue(section, key, val string) {
|
||||
app.config.Section(section).Key(key).SetValue(app.config.Section(section).Key(key).MustString(val))
|
||||
func (config *Config) MustSetValue(section, key, val string) {
|
||||
config.Section(section).Key(key).SetValue(config.Section(section).Key(key).MustString(val))
|
||||
}
|
||||
|
||||
func (app *appContext) loadConfig() error {
|
||||
var err error
|
||||
app.config, err = ini.Load(app.configPath)
|
||||
func (config *Config) MustSetURLPath(section, key, val string) {
|
||||
if !strings.HasPrefix(val, "/") && val != "" {
|
||||
val = "/" + val
|
||||
}
|
||||
config.MustSetValue(section, key, val)
|
||||
}
|
||||
|
||||
func FixFullURL(v string) string {
|
||||
// Keep relative paths relative
|
||||
if strings.HasPrefix(v, "/") {
|
||||
return v
|
||||
}
|
||||
if !strings.HasPrefix(v, "http://") && !strings.HasPrefix(v, "https://") {
|
||||
v = "http://" + v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func MustGetNonEmptyURL(path string) string {
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
return "/" + path
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func FormatSubpath(path string, removeSingleSlash bool) string {
|
||||
if path == "/" {
|
||||
if removeSingleSlash {
|
||||
return ""
|
||||
}
|
||||
return path
|
||||
}
|
||||
return strings.TrimSuffix(path, "/")
|
||||
}
|
||||
|
||||
func (config *Config) MustCorrectURL(section, key, value string) {
|
||||
v := config.Section(section).Key(key).String()
|
||||
if v == "" {
|
||||
v = value
|
||||
}
|
||||
v = FixFullURL(v)
|
||||
config.Section(section).Key(key).SetValue(v)
|
||||
}
|
||||
|
||||
// ExternalDomain returns the Host for the request, using the fixed externalDomain value unless UseProxyHost is true.
|
||||
func ExternalDomain(gc *gin.Context) string {
|
||||
if !UseProxyHost || gc.Request.Host == "" {
|
||||
return externalDomain
|
||||
}
|
||||
return gc.Request.Host
|
||||
}
|
||||
|
||||
// ExternalDomainNoPort attempts to return ExternalDomain() with the port removed. If the internally-used method fails, it is assumed the domain has no port anyway.
|
||||
func (app *appContext) ExternalDomainNoPort(gc *gin.Context) string {
|
||||
domain := ExternalDomain(gc)
|
||||
host, _, err := net.SplitHostPort(domain)
|
||||
if err != nil {
|
||||
return err
|
||||
return domain
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// ExternalURI returns the External URI of jfa-go's root directory (by default, where the admin page is), using the fixed externalURI value unless UseProxyHost is true and gc is not nil.
|
||||
// When nil is passed, externalURI is returned.
|
||||
func ExternalURI(gc *gin.Context) string {
|
||||
if gc == nil {
|
||||
return externalURI
|
||||
}
|
||||
|
||||
app.MustSetValue("jellyfin", "public_server", app.config.Section("jellyfin").Key("server").String())
|
||||
var proto string
|
||||
if gc.Request.TLS != nil || gc.Request.Header.Get("X-Forwarded-Proto") == "https" || gc.Request.Header.Get("X-Forwarded-Protocol") == "https" {
|
||||
proto = "https://"
|
||||
} else {
|
||||
proto = "http://"
|
||||
}
|
||||
|
||||
app.MustSetValue("ui", "redirect_url", app.config.Section("jellyfin").Key("public_server").String())
|
||||
// app.debug.Printf("Request: %+v\n", gc.Request)
|
||||
if UseProxyHost && gc.Request.Host != "" {
|
||||
return proto + gc.Request.Host + PAGES.Base
|
||||
}
|
||||
return externalURI
|
||||
}
|
||||
|
||||
for _, key := range app.config.Section("files").Keys() {
|
||||
func (app *appContext) EvaluateRelativePath(gc *gin.Context, path string) string {
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
return path
|
||||
}
|
||||
|
||||
var proto string
|
||||
if gc.Request.TLS != nil || gc.Request.Header.Get("X-Forwarded-Proto") == "https" || gc.Request.Header.Get("X-Forwarded-Protocol") == "https" {
|
||||
proto = "https://"
|
||||
} else {
|
||||
proto = "http://"
|
||||
}
|
||||
|
||||
return proto + ExternalDomain(gc) + path
|
||||
}
|
||||
|
||||
// NewConfig reads and patches a config file for use. Passed loggers are used only once. Some dependencies can be reloaded after this is called with ReloadDependents(app).
|
||||
func NewConfig(configPathOrContents any, dataPath string, logs LoggerSet) (*Config, error) {
|
||||
var err error
|
||||
config := &Config{}
|
||||
config.File, err = ini.ShadowLoad(configPathOrContents)
|
||||
if err != nil {
|
||||
return config, err
|
||||
}
|
||||
|
||||
// URLs
|
||||
config.MustSetURLPath("ui", "url_base", "")
|
||||
config.MustSetURLPath("url_paths", "admin", "")
|
||||
config.MustSetURLPath("url_paths", "user_page", "/my/account")
|
||||
config.MustSetURLPath("url_paths", "form", "/invite")
|
||||
PAGES.Base = FormatSubpath(config.Section("ui").Key("url_base").String(), true)
|
||||
PAGES.Admin = FormatSubpath(config.Section("url_paths").Key("admin").String(), true)
|
||||
PAGES.MyAccount = FormatSubpath(config.Section("url_paths").Key("user_page").String(), true)
|
||||
PAGES.Form = FormatSubpath(config.Section("url_paths").Key("form").String(), true)
|
||||
if !(config.Section("user_page").Key("enabled").MustBool(true)) {
|
||||
PAGES.MyAccount = "disabled"
|
||||
}
|
||||
if PAGES.Base == PAGES.Form || PAGES.Base == "/accounts" || PAGES.Base == "/settings" || PAGES.Base == "/activity" {
|
||||
logs.err.Printf(lm.BadURLBase, PAGES.Base)
|
||||
}
|
||||
logs.info.Printf(lm.SubpathBlockMessage, PAGES.Base, PAGES.Admin, PAGES.MyAccount, PAGES.Form)
|
||||
|
||||
config.MustCorrectURL("jellyfin", "server", "")
|
||||
config.MustCorrectURL("jellyfin", "public_server", config.Section("jellyfin").Key("server").String())
|
||||
config.MustCorrectURL("ui", "redirect_url", config.Section("jellyfin").Key("public_server").String())
|
||||
|
||||
for _, key := range config.Section("files").Keys() {
|
||||
if name := key.Name(); name != "html_templates" && name != "lang_files" {
|
||||
key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json"))))
|
||||
key.SetValue(key.MustString(filepath.Join(dataPath, (key.Name() + ".json"))))
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users", "announcements", "custom_user_page_content"} {
|
||||
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json"))))
|
||||
config.Section("files").Key(key).SetValue(config.Section("files").Key(key).MustString(filepath.Join(dataPath, (key + ".json"))))
|
||||
}
|
||||
for _, key := range []string{"matrix_sql"} {
|
||||
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".db"))))
|
||||
config.Section("files").Key(key).SetValue(config.Section("files").Key(key).MustString(filepath.Join(dataPath, (key + ".db"))))
|
||||
}
|
||||
app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/")
|
||||
app.config.Section("email").Key("no_username").SetValue(strconv.FormatBool(app.config.Section("email").Key("no_username").MustBool(false)))
|
||||
|
||||
app.MustSetValue("password_resets", "email_html", "jfa-go:"+"email.html")
|
||||
app.MustSetValue("password_resets", "email_text", "jfa-go:"+"email.txt")
|
||||
|
||||
app.MustSetValue("invite_emails", "email_html", "jfa-go:"+"invite-email.html")
|
||||
app.MustSetValue("invite_emails", "email_text", "jfa-go:"+"invite-email.txt")
|
||||
|
||||
app.MustSetValue("email_confirmation", "email_html", "jfa-go:"+"confirmation.html")
|
||||
app.MustSetValue("email_confirmation", "email_text", "jfa-go:"+"confirmation.txt")
|
||||
|
||||
app.MustSetValue("notifications", "expiry_html", "jfa-go:"+"expired.html")
|
||||
app.MustSetValue("notifications", "expiry_text", "jfa-go:"+"expired.txt")
|
||||
|
||||
app.MustSetValue("notifications", "created_html", "jfa-go:"+"created.html")
|
||||
app.MustSetValue("notifications", "created_text", "jfa-go:"+"created.txt")
|
||||
|
||||
app.MustSetValue("deletion", "email_html", "jfa-go:"+"deleted.html")
|
||||
app.MustSetValue("deletion", "email_text", "jfa-go:"+"deleted.txt")
|
||||
|
||||
app.MustSetValue("smtp", "hello_hostname", "localhost")
|
||||
app.MustSetValue("smtp", "cert_validation", "true")
|
||||
|
||||
sc := app.config.Section("discord").Key("start_command").MustString("start")
|
||||
app.config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
|
||||
|
||||
jfUrl := app.config.Section("jellyfin").Key("server").String()
|
||||
if !(strings.HasPrefix(jfUrl, "http://") || strings.HasPrefix(jfUrl, "https://")) {
|
||||
app.config.Section("jellyfin").Key("server").SetValue("http://" + jfUrl)
|
||||
// If true, ExternalDomain() will return one based on the reported Host (ideally reported in "Host" or "X-Forwarded-Host" by the reverse proxy), falling back to externalDomain if not set.
|
||||
UseProxyHost = config.Section("ui").Key("use_proxy_host").MustBool(false)
|
||||
externalURI = strings.TrimSuffix(strings.TrimSuffix(config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/")
|
||||
if !strings.HasSuffix(externalURI, PAGES.Base) {
|
||||
logs.err.Println(lm.NoURLSuffix)
|
||||
}
|
||||
if externalURI == "" {
|
||||
if UseProxyHost {
|
||||
logs.err.Println(lm.NoExternalHost + lm.LoginWontSave + lm.SetExternalHostDespiteUseProxyHost)
|
||||
} else {
|
||||
logs.err.Println(lm.NoExternalHost + lm.LoginWontSave)
|
||||
}
|
||||
}
|
||||
u, err := url.Parse(externalURI)
|
||||
if err == nil {
|
||||
externalDomain = u.Hostname()
|
||||
}
|
||||
|
||||
config.Section("email").Key("no_username").SetValue(strconv.FormatBool(config.Section("email").Key("no_username").MustBool(false)))
|
||||
|
||||
// FIXME: Remove all these, eventually
|
||||
// config.MustSetValue("password_resets", "email_html", "jfa-go:"+"password-reset.html")
|
||||
// config.MustSetValue("password_resets", "email_text", "jfa-go:"+"password-reset.txt")
|
||||
|
||||
// config.MustSetValue("invite_emails", "email_html", "jfa-go:"+"invite-email.html")
|
||||
// config.MustSetValue("invite_emails", "email_text", "jfa-go:"+"invite-email.txt")
|
||||
|
||||
// config.MustSetValue("email_confirmation", "email_html", "jfa-go:"+"confirmation.html")
|
||||
// config.MustSetValue("email_confirmation", "email_text", "jfa-go:"+"confirmation.txt")
|
||||
|
||||
// config.MustSetValue("notifications", "expiry_html", "jfa-go:"+"expired.html")
|
||||
// config.MustSetValue("notifications", "expiry_text", "jfa-go:"+"expired.txt")
|
||||
|
||||
// config.MustSetValue("notifications", "created_html", "jfa-go:"+"created.html")
|
||||
// config.MustSetValue("notifications", "created_text", "jfa-go:"+"created.txt")
|
||||
|
||||
// config.MustSetValue("deletion", "email_html", "jfa-go:"+"deleted.html")
|
||||
// config.MustSetValue("deletion", "email_text", "jfa-go:"+"deleted.txt")
|
||||
|
||||
// Deletion template is good enough for these as well.
|
||||
app.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html")
|
||||
app.MustSetValue("disable_enable", "disabled_text", "jfa-go:"+"deleted.txt")
|
||||
app.MustSetValue("disable_enable", "enabled_html", "jfa-go:"+"deleted.html")
|
||||
app.MustSetValue("disable_enable", "enabled_text", "jfa-go:"+"deleted.txt")
|
||||
// config.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html")
|
||||
// config.MustSetValue("disable_enable", "disabled_text", "jfa-go:"+"deleted.txt")
|
||||
// config.MustSetValue("disable_enable", "enabled_html", "jfa-go:"+"deleted.html")
|
||||
// config.MustSetValue("disable_enable", "enabled_text", "jfa-go:"+"deleted.txt")
|
||||
|
||||
app.MustSetValue("welcome_email", "email_html", "jfa-go:"+"welcome.html")
|
||||
app.MustSetValue("welcome_email", "email_text", "jfa-go:"+"welcome.txt")
|
||||
// config.MustSetValue("welcome_email", "email_html", "jfa-go:"+"welcome.html")
|
||||
// config.MustSetValue("welcome_email", "email_text", "jfa-go:"+"welcome.txt")
|
||||
|
||||
app.MustSetValue("template_email", "email_html", "jfa-go:"+"template.html")
|
||||
app.MustSetValue("template_email", "email_text", "jfa-go:"+"template.txt")
|
||||
// config.MustSetValue("template_email", "email_html", "jfa-go:"+"template.html")
|
||||
// config.MustSetValue("template_email", "email_text", "jfa-go:"+"template.txt")
|
||||
|
||||
app.MustSetValue("user_expiry", "behaviour", "disable_user")
|
||||
app.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html")
|
||||
app.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt")
|
||||
config.MustSetValue("user_expiry", "behaviour", "disable_user")
|
||||
// config.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html")
|
||||
// config.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt")
|
||||
|
||||
app.MustSetValue("matrix", "topic", "Jellyfin notifications")
|
||||
app.MustSetValue("matrix", "show_on_reg", "true")
|
||||
// config.MustSetValue("user_expiry", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html")
|
||||
// config.MustSetValue("user_expiry", "adjustment_email_text", "jfa-go:"+"expiry-adjusted.txt")
|
||||
|
||||
app.MustSetValue("discord", "show_on_reg", "true")
|
||||
// config.MustSetValue("user_expiry", "reminder_email_html", "jfa-go:"+"expiry-reminder.html")
|
||||
// config.MustSetValue("user_expiry", "reminder_email_text", "jfa-go:"+"expiry-reminder.txt")
|
||||
|
||||
app.MustSetValue("telegram", "show_on_reg", "true")
|
||||
fnameSettingSuffix := []string{"html", "text"}
|
||||
fnameExtension := []string{"html", "txt"}
|
||||
|
||||
app.config.Section("jellyfin").Key("version").SetValue(version)
|
||||
app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
|
||||
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
|
||||
for _, cc := range customContent {
|
||||
if cc.SourceFile.DefaultValue == "" {
|
||||
continue
|
||||
}
|
||||
for i := range fnameSettingSuffix {
|
||||
config.MustSetValue(cc.SourceFile.Section, cc.SourceFile.SettingPrefix+fnameSettingSuffix[i], "jfa-go:"+cc.SourceFile.DefaultValue+"."+fnameExtension[i])
|
||||
}
|
||||
}
|
||||
|
||||
// These two settings are pretty much the same
|
||||
url1 := app.config.Section("invite_emails").Key("url_base").String()
|
||||
url2 := app.config.Section("password_resets").Key("url_base").String()
|
||||
app.MustSetValue("password_resets", "url_base", strings.TrimSuffix(url1, "/invite"))
|
||||
app.MustSetValue("invite_emails", "url_base", url2)
|
||||
config.MustSetValue("smtp", "hello_hostname", "localhost")
|
||||
config.MustSetValue("smtp", "cert_validation", "true")
|
||||
config.MustSetValue("smtp", "auth_type", "4")
|
||||
config.MustSetValue("smtp", "port", "465")
|
||||
|
||||
messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false)
|
||||
telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false)
|
||||
discordEnabled = app.config.Section("discord").Key("enabled").MustBool(false)
|
||||
matrixEnabled = app.config.Section("matrix").Key("enabled").MustBool(false)
|
||||
config.MustSetValue("activity_log", "keep_n_records", "1000")
|
||||
config.MustSetValue("activity_log", "delete_after_days", "90")
|
||||
|
||||
sc := config.Section("discord").Key("start_command").MustString("start")
|
||||
config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
|
||||
|
||||
config.MustSetValue("email", "collect", "true")
|
||||
collect := config.Section("email").Key("collect").MustBool(true)
|
||||
required := config.Section("email").Key("required").MustBool(false) && collect
|
||||
config.Section("email").Key("required").SetValue(strconv.FormatBool(required))
|
||||
unique := config.Section("email").Key("require_unique").MustBool(false) && collect
|
||||
config.Section("email").Key("require_unique").SetValue(strconv.FormatBool(unique))
|
||||
|
||||
config.MustSetValue("matrix", "topic", "Jellyfin notifications")
|
||||
config.MustSetValue("matrix", "show_on_reg", "true")
|
||||
|
||||
config.MustSetValue("discord", "show_on_reg", "true")
|
||||
|
||||
config.MustSetValue("telegram", "show_on_reg", "true")
|
||||
|
||||
config.MustSetValue("backups", "every_n_minutes", "1440")
|
||||
config.MustSetValue("backups", "path", filepath.Join(dataPath, "backups"))
|
||||
config.MustSetValue("backups", "keep_n_backups", "20")
|
||||
config.MustSetValue("backups", "keep_previous_version_backup", "true")
|
||||
|
||||
config.Section("jellyfin").Key("version").SetValue(version)
|
||||
config.Section("jellyfin").Key("device").SetValue("jfa-go")
|
||||
config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
|
||||
|
||||
config.MustSetValue("jellyfin", "cache_timeout", "30")
|
||||
config.MustSetValue("jellyfin", "web_cache_async_timeout", "1")
|
||||
config.MustSetValue("jellyfin", "web_cache_sync_timeout", "10")
|
||||
config.MustSetValue("jellyfin", "activity_cache_sync_timeout_seconds", "20")
|
||||
|
||||
LOGIP = config.Section("advanced").Key("log_ips").MustBool(false)
|
||||
LOGIPU = config.Section("advanced").Key("log_ips_users").MustBool(false)
|
||||
|
||||
config.MustSetValue("advanced", "auth_retry_count", "6")
|
||||
config.MustSetValue("advanced", "auth_retry_gap", "10")
|
||||
|
||||
config.MustSetValue("ui", "port", "8056")
|
||||
config.MustSetValue("advanced", "tls_port", "8057")
|
||||
|
||||
config.MustSetValue("advanced", "value_log_size", "512")
|
||||
|
||||
pwrMethods := []string{"allow_pwr_username", "allow_pwr_email", "allow_pwr_contact_method"}
|
||||
allDisabled := true
|
||||
for _, v := range pwrMethods {
|
||||
if config.Section("user_page").Key(v).MustBool(true) {
|
||||
allDisabled = false
|
||||
}
|
||||
}
|
||||
if allDisabled {
|
||||
logs.info.Println(lm.EnableAllPWRMethods)
|
||||
for _, v := range pwrMethods {
|
||||
config.Section("user_page").Key(v).SetValue("true")
|
||||
}
|
||||
}
|
||||
|
||||
messagesEnabled = config.Section("messages").Key("enabled").MustBool(false)
|
||||
telegramEnabled = config.Section("telegram").Key("enabled").MustBool(false)
|
||||
discordEnabled = config.Section("discord").Key("enabled").MustBool(false)
|
||||
matrixEnabled = config.Section("matrix").Key("enabled").MustBool(false)
|
||||
if !messagesEnabled {
|
||||
emailEnabled = false
|
||||
telegramEnabled = false
|
||||
discordEnabled = false
|
||||
matrixEnabled = false
|
||||
} else if app.config.Section("email").Key("method").MustString("") == "" {
|
||||
} else if config.Section("email").Key("method").MustString("") == "" {
|
||||
emailEnabled = false
|
||||
} else {
|
||||
emailEnabled = true
|
||||
@@ -135,9 +343,64 @@ func (app *appContext) loadConfig() error {
|
||||
messagesEnabled = false
|
||||
}
|
||||
|
||||
app.MustSetValue("updates", "enabled", "true")
|
||||
releaseChannel := app.config.Section("updates").Key("channel").String()
|
||||
if app.config.Section("updates").Key("enabled").MustBool(false) {
|
||||
if proxyEnabled := config.Section("advanced").Key("proxy").MustBool(false); proxyEnabled {
|
||||
config.proxyConfig = &easyproxy.ProxyConfig{}
|
||||
config.proxyConfig.Protocol = easyproxy.HTTP
|
||||
if strings.Contains(config.Section("advanced").Key("proxy_protocol").MustString("http"), "socks") {
|
||||
config.proxyConfig.Protocol = easyproxy.SOCKS5
|
||||
}
|
||||
config.proxyConfig.Addr = config.Section("advanced").Key("proxy_address").MustString("")
|
||||
config.proxyConfig.User = config.Section("advanced").Key("proxy_user").MustString("")
|
||||
config.proxyConfig.Password = config.Section("advanced").Key("proxy_password").MustString("")
|
||||
config.proxyTransport, err = easyproxy.NewTransport(*(config.proxyConfig))
|
||||
if err != nil {
|
||||
logs.err.Printf(lm.FailedInitProxy, config.proxyConfig.Addr, err)
|
||||
// As explained in lm.FailedInitProxy, sleep here might grab the admin's attention,
|
||||
// Since we don't crash on this failing.
|
||||
time.Sleep(15 * time.Second)
|
||||
config.proxyConfig = nil
|
||||
config.proxyTransport = nil
|
||||
} else {
|
||||
logs.info.Printf(lm.InitProxy, config.proxyConfig.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
config.MustSetValue("updates", "enabled", "true")
|
||||
|
||||
substituteStrings = config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("")
|
||||
|
||||
if substituteStrings != "" {
|
||||
v := config.Section("ui").Key("success_message")
|
||||
v.SetValue(strings.ReplaceAll(v.String(), "Jellyfin", substituteStrings))
|
||||
}
|
||||
|
||||
datePattern = config.Section("messages").Key("date_format").String()
|
||||
timePattern = `%H:%M`
|
||||
if !(config.Section("messages").Key("use_24h").MustBool(true)) {
|
||||
timePattern = `%I:%M %p`
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// ReloadDependents re-initialises or applies changes to components of the app which can be reconfigured without restarting.
|
||||
func (config *Config) ReloadDependents(app *appContext) {
|
||||
oldFormLang := config.Section("ui").Key("language").MustString("")
|
||||
if oldFormLang != "" {
|
||||
app.storage.lang.chosenUserLang = oldFormLang
|
||||
}
|
||||
newFormLang := config.Section("ui").Key("language-form").MustString("")
|
||||
if newFormLang != "" {
|
||||
app.storage.lang.chosenUserLang = newFormLang
|
||||
}
|
||||
|
||||
app.storage.lang.chosenAdminLang = config.Section("ui").Key("language-admin").MustString("en-us")
|
||||
app.storage.lang.chosenEmailLang = config.Section("email").Key("language").MustString("en-us")
|
||||
app.storage.lang.chosenPWRLang = config.Section("password_resets").Key("language").MustString("en-us")
|
||||
app.storage.lang.chosenTelegramLang = config.Section("telegram").Key("language").MustString("en-us")
|
||||
|
||||
releaseChannel := config.Section("updates").Key("channel").String()
|
||||
if config.Section("updates").Key("enabled").MustBool(false) {
|
||||
v := version
|
||||
if releaseChannel == "stable" {
|
||||
if version == "git" {
|
||||
@@ -146,7 +409,10 @@ func (app *appContext) loadConfig() error {
|
||||
} else if releaseChannel == "unstable" {
|
||||
v = "git"
|
||||
}
|
||||
app.updater = newUpdater(baseURL, namespace, repo, v, commit, updater)
|
||||
app.updater = NewUpdater(baseURL, namespace, repo, v, commit, updater)
|
||||
if config.proxyTransport != nil {
|
||||
app.updater.SetTransport(config.proxyTransport)
|
||||
}
|
||||
}
|
||||
if releaseChannel == "" {
|
||||
if version == "git" {
|
||||
@@ -154,39 +420,115 @@ func (app *appContext) loadConfig() error {
|
||||
} else {
|
||||
releaseChannel = "stable"
|
||||
}
|
||||
app.MustSetValue("updates", "channel", releaseChannel)
|
||||
config.MustSetValue("updates", "channel", releaseChannel)
|
||||
}
|
||||
|
||||
app.storage.customEmails_path = app.config.Section("files").Key("custom_emails").String()
|
||||
app.storage.loadCustomEmails()
|
||||
app.email = NewEmailer(config, app.storage, app.LoggerSet)
|
||||
|
||||
app.MustSetValue("user_page", "enabled", "true")
|
||||
if app.config.Section("user_page").Key("enabled").MustBool(false) {
|
||||
app.storage.userPage_path = app.config.Section("files").Key("custom_user_page_content").String()
|
||||
app.storage.loadUserPageContent()
|
||||
}
|
||||
|
||||
substituteStrings = app.config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("")
|
||||
|
||||
if substituteStrings != "" {
|
||||
v := app.config.Section("ui").Key("success_message")
|
||||
v.SetValue(strings.ReplaceAll(v.String(), "Jellyfin", substituteStrings))
|
||||
}
|
||||
|
||||
oldFormLang := app.config.Section("ui").Key("language").MustString("")
|
||||
if oldFormLang != "" {
|
||||
app.storage.lang.chosenUserLang = oldFormLang
|
||||
}
|
||||
newFormLang := app.config.Section("ui").Key("language-form").MustString("")
|
||||
if newFormLang != "" {
|
||||
app.storage.lang.chosenUserLang = newFormLang
|
||||
}
|
||||
app.storage.lang.chosenAdminLang = app.config.Section("ui").Key("language-admin").MustString("en-us")
|
||||
app.storage.lang.chosenEmailLang = app.config.Section("email").Key("language").MustString("en-us")
|
||||
app.storage.lang.chosenPWRLang = app.config.Section("password_resets").Key("language").MustString("en-us")
|
||||
app.storage.lang.chosenTelegramLang = app.config.Section("telegram").Key("language").MustString("en-us")
|
||||
|
||||
app.email = NewEmailer(app)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *appContext) ReloadConfig() {
|
||||
var err error = nil
|
||||
app.config, err = NewConfig(app.configPath, app.dataPath, app.LoggerSet)
|
||||
if err != nil {
|
||||
app.err.Fatalf(lm.FailedLoadConfig, app.configPath, err)
|
||||
}
|
||||
|
||||
app.config.ReloadDependents(app)
|
||||
app.info.Printf(lm.LoadConfig, app.configPath)
|
||||
}
|
||||
|
||||
func (app *appContext) PatchConfigBase() {
|
||||
conf := app.configBase
|
||||
// Load language options
|
||||
formOptions := app.storage.lang.User.getOptions()
|
||||
pwrOptions := app.storage.lang.PasswordReset.getOptions()
|
||||
adminOptions := app.storage.lang.Admin.getOptions()
|
||||
emailOptions := app.storage.lang.Email.getOptions()
|
||||
telegramOptions := app.storage.lang.Email.getOptions()
|
||||
|
||||
for i, section := range app.configBase.Sections {
|
||||
if section.Section == "updates" && updater == "" {
|
||||
section.Meta.Disabled = true
|
||||
}
|
||||
for j, setting := range section.Settings {
|
||||
if section.Section == "ui" {
|
||||
if setting.Setting == "language-form" {
|
||||
setting.Options = formOptions
|
||||
setting.Value = "en-us"
|
||||
} else if setting.Setting == "language-admin" {
|
||||
setting.Options = adminOptions
|
||||
setting.Value = "en-us"
|
||||
}
|
||||
} else if section.Section == "password_resets" {
|
||||
if setting.Setting == "language" {
|
||||
setting.Options = pwrOptions
|
||||
setting.Value = "en-us"
|
||||
}
|
||||
} else if section.Section == "email" {
|
||||
if setting.Setting == "language" {
|
||||
setting.Options = emailOptions
|
||||
setting.Value = "en-us"
|
||||
}
|
||||
} else if section.Section == "telegram" {
|
||||
if setting.Setting == "language" {
|
||||
setting.Options = telegramOptions
|
||||
setting.Value = "en-us"
|
||||
}
|
||||
} else if section.Section == "smtp" {
|
||||
if setting.Setting == "ssl_cert" && PLATFORM == "windows" {
|
||||
// Not accurate but the effect is hiding the option, which we want.
|
||||
setting.Deprecated = true
|
||||
}
|
||||
} else if section.Section == "matrix" {
|
||||
if setting.Setting == "encryption" && !MatrixE2EE() {
|
||||
// Not accurate but the effect is hiding the option, which we want.
|
||||
setting.Deprecated = true
|
||||
}
|
||||
}
|
||||
val := app.config.Section(section.Section).Key(setting.Setting)
|
||||
switch setting.Type {
|
||||
case "list":
|
||||
setting.Value = val.StringsWithShadows("|")
|
||||
case "text", "email", "select", "password", "note":
|
||||
setting.Value = val.MustString("")
|
||||
case "number":
|
||||
setting.Value = val.MustInt(0)
|
||||
case "bool":
|
||||
setting.Value = val.MustBool(false)
|
||||
}
|
||||
section.Settings[j] = setting
|
||||
}
|
||||
conf.Sections[i] = section
|
||||
}
|
||||
app.patchedConfig = conf
|
||||
}
|
||||
|
||||
func (app *appContext) PatchConfigDiscordRoles() {
|
||||
if !discordEnabled {
|
||||
return
|
||||
}
|
||||
r, err := app.discord.ListRoles()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
roles := make([]common.Option, len(r)+1)
|
||||
roles[0] = common.Option{"", "None"}
|
||||
for i, role := range r {
|
||||
roles[i+1] = role
|
||||
}
|
||||
|
||||
for i, section := range app.patchedConfig.Sections {
|
||||
if section.Section != "discord" {
|
||||
continue
|
||||
}
|
||||
for j, setting := range section.Settings {
|
||||
if setting.Setting != "apply_role" {
|
||||
continue
|
||||
}
|
||||
setting.Options = roles
|
||||
section.Settings[j] = setting
|
||||
}
|
||||
app.patchedConfig.Sections[i] = section
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
### fixconfig
|
||||
|
||||
Python's `json` library retains the order of data in a JSON file, which meant settings sent to the web page would be in the right order. Go's `encoding/json` and maps do not retain order, so `enumerate/enumerate_config.py` opens the json file, and for each section, adds an "order" array which tells the web page in which order to display settings.
|
||||
Specify the input and output files with `-i` and `-o` respectively.
|
||||
1
config/README.txt
Normal file
@@ -0,0 +1 @@
|
||||
The two python scripts here, `config-json-to-new-yaml.py` and `gen-rough-schema.py` were used to convert the old format, which was stored in a JSON file, to the new format in YAML. The latter script is used to get the possible values for settings and sections, so they could be properly defined in common/config.go.
|
||||
1763
config/config-base.yaml
Normal file
35
config/config-json-to-new-yaml.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from ruamel.yaml import YAML
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
yaml = YAML()
|
||||
|
||||
# c = yaml.load(Path(sys.argv[len(sys.argv)-1]))
|
||||
with open(sys.argv[len(sys.argv)-1], 'r') as f:
|
||||
c = json.load(f)
|
||||
|
||||
c.pop("order")
|
||||
|
||||
c1 = c.copy()
|
||||
c1["sections"] = []
|
||||
for section in c["sections"]:
|
||||
codeSection = { "section": section }
|
||||
s = codeSection | c["sections"][section]
|
||||
s.pop("order")
|
||||
c1["sections"].append(s)
|
||||
|
||||
c2 = c.copy()
|
||||
c2["sections"] = []
|
||||
|
||||
for section in c1["sections"]:
|
||||
sArray = []
|
||||
for setting in section["settings"]:
|
||||
codeSetting = { "setting": setting }
|
||||
s = codeSetting | section["settings"][setting]
|
||||
sArray.append(s)
|
||||
|
||||
section["settings"] = sArray
|
||||
c2["sections"].append(section)
|
||||
|
||||
|
||||
yaml.dump(c2, sys.stdout)
|
||||
40
config/gen-rough-schema.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import json
|
||||
import sys
|
||||
|
||||
sectionSchema = {}
|
||||
metaSchema = {}
|
||||
settingSchema = {}
|
||||
typeValues = {}
|
||||
|
||||
# c = yaml.load(Path(sys.argv[len(sys.argv)-1]))
|
||||
with open(sys.argv[len(sys.argv)-1], 'r') as f:
|
||||
c = json.load(f)
|
||||
|
||||
for section in c["sections"]:
|
||||
for key in c["sections"][section]:
|
||||
sectionSchema[key] = True
|
||||
|
||||
for key in c["sections"][section]["meta"]:
|
||||
metaSchema[key] = c["sections"][section]["meta"][key]
|
||||
|
||||
for setting in c["sections"][section]["settings"]:
|
||||
for field in c["sections"][section]["settings"][setting]:
|
||||
settingSchema[field] = c["sections"][section]["settings"][setting][field]
|
||||
typeValues[c["sections"][section]["settings"][setting]["type"]] = True
|
||||
|
||||
print("Section Content:")
|
||||
for v in sectionSchema:
|
||||
print(v)
|
||||
print("---")
|
||||
print("Meta Schema")
|
||||
for v in metaSchema:
|
||||
print(v, "=", type(metaSchema[v]))
|
||||
print("---")
|
||||
print("Setting Schema")
|
||||
for v in settingSchema:
|
||||
print(v, "=", type(settingSchema[v]))
|
||||
print("---")
|
||||
print("Possible Types")
|
||||
for v in typeValues:
|
||||
print(v)
|
||||
|
||||
252
css/base.css
@@ -3,6 +3,7 @@
|
||||
@import "./dark.css";
|
||||
@import "./tooltip.css";
|
||||
@import "./loader.css";
|
||||
@import "./fonts.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@@ -13,8 +14,12 @@
|
||||
--border-width-2: 3px;
|
||||
--border-width-4: 5px;
|
||||
--border-width-8: 8px;
|
||||
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
--bg-light: #fff;
|
||||
--bg-dark: #101010;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.light {
|
||||
@@ -23,14 +28,15 @@
|
||||
|
||||
.dark {
|
||||
--settings-section-button-filter: 80%;
|
||||
color-scheme: dark !important;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background-color: #101010;
|
||||
background-color: var(--bg-dark);
|
||||
}
|
||||
|
||||
html:not(.dark) body {
|
||||
background-color: #fff;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
|
||||
.dark select, .dark option, .dark input {
|
||||
@@ -59,21 +65,7 @@ html:not(.dark) .card.\@low:not(.\~neutral):not(.\~positive):not(.\~urge):not(.\
|
||||
display: initial;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
margin: 5% 20% 5% 20%;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.page-container {
|
||||
margin: 2%;
|
||||
margin-top: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1000px) {
|
||||
:root {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@media screen and (max-width: 1024px) {
|
||||
.table-responsive table {
|
||||
min-width: 800px;
|
||||
}
|
||||
@@ -103,48 +95,6 @@ div.card:contains(section.banner.footer) {
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.al {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ar {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ac {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.w-100 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.h-100 {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.align-top {
|
||||
align-items: top;
|
||||
}
|
||||
|
||||
.flex-expand {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-row-group {
|
||||
display: block;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -169,23 +119,7 @@ span.sm:not(.heading) {
|
||||
margin: .25rem;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flex-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.flex-form {
|
||||
flex: 1;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Who knows for half of these to be honest */
|
||||
@media screen and (max-width: 400px) {
|
||||
.row {
|
||||
flex-direction: column;
|
||||
@@ -216,69 +150,6 @@ sup.\~critical, .text-critical {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.inv-created-users strong,p {
|
||||
padding-left: 0.5rem;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.inv-created-users.empty strong,p {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.inv {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.inv-table {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.inv-profilearea {
|
||||
min-width: 20%;
|
||||
}
|
||||
|
||||
.inv-profileselect {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.inv-codearea {
|
||||
max-width: 40%;
|
||||
min-width: 10rem;
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inv-empty .inv-codearea {
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
|
||||
.invite-link {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.no-pad {
|
||||
padding: 0px 0px 0px 0px;
|
||||
}
|
||||
|
||||
.elem-pad > * {
|
||||
margin: var(--spacing-4, 1rem);
|
||||
}
|
||||
|
||||
.icon.clickable {
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
box-sizing: border-box; /* fixes weird length issue with inputs */
|
||||
}
|
||||
@@ -297,10 +168,6 @@ sup.\~critical, .text-critical {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex-auto {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.center {
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -309,14 +176,6 @@ sup.\~critical, .text-critical {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.no-lp {
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.focused {
|
||||
display: block;
|
||||
}
|
||||
@@ -345,9 +204,9 @@ sup.\~critical, .text-critical {
|
||||
font-size: 1rem;
|
||||
padding-top: 0.1rem;
|
||||
padding-bottom: 0.1rem;
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 1rem;
|
||||
max-width: 75%;
|
||||
margin-inline-start: 0.5rem;
|
||||
margin-inline-end: 1rem;
|
||||
width: 5rem;;
|
||||
}
|
||||
|
||||
.stealth-input-hidden {
|
||||
@@ -359,15 +218,8 @@ sup.\~critical, .text-critical {
|
||||
padding-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.settings-section-button {
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.settings-section-button:hover, .settings-section-button:focus {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
background-color: var(--color-neutral-normal-fill);
|
||||
filter: brightness(var(--settings-section-button-filter)) !important;
|
||||
}
|
||||
@@ -380,7 +232,7 @@ sup.\~critical, .text-critical {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
.textarea:not(code-input *) {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
@@ -392,7 +244,7 @@ sup.\~critical, .text-critical {
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
select, textarea {
|
||||
select, textarea:not(code-input *) {
|
||||
color: inherit;
|
||||
border: 0 solid var(--color-neutral-300);
|
||||
appearance: none;
|
||||
@@ -400,7 +252,7 @@ select, textarea {
|
||||
-moz-appearance: none;
|
||||
}
|
||||
|
||||
html.dark textarea {
|
||||
html.dark textarea:not(code-input *) {
|
||||
background-color: #202020
|
||||
}
|
||||
|
||||
@@ -413,17 +265,25 @@ table {
|
||||
color: var(--color-content);
|
||||
}
|
||||
|
||||
table.table.manual-pad th, table.table.manual-pad td {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
table.table-p-0 th, table.table-p-0 td {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
td:dir(rtl), th:dir(rtl) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
p.top {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
#notification-box {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
@@ -449,7 +309,7 @@ p.top {
|
||||
bottom: 115%;
|
||||
}
|
||||
|
||||
pre {
|
||||
pre:not(code-input *) {
|
||||
white-space: pre-wrap; /* css-3 */
|
||||
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
|
||||
white-space: -pre-wrap; /* Opera 4-6 */
|
||||
@@ -572,7 +432,6 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.g-recaptcha {
|
||||
overflow: hidden;
|
||||
width: 296px;
|
||||
@@ -584,3 +443,52 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
|
||||
.g-recaptcha iframe {
|
||||
margin: -2px 0px 0px -4px;
|
||||
}
|
||||
|
||||
.dropdown-manual-toggle {
|
||||
margin-bottom: -0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
section.section:not(.\~neutral) {
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.switch {
|
||||
@apply flex flex-row gap-2 items-center;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
/* seems to be the sweet spot */
|
||||
--inside-input-base: -2.1rem;
|
||||
|
||||
/* thought --spacing would do the trick but apparently not */
|
||||
--tailwind-spacing: 0.25rem;
|
||||
}
|
||||
|
||||
/* places buttons inside a sibling input element (hopefully), based on the flex gap of the parent. */
|
||||
.gap-1 > .button.inside-input {
|
||||
margin-inline-start: calc(var(--inside-input-base) - 1.0*var(--tailwind-spacing));
|
||||
}
|
||||
|
||||
.gap-2 > .button.inside-input {
|
||||
margin-inline-start: calc(var(--inside-input-base) - 2.0*var(--tailwind-spacing));
|
||||
}
|
||||
|
||||
.force-ltr {
|
||||
direction: ltr !important;
|
||||
}
|
||||
|
||||
.content ul, .content ol {
|
||||
margin-left: unset;
|
||||
margin-inline-start: 2rem;
|
||||
}
|
||||
|
||||
.content li {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.input {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
18
css/colors.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const colors = require("tailwindcss/colors");
|
||||
const dark = require("../css/dark");
|
||||
|
||||
export const colorSet = {
|
||||
neutral: colors.slate,
|
||||
positive: colors.green,
|
||||
urge: colors.violet,
|
||||
warning: colors.yellow,
|
||||
info: colors.blue,
|
||||
critical: colors.red,
|
||||
d_neutral: dark.d_neutral,
|
||||
d_positive: dark.d_positive,
|
||||
d_urge: dark.d_urge,
|
||||
d_warning: dark.d_warning,
|
||||
d_info: dark.d_info,
|
||||
d_critical: dark.d_critical,
|
||||
discord: "#5865F2"
|
||||
};
|
||||
44
css/fonts.css
Normal file
@@ -0,0 +1,44 @@
|
||||
/* hanken-grotesk-regular - cyrillic-ext_latin_vietnamese */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Hanken Grotesk';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/hanken-grotesk-v8-cyrillic-ext_latin_vietnamese-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
||||
/* hanken-grotesk-500 - cyrillic-ext_latin_vietnamese */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Hanken Grotesk';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/hanken-grotesk-v8-cyrillic-ext_latin_vietnamese-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
||||
/* hanken-grotesk-500italic - cyrillic-ext_latin_vietnamese */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Hanken Grotesk';
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/hanken-grotesk-v8-cyrillic-ext_latin_vietnamese-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
||||
/* hanken-grotesk-700 - cyrillic-ext_latin_vietnamese */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Hanken Grotesk';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('../fonts/hanken-grotesk-v8-cyrillic-ext_latin_vietnamese-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
||||
/* hanken-grotesk-700italic - cyrillic-ext_latin_vietnamese */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Hanken Grotesk';
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
src: url('../fonts/hanken-grotesk-v8-cyrillic-ext_latin_vietnamese-700italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
color: rgba(0, 0, 0, 0) !important;
|
||||
}
|
||||
|
||||
.loader.rel {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loader .dot {
|
||||
--diameter: 0.5rem;
|
||||
--radius: calc(var(--diameter) / 2);
|
||||
@@ -15,6 +19,12 @@
|
||||
left: calc(50% - var(--radius));
|
||||
animation: osc 1s cubic-bezier(.72,.16,.31,.97) infinite;
|
||||
}
|
||||
|
||||
.loader.rel .dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.loader.loader-sm .dot {
|
||||
--deviation: 10%;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,26 @@
|
||||
background-color: rgba(0,0,0,40%);
|
||||
}
|
||||
|
||||
.wall {
|
||||
position: fixed;
|
||||
z-index: 11;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
html.dark .wall {
|
||||
background-color: var(--bg-dark);
|
||||
}
|
||||
|
||||
html:not(.dark) .wall {
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
color: #aaa;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
.tooltip .content {
|
||||
visibility: hidden;
|
||||
max-width: 10rem;
|
||||
opacity: 0;
|
||||
max-width: 16rem;
|
||||
min-width: 6rem;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
@@ -13,24 +14,58 @@
|
||||
border-radius: 6px;
|
||||
overflow-wrap: break-word;
|
||||
text-align: center;
|
||||
transition: opacity 100ms;
|
||||
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: -1rem;
|
||||
}
|
||||
|
||||
.tooltip.below .content {
|
||||
top: calc(100% + 0.125rem);
|
||||
left: 50%;
|
||||
right: 0;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.tooltip.above .content {
|
||||
top: unset;
|
||||
bottom: calc(100% + 0.125rem);
|
||||
left: 50%;
|
||||
right: 0;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.tooltip.darker .content {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.tooltip.right .content {
|
||||
left: 120%;
|
||||
}
|
||||
|
||||
.tooltip.right:dir(rtl):not(.force-ltr) .content {
|
||||
right: 120%;
|
||||
left: unset;
|
||||
}
|
||||
|
||||
.tooltip.left .content {
|
||||
right: 120%;
|
||||
}
|
||||
|
||||
.tooltip.left:dir(rtl):not(.force-ltr) .content {
|
||||
left: 120%;
|
||||
right: unset;
|
||||
}
|
||||
|
||||
.tooltip .content.sm {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tooltip:hover .content {
|
||||
.tooltip:hover .content,
|
||||
.tooltip:focus .content,
|
||||
.tooltip:focus-within .content
|
||||
{
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
427
customcontent.go
Normal file
@@ -0,0 +1,427 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
)
|
||||
|
||||
func defaultVars(vars ...string) []string {
|
||||
return slices.Concat(vars, []string{
|
||||
"username",
|
||||
})
|
||||
}
|
||||
|
||||
func defaultVals(vals map[string]any) map[string]any {
|
||||
maps.Copy(vals, map[string]any{
|
||||
"username": "Username",
|
||||
})
|
||||
return vals
|
||||
}
|
||||
|
||||
func vendorHeader(config *Config, lang *emailLang) string { return "jfa-go" }
|
||||
func serverHeader(config *Config, lang *emailLang) string {
|
||||
if substituteStrings == "" {
|
||||
return "Jellyfin"
|
||||
} else {
|
||||
return substituteStrings
|
||||
}
|
||||
}
|
||||
func messageFooter(config *Config, lang *emailLang) string {
|
||||
return config.Section("messages").Key("message").String()
|
||||
}
|
||||
|
||||
var customContent = map[string]CustomContentInfo{
|
||||
"EmailConfirmation": {
|
||||
Name: "EmailConfirmation",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].EmailConfirmation["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return config.Section("email_confirmation").Key("subject").MustString(lang.EmailConfirmation.get("title"))
|
||||
},
|
||||
Variables: defaultVars(
|
||||
"confirmationURL",
|
||||
),
|
||||
Placeholders: defaultVals(map[string]any{
|
||||
"confirmationURL": "https://sub2.test.url/invite/xxxxxx?key=xxxxxx",
|
||||
}),
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "email_confirmation",
|
||||
SettingPrefix: "email_",
|
||||
DefaultValue: "confirmation",
|
||||
},
|
||||
},
|
||||
"ExpiryReminder": {
|
||||
Name: "ExpiryReminder",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].ExpiryReminder["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return config.Section("user_expiry").Key("reminder_subject").MustString(lang.ExpiryReminder.get("title"))
|
||||
},
|
||||
Variables: defaultVars(
|
||||
"expiresIn",
|
||||
"date",
|
||||
"time",
|
||||
),
|
||||
Placeholders: defaultVals(map[string]any{
|
||||
"expiresIn": "3d 4h 32m",
|
||||
"date": "20/08/25",
|
||||
"time": "14:19",
|
||||
}),
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "user_expiry",
|
||||
SettingPrefix: "reminder_email_",
|
||||
DefaultValue: "expiry-reminder",
|
||||
},
|
||||
},
|
||||
"InviteEmail": {
|
||||
Name: "InviteEmail",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].InviteEmail["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return config.Section("invite_emails").Key("subject").MustString(lang.InviteEmail.get("title"))
|
||||
},
|
||||
Variables: []string{
|
||||
"date",
|
||||
"time",
|
||||
"expiresInMinutes",
|
||||
"inviteURL",
|
||||
},
|
||||
Placeholders: defaultVals(map[string]any{
|
||||
"date": "01/01/01",
|
||||
"time": "00:00",
|
||||
"expiresInMinutes": "16d 13h 19m",
|
||||
"inviteURL": "https://sub2.test.url/invite/xxxxxx",
|
||||
}),
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "invite_emails",
|
||||
SettingPrefix: "email_",
|
||||
DefaultValue: "invite-email",
|
||||
},
|
||||
},
|
||||
"InviteExpiry": {
|
||||
Name: "InviteExpiry",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].InviteExpiry["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return lang.InviteExpiry.get("title")
|
||||
},
|
||||
HeaderText: vendorHeader,
|
||||
FooterText: func(config *Config, lang *emailLang) string {
|
||||
return lang.InviteExpiry.get("notificationNotice")
|
||||
},
|
||||
Variables: []string{
|
||||
"code",
|
||||
"time",
|
||||
},
|
||||
Placeholders: map[string]any{
|
||||
"code": "\"xxxxxx\"",
|
||||
"time": "01/01/01 00:00",
|
||||
},
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "notifications",
|
||||
SettingPrefix: "expiry_",
|
||||
DefaultValue: "expired",
|
||||
},
|
||||
},
|
||||
"PasswordReset": {
|
||||
Name: "PasswordReset",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].PasswordReset["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return config.Section("password_resets").Key("subject").MustString(lang.PasswordReset.get("title"))
|
||||
},
|
||||
Variables: defaultVars(
|
||||
"date",
|
||||
"time",
|
||||
"expiresInMinutes",
|
||||
"pin",
|
||||
),
|
||||
Placeholders: defaultVals(map[string]any{
|
||||
"date": "01/01/01",
|
||||
"time": "00:00",
|
||||
"expiresInMinutes": "16d 13h 19m",
|
||||
"pin": "12-34-56",
|
||||
}),
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "password_resets",
|
||||
SettingPrefix: "email_",
|
||||
// This was the first email type added, hence the undescriptive filename.
|
||||
DefaultValue: "password-reset",
|
||||
},
|
||||
},
|
||||
"UserCreated": {
|
||||
Name: "UserCreated",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserCreated["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return lang.UserCreated.get("title")
|
||||
},
|
||||
HeaderText: vendorHeader,
|
||||
FooterText: func(config *Config, lang *emailLang) string {
|
||||
return lang.UserCreated.get("notificationNotice")
|
||||
},
|
||||
Variables: []string{
|
||||
"code",
|
||||
"name",
|
||||
"address",
|
||||
"time",
|
||||
},
|
||||
Placeholders: map[string]any{
|
||||
"name": "Subject Username",
|
||||
"code": "\"xxxxxx\"",
|
||||
"address": "Email Address",
|
||||
"time": "01/01/01 00:00",
|
||||
},
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "notifications",
|
||||
SettingPrefix: "created_",
|
||||
DefaultValue: "created",
|
||||
},
|
||||
},
|
||||
"UserDeleted": {
|
||||
Name: "UserDeleted",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserDeleted["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return config.Section("deletion").Key("subject").MustString(lang.UserDeleted.get("title"))
|
||||
},
|
||||
Variables: defaultVars(
|
||||
"reason",
|
||||
),
|
||||
Placeholders: defaultVals(map[string]any{
|
||||
"reason": "Reason",
|
||||
}),
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "deletion",
|
||||
SettingPrefix: "email_",
|
||||
DefaultValue: "deleted",
|
||||
},
|
||||
},
|
||||
"UserDisabled": {
|
||||
Name: "UserDisabled",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserDisabled["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return config.Section("disable_enable").Key("subject_disabled").MustString(lang.UserDisabled.get("title"))
|
||||
},
|
||||
Variables: defaultVars(
|
||||
"reason",
|
||||
),
|
||||
Placeholders: defaultVals(map[string]any{
|
||||
"reason": "Reason",
|
||||
}),
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "disable_enable",
|
||||
SettingPrefix: "disabled_",
|
||||
// Template is shared between deletion enabling and disabling.
|
||||
DefaultValue: "deleted",
|
||||
},
|
||||
},
|
||||
"UserEnabled": {
|
||||
Name: "UserEnabled",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserEnabled["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return config.Section("disable_enable").Key("subject_enabled").MustString(lang.UserEnabled.get("title"))
|
||||
},
|
||||
Variables: defaultVars(
|
||||
"reason",
|
||||
),
|
||||
Placeholders: defaultVals(map[string]any{
|
||||
"reason": "Reason",
|
||||
}),
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "disable_enable",
|
||||
SettingPrefix: "enabled_",
|
||||
// Template is shared between deletion enabling and disabling.
|
||||
DefaultValue: "deleted",
|
||||
},
|
||||
},
|
||||
"UserExpired": {
|
||||
Name: "UserExpired",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserExpired["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return config.Section("user_expiry").Key("subject").MustString(lang.UserExpired.get("title"))
|
||||
},
|
||||
Variables: defaultVars(),
|
||||
Placeholders: defaultVals(map[string]any{}),
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "user_expiry",
|
||||
SettingPrefix: "email_",
|
||||
DefaultValue: "user-expired",
|
||||
},
|
||||
},
|
||||
"UserExpiryAdjusted": {
|
||||
Name: "UserExpiryAdjusted",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserExpiryAdjusted["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return config.Section("user_expiry").Key("adjustment_subject").MustString(lang.UserExpiryAdjusted.get("title"))
|
||||
},
|
||||
Variables: defaultVars(
|
||||
"newExpiry",
|
||||
"reason",
|
||||
),
|
||||
Placeholders: defaultVals(map[string]any{
|
||||
"newExpiry": "01/01/01 00:00",
|
||||
"reason": "Reason",
|
||||
}),
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "user_expiry",
|
||||
SettingPrefix: "adjustment_email_",
|
||||
DefaultValue: "expiry-adjusted",
|
||||
},
|
||||
},
|
||||
"WelcomeEmail": {
|
||||
Name: "WelcomeEmail",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].WelcomeEmail["name"] },
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return config.Section("welcome_email").Key("subject").MustString(lang.WelcomeEmail.get("title"))
|
||||
},
|
||||
Variables: defaultVars(
|
||||
"jellyfinURL",
|
||||
"yourAccountWillExpire",
|
||||
),
|
||||
Conditionals: []string{
|
||||
"yourAccountWillExpire",
|
||||
},
|
||||
Placeholders: defaultVals(map[string]any{
|
||||
"jellyfinURL": "https://example.io",
|
||||
"yourAccountWillExpire": "17/08/25 14:19",
|
||||
}),
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "welcome_email",
|
||||
SettingPrefix: "email_",
|
||||
DefaultValue: "welcome",
|
||||
},
|
||||
},
|
||||
"TemplateEmail": {
|
||||
Name: "TemplateEmail",
|
||||
DisplayName: func(dict *Lang, lang string) string {
|
||||
return "EmptyCustomContent"
|
||||
},
|
||||
ContentType: CustomTemplate,
|
||||
SourceFile: ContentSourceFileInfo{
|
||||
Section: "template_email",
|
||||
SettingPrefix: "email_",
|
||||
DefaultValue: "template",
|
||||
},
|
||||
},
|
||||
"UserLogin": {
|
||||
Name: "UserLogin",
|
||||
ContentType: CustomCard,
|
||||
DisplayName: func(dict *Lang, lang string) string {
|
||||
if _, ok := dict.Admin[lang]; !ok {
|
||||
lang = dict.chosenAdminLang
|
||||
}
|
||||
return dict.Admin[lang].Strings["userPageLogin"]
|
||||
},
|
||||
Variables: []string{},
|
||||
},
|
||||
"UserPage": {
|
||||
Name: "UserPage",
|
||||
ContentType: CustomCard,
|
||||
DisplayName: func(dict *Lang, lang string) string {
|
||||
if _, ok := dict.Admin[lang]; !ok {
|
||||
lang = dict.chosenAdminLang
|
||||
}
|
||||
return dict.Admin[lang].Strings["userPagePage"]
|
||||
},
|
||||
Variables: defaultVars(),
|
||||
Placeholders: defaultVals(map[string]any{}),
|
||||
},
|
||||
"PostSignupCard": {
|
||||
Name: "PostSignupCard",
|
||||
ContentType: CustomCard,
|
||||
DisplayName: func(dict *Lang, lang string) string {
|
||||
if _, ok := dict.Admin[lang]; !ok {
|
||||
lang = dict.chosenAdminLang
|
||||
}
|
||||
return dict.Admin[lang].Strings["postSignupCard"]
|
||||
},
|
||||
Description: func(dict *Lang, lang string) string {
|
||||
if _, ok := dict.Admin[lang]; !ok {
|
||||
lang = dict.chosenAdminLang
|
||||
}
|
||||
return dict.Admin[lang].Strings["postSignupCardDescription"]
|
||||
},
|
||||
Variables: defaultVars(
|
||||
"myAccountURL",
|
||||
),
|
||||
Placeholders: defaultVals(map[string]any{
|
||||
"myAccountURL": "https://example.url/my/account",
|
||||
}),
|
||||
},
|
||||
"PreSignupCard": {
|
||||
Name: "PreSignupCard",
|
||||
ContentType: CustomCard,
|
||||
DisplayName: func(dict *Lang, lang string) string {
|
||||
if _, ok := dict.Admin[lang]; !ok {
|
||||
lang = dict.chosenAdminLang
|
||||
}
|
||||
return dict.Admin[lang].Strings["preSignupCard"]
|
||||
},
|
||||
Description: func(dict *Lang, lang string) string {
|
||||
if _, ok := dict.Admin[lang]; !ok {
|
||||
lang = dict.chosenAdminLang
|
||||
}
|
||||
return dict.Admin[lang].Strings["preSignupCardDescription"]
|
||||
},
|
||||
Variables: []string{
|
||||
"myAccountURL",
|
||||
"profile",
|
||||
},
|
||||
Placeholders: map[string]any{
|
||||
"myAccountURL": "https://example.url/my/account",
|
||||
"profile": "Default User Profile",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var EmptyCustomContent = CustomContentInfo{
|
||||
Name: "EmptyCustomContent",
|
||||
ContentType: CustomMessage,
|
||||
DisplayName: func(dict *Lang, lang string) string {
|
||||
return "EmptyCustomContent"
|
||||
},
|
||||
Subject: func(config *Config, lang *emailLang) string {
|
||||
return "EmptyCustomContent"
|
||||
},
|
||||
HeaderText: serverHeader,
|
||||
FooterText: messageFooter,
|
||||
Description: nil,
|
||||
Variables: []string{},
|
||||
Placeholders: map[string]any{},
|
||||
}
|
||||
|
||||
var AnnouncementCustomContent = func(subject string) CustomContentInfo {
|
||||
cci := EmptyCustomContent
|
||||
cci.Subject = func(config *Config, lang *emailLang) string { return subject }
|
||||
cci.Variables = defaultVars()
|
||||
cci.Placeholders = defaultVals(map[string]any{})
|
||||
return cci
|
||||
}
|
||||
|
||||
// Validates customContent and sets default fields if needed.
|
||||
var _runtimeValidation = func() bool {
|
||||
for name, cc := range customContent {
|
||||
if name != cc.Name {
|
||||
panic(fmt.Errorf("customContent key and name not matching: %s != %s", name, cc.Name))
|
||||
}
|
||||
if cc.DisplayName == nil {
|
||||
panic(fmt.Errorf("no customContent[%s] DisplayName set", name))
|
||||
}
|
||||
if cc.HeaderText == nil {
|
||||
cc.HeaderText = serverHeader
|
||||
customContent[name] = cc
|
||||
}
|
||||
if cc.FooterText == nil {
|
||||
cc.FooterText = messageFooter
|
||||
customContent[name] = cc
|
||||
}
|
||||
}
|
||||
return true
|
||||
}()
|
||||
168
daemon.go
@@ -1,168 +0,0 @@
|
||||
package main
|
||||
|
||||
import "time"
|
||||
|
||||
// clearEmails removes stored emails for users which no longer exist.
|
||||
// meant to be called with other such housekeeping functions, so assumes
|
||||
// the user cache is fresh.
|
||||
func (app *appContext) clearEmails() {
|
||||
app.debug.Println("Housekeeping: removing unused email addresses")
|
||||
users, status, err := app.jf.GetUsers(false)
|
||||
if status != 200 || err != nil || len(users) == 0 {
|
||||
app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err)
|
||||
return
|
||||
}
|
||||
// Rebuild email storage to from existing users to reduce time complexity
|
||||
emails := emailStore{}
|
||||
app.storage.emailsLock.Lock()
|
||||
for _, user := range users {
|
||||
if email, ok := app.storage.GetEmailsKey(user.ID); ok {
|
||||
emails[user.ID] = email
|
||||
}
|
||||
}
|
||||
app.storage.emails = emails
|
||||
app.storage.storeEmails()
|
||||
app.storage.emailsLock.Unlock()
|
||||
}
|
||||
|
||||
// clearDiscord does the same as clearEmails, but for Discord Users.
|
||||
func (app *appContext) clearDiscord() {
|
||||
app.debug.Println("Housekeeping: removing unused Discord IDs")
|
||||
users, status, err := app.jf.GetUsers(false)
|
||||
if status != 200 || err != nil || len(users) == 0 {
|
||||
app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err)
|
||||
return
|
||||
}
|
||||
// Rebuild discord storage to from existing users to reduce time complexity
|
||||
dcUsers := discordStore{}
|
||||
app.storage.discordLock.Lock()
|
||||
for _, user := range users {
|
||||
if dcUser, ok := app.storage.GetDiscordKey(user.ID); ok {
|
||||
dcUsers[user.ID] = dcUser
|
||||
}
|
||||
}
|
||||
app.storage.discord = dcUsers
|
||||
app.storage.storeDiscordUsers()
|
||||
app.storage.discordLock.Unlock()
|
||||
}
|
||||
|
||||
// clearMatrix does the same as clearEmails, but for Matrix Users.
|
||||
func (app *appContext) clearMatrix() {
|
||||
app.debug.Println("Housekeeping: removing unused Matrix IDs")
|
||||
users, status, err := app.jf.GetUsers(false)
|
||||
if status != 200 || err != nil || len(users) == 0 {
|
||||
app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err)
|
||||
return
|
||||
}
|
||||
// Rebuild matrix storage to from existing users to reduce time complexity
|
||||
mxUsers := matrixStore{}
|
||||
app.storage.matrixLock.Lock()
|
||||
for _, user := range users {
|
||||
if mxUser, ok := app.storage.GetMatrixKey(user.ID); ok {
|
||||
mxUsers[user.ID] = mxUser
|
||||
}
|
||||
}
|
||||
app.storage.matrix = mxUsers
|
||||
app.storage.storeMatrixUsers()
|
||||
app.storage.matrixLock.Unlock()
|
||||
}
|
||||
|
||||
// clearTelegram does the same as clearEmails, but for Telegram Users.
|
||||
func (app *appContext) clearTelegram() {
|
||||
app.debug.Println("Housekeeping: removing unused Telegram IDs")
|
||||
users, status, err := app.jf.GetUsers(false)
|
||||
if status != 200 || err != nil || len(users) == 0 {
|
||||
app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err)
|
||||
return
|
||||
}
|
||||
// Rebuild telegram storage to from existing users to reduce time complexity
|
||||
tgUsers := telegramStore{}
|
||||
app.storage.telegramLock.Lock()
|
||||
for _, user := range users {
|
||||
if tgUser, ok := app.storage.GetTelegramKey(user.ID); ok {
|
||||
tgUsers[user.ID] = tgUser
|
||||
}
|
||||
}
|
||||
app.storage.telegram = tgUsers
|
||||
app.storage.storeTelegramUsers()
|
||||
app.storage.telegramLock.Unlock()
|
||||
}
|
||||
|
||||
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
|
||||
|
||||
type housekeepingDaemon struct {
|
||||
Stopped bool
|
||||
ShutdownChannel chan string
|
||||
Interval time.Duration
|
||||
period time.Duration
|
||||
jobs []func(app *appContext)
|
||||
app *appContext
|
||||
}
|
||||
|
||||
func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemon {
|
||||
daemon := housekeepingDaemon{
|
||||
Stopped: false,
|
||||
ShutdownChannel: make(chan string),
|
||||
Interval: interval,
|
||||
period: interval,
|
||||
app: app,
|
||||
}
|
||||
daemon.jobs = []func(app *appContext){func(app *appContext) {
|
||||
app.debug.Println("Housekeeping: Checking for expired invites")
|
||||
app.checkInvites()
|
||||
}}
|
||||
|
||||
clearEmail := app.config.Section("email").Key("require_unique").MustBool(false)
|
||||
clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false)
|
||||
clearTelegram := app.config.Section("telegram").Key("require_unique").MustBool(false)
|
||||
clearMatrix := app.config.Section("matrix").Key("require_unique").MustBool(false)
|
||||
|
||||
if clearEmail || clearDiscord || clearTelegram || clearMatrix {
|
||||
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.jf.CacheExpiry = time.Now() })
|
||||
}
|
||||
|
||||
if clearEmail {
|
||||
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearEmails() })
|
||||
}
|
||||
if clearDiscord {
|
||||
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearDiscord() })
|
||||
}
|
||||
if clearTelegram {
|
||||
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearTelegram() })
|
||||
}
|
||||
if clearMatrix {
|
||||
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearMatrix() })
|
||||
}
|
||||
|
||||
return &daemon
|
||||
}
|
||||
|
||||
func (rt *housekeepingDaemon) run() {
|
||||
rt.app.info.Println("Invite daemon started")
|
||||
for {
|
||||
select {
|
||||
case <-rt.ShutdownChannel:
|
||||
rt.ShutdownChannel <- "Down"
|
||||
return
|
||||
case <-time.After(rt.period):
|
||||
break
|
||||
}
|
||||
started := time.Now()
|
||||
rt.app.storage.loadInvites()
|
||||
|
||||
for _, job := range rt.jobs {
|
||||
job(rt.app)
|
||||
}
|
||||
|
||||
finished := time.Now()
|
||||
duration := finished.Sub(started)
|
||||
rt.period = rt.Interval - duration
|
||||
}
|
||||
}
|
||||
|
||||
func (rt *housekeepingDaemon) Shutdown() {
|
||||
rt.Stopped = true
|
||||
rt.ShutdownChannel <- "Down"
|
||||
<-rt.ShutdownChannel
|
||||
close(rt.ShutdownChannel)
|
||||
}
|
||||
587
discord.go
@@ -1,28 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dg "github.com/bwmarrin/discordgo"
|
||||
"github.com/hrfee/jfa-go/common"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
type DiscordDaemon struct {
|
||||
Stopped bool
|
||||
ShutdownChannel chan string
|
||||
bot *dg.Session
|
||||
username string
|
||||
tokens map[string]VerifToken // Map of pins to tokens.
|
||||
verifiedTokens map[string]DiscordUser // Map of token pins to discord users.
|
||||
channelID, channelName, inviteChannelID, inviteChannelName string
|
||||
guildID string
|
||||
serverChannelName, serverName string
|
||||
users map[string]DiscordUser // Map of user IDs to users. Added to on first interaction, and loaded from app.storage.discord on start.
|
||||
roleID string
|
||||
app *appContext
|
||||
commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string)
|
||||
commandIDs []string
|
||||
Stopped bool
|
||||
ShutdownChannel chan string
|
||||
bot *dg.Session
|
||||
username string
|
||||
tokens map[string]VerifToken // Map of pins to tokens.
|
||||
verifiedTokens map[string]DiscordUser // Map of token pins to discord users.
|
||||
Channel, InviteChannel struct{ ID, Name string }
|
||||
guildID string
|
||||
serverChannelName, serverName string
|
||||
users map[string]DiscordUser // Map of user IDs to users. Added to on first interaction, and loaded from app.storage.discord on start.
|
||||
roleID string
|
||||
app *appContext
|
||||
commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string)
|
||||
commandIDs []string
|
||||
commandDescriptions []*dg.ApplicationCommand
|
||||
retryOpts *common.MustAuthenticateOptions
|
||||
}
|
||||
|
||||
func EmptyDiscordUser() *DiscordUser {
|
||||
return &DiscordUser{
|
||||
ID: "",
|
||||
Username: "",
|
||||
Discriminator: "",
|
||||
Lang: "",
|
||||
Contact: false,
|
||||
JellyfinID: "",
|
||||
}
|
||||
}
|
||||
|
||||
func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
|
||||
@@ -49,13 +67,29 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
|
||||
dd.commandHandlers[app.config.Section("discord").Key("start_command").MustString("start")] = dd.cmdStart
|
||||
dd.commandHandlers["lang"] = dd.cmdLang
|
||||
dd.commandHandlers["pin"] = dd.cmdPIN
|
||||
dd.commandHandlers["inv"] = dd.cmdInvite
|
||||
for _, user := range app.storage.GetDiscord() {
|
||||
dd.users[user.ID] = user
|
||||
}
|
||||
|
||||
dd.retryOpts = &common.MustAuthenticateOptions{
|
||||
RetryCount: app.config.Section("advanced").Key("auth_retry_count").MustInt(6),
|
||||
RetryGap: time.Duration(app.config.Section("advanced").Key("auth_retry_gap").MustInt(10)) * time.Second,
|
||||
LogFailures: true,
|
||||
}
|
||||
|
||||
dd.bot.AddHandler(dd.commandHandler)
|
||||
|
||||
dd.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers | dg.IntentsGuildInvites
|
||||
|
||||
return dd, nil
|
||||
}
|
||||
|
||||
// SetTransport sets the http.Transport to use for requests. Can be used to set a proxy.
|
||||
func (d *DiscordDaemon) SetTransport(t *http.Transport) {
|
||||
d.bot.Client.Transport = t
|
||||
}
|
||||
|
||||
// NewAuthToken generates an 8-character pin in the form "A1-2B-CD".
|
||||
func (d *DiscordDaemon) NewAuthToken() string {
|
||||
pin := genAuthToken()
|
||||
@@ -88,15 +122,27 @@ func (d *DiscordDaemon) MustGetUser(channelID, userID, discrim, username string)
|
||||
return d.NewUnknownUser(channelID, userID, discrim, username)
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) run() {
|
||||
d.bot.AddHandler(d.messageHandler)
|
||||
func (d *DiscordDaemon) Run() {
|
||||
ro := common.MustAuthenticateOptions{}
|
||||
ro = *d.retryOpts
|
||||
ro.Counter = 0
|
||||
d.run(&ro)
|
||||
}
|
||||
|
||||
d.bot.AddHandler(d.commandHandler)
|
||||
|
||||
d.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers | dg.IntentsGuildInvites
|
||||
func (d *DiscordDaemon) run(retry *common.MustAuthenticateOptions) {
|
||||
if err := d.bot.Open(); err != nil {
|
||||
d.app.err.Printf("Discord: Failed to start daemon: %v", err)
|
||||
return
|
||||
if retry == nil || retry.LogFailures {
|
||||
d.app.err.Printf(lm.FailedStartDaemon, lm.Discord, err)
|
||||
}
|
||||
if retry != nil {
|
||||
retry.Counter += 1
|
||||
if retry.Counter >= retry.RetryCount {
|
||||
return
|
||||
}
|
||||
time.Sleep(retry.RetryGap)
|
||||
d.run(retry)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Wait for everything to populate, it's slow sometimes.
|
||||
for d.bot.State == nil {
|
||||
@@ -113,27 +159,31 @@ func (d *DiscordDaemon) run() {
|
||||
d.guildID = d.bot.State.Guilds[len(d.bot.State.Guilds)-1].ID
|
||||
guild, err := d.bot.Guild(d.guildID)
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to get guild: %v", err)
|
||||
d.app.err.Printf(lm.FailedGetDiscordGuild, err)
|
||||
}
|
||||
d.serverChannelName = guild.Name
|
||||
d.serverName = guild.Name
|
||||
if channel := d.app.config.Section("discord").Key("channel").String(); channel != "" {
|
||||
d.channelName = channel
|
||||
d.Channel.Name = channel
|
||||
d.serverChannelName += "/" + channel
|
||||
}
|
||||
if d.app.config.Section("discord").Key("provide_invite").MustBool(false) {
|
||||
if invChannel := d.app.config.Section("discord").Key("invite_channel").String(); invChannel != "" {
|
||||
d.inviteChannelName = invChannel
|
||||
d.InviteChannel.Name = invChannel
|
||||
}
|
||||
}
|
||||
d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start"))
|
||||
defer d.deregisterCommands()
|
||||
defer d.bot.Close()
|
||||
|
||||
go d.registerCommands()
|
||||
ro := common.MustAuthenticateOptions{}
|
||||
ro = *(d.retryOpts)
|
||||
ro.Counter = 0
|
||||
|
||||
go d.registerCommands(&ro)
|
||||
|
||||
<-d.ShutdownChannel
|
||||
d.ShutdownChannel <- "Down"
|
||||
return
|
||||
}
|
||||
|
||||
// ListRoles returns a list of available (excluding bot and @everyone) roles in a guild as a list of containing an array of the guild ID and its name.
|
||||
@@ -141,7 +191,7 @@ func (d *DiscordDaemon) ListRoles() (roles [][2]string, err error) {
|
||||
var r []*dg.Role
|
||||
r, err = d.bot.GuildRoles(d.guildID)
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to get roles: %v", err)
|
||||
d.app.err.Printf(lm.FailedGetDiscordRoles, err)
|
||||
return
|
||||
}
|
||||
for _, role := range r {
|
||||
@@ -164,44 +214,62 @@ func (d *DiscordDaemon) ApplyRole(userID string) error {
|
||||
return d.bot.GuildMemberRoleAdd(d.guildID, userID, d.roleID)
|
||||
}
|
||||
|
||||
// RemoveRole removes the member role to the given user if set.
|
||||
func (d *DiscordDaemon) RemoveRole(userID string) error {
|
||||
if d.roleID == "" {
|
||||
return nil
|
||||
}
|
||||
return d.bot.GuildMemberRoleRemove(d.guildID, userID, d.roleID)
|
||||
}
|
||||
|
||||
// SetRoleDisabled removes the role if "disabled", and applies if "!disabled".
|
||||
func (d *DiscordDaemon) SetRoleDisabled(userID string, disabled bool) (err error) {
|
||||
if disabled {
|
||||
err = d.RemoveRole(userID)
|
||||
} else {
|
||||
err = d.ApplyRole(userID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// NewTempInvite creates an invite link, and returns the invite URL, as well as the URL for the server icon.
|
||||
func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconURL string) {
|
||||
var inv *dg.Invite
|
||||
var err error
|
||||
if d.inviteChannelName == "" {
|
||||
d.app.err.Println("Discord: Cannot create invite without channel specified in settings.")
|
||||
if d.InviteChannel.Name == "" {
|
||||
d.app.err.Printf(lm.FailedCreateDiscordInviteChannel, lm.InviteChannelEmpty)
|
||||
return
|
||||
}
|
||||
if d.inviteChannelID == "" {
|
||||
if d.InviteChannel.ID == "" {
|
||||
channels, err := d.bot.GuildChannels(d.guildID)
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Couldn't get channel list: %v", err)
|
||||
d.app.err.Printf(lm.FailedGetDiscordChannels, err)
|
||||
return
|
||||
}
|
||||
found := false
|
||||
for _, channel := range channels {
|
||||
// channel, err := d.bot.Channel(ch.ID)
|
||||
// if err != nil {
|
||||
// d.app.err.Printf("Discord: Couldn't get channel: %v", err)
|
||||
// d.app.err.Printf(lm.FailedGetDiscordChannel, ch.ID, err)
|
||||
// return
|
||||
// }
|
||||
if channel.Name == d.inviteChannelName {
|
||||
d.inviteChannelID = channel.ID
|
||||
if channel.Name == d.InviteChannel.Name {
|
||||
d.InviteChannel.ID = channel.ID
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
d.app.err.Printf("Discord: Couldn't find invite channel \"%s\"", d.inviteChannelName)
|
||||
d.app.err.Printf(lm.FailedGetDiscordChannel, d.InviteChannel.Name, lm.NotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
// channel, err := d.bot.Channel(d.inviteChannelID)
|
||||
// if err != nil {
|
||||
// d.app.err.Printf("Discord: Couldn't get invite channel: %v", err)
|
||||
// d.app.err.Printf(lm.FailedGetDiscordChannel, d.inviteChannelID, err)
|
||||
// return
|
||||
// }
|
||||
inv, err = d.bot.ChannelInviteCreate(d.inviteChannelID, dg.Invite{
|
||||
inv, err = d.bot.ChannelInviteCreate(d.InviteChannel.ID, dg.Invite{
|
||||
// Guild: d.bot.State.Guilds[len(d.bot.State.Guilds)-1],
|
||||
// Channel: channel,
|
||||
// Inviter: d.bot.State.User,
|
||||
@@ -210,18 +278,16 @@ func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconU
|
||||
Temporary: false,
|
||||
})
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to create invite: %v", err)
|
||||
d.app.err.Printf(lm.FailedGenerateDiscordInvite, err)
|
||||
return
|
||||
}
|
||||
inviteURL = "https://discord.gg/" + inv.Code
|
||||
guild, err := d.bot.Guild(d.guildID)
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to get guild: %v", err)
|
||||
d.app.err.Printf(lm.FailedGetDiscordGuild, err)
|
||||
return
|
||||
}
|
||||
// FIXME: Fix CSS, and handle no icon
|
||||
iconURL = guild.IconURL("256")
|
||||
fmt.Println("GOT ICON", iconURL)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -253,7 +319,7 @@ func (d *DiscordDaemon) GetUsers(username string) []*dg.Member {
|
||||
1000,
|
||||
)
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to get members: %v", err)
|
||||
d.app.err.Printf(lm.FailedGetDiscordGuildMembers, err)
|
||||
return nil
|
||||
}
|
||||
hasDiscriminator := strings.Contains(username, "#")
|
||||
@@ -283,7 +349,7 @@ func (d *DiscordDaemon) GetUsers(username string) []*dg.Member {
|
||||
func (d *DiscordDaemon) NewUser(ID string) (user DiscordUser, ok bool) {
|
||||
u, err := d.bot.User(ID)
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to get user: %v", err)
|
||||
d.app.err.Printf(lm.FailedGetUser, ID, lm.Discord, err)
|
||||
return
|
||||
}
|
||||
user.ID = ID
|
||||
@@ -292,7 +358,7 @@ func (d *DiscordDaemon) NewUser(ID string) (user DiscordUser, ok bool) {
|
||||
user.Discriminator = u.Discriminator
|
||||
channel, err := d.bot.UserChannelCreate(ID)
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to create DM channel: %v", err)
|
||||
d.app.err.Printf(lm.FailedCreateDiscordDMChannel, ID, err)
|
||||
return
|
||||
}
|
||||
user.ChannelID = channel.ID
|
||||
@@ -307,8 +373,8 @@ func (d *DiscordDaemon) Shutdown() {
|
||||
close(d.ShutdownChannel)
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) registerCommands() {
|
||||
commands := []*dg.ApplicationCommand{
|
||||
func (d *DiscordDaemon) registerCommands(retry *common.MustAuthenticateOptions) {
|
||||
d.commandDescriptions = []*dg.ApplicationCommand{
|
||||
{
|
||||
Name: d.app.config.Section("discord").Key("start_command").MustString("start"),
|
||||
Description: "Start the Discord linking process. The bot will send further instructions.",
|
||||
@@ -338,63 +404,152 @@ func (d *DiscordDaemon) registerCommands() {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "inv",
|
||||
Description: "Send an invite to a discord user (admin only).",
|
||||
Options: []*dg.ApplicationCommandOption{
|
||||
{
|
||||
Type: dg.ApplicationCommandOptionUser,
|
||||
Name: "user",
|
||||
Description: "User to Invite.",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: dg.ApplicationCommandOptionInteger,
|
||||
Name: "expiry",
|
||||
Description: "Time in minutes before expiration.",
|
||||
Required: false,
|
||||
},
|
||||
/* Label should be automatically set to something like "Discord invite for @username"
|
||||
{
|
||||
Type: dg.ApplicationCommandOptionString,
|
||||
Name: "label",
|
||||
Description: "Label given to this invite (shown on the Admin page)",
|
||||
Required: false,
|
||||
}, */
|
||||
{
|
||||
Type: dg.ApplicationCommandOptionString,
|
||||
Name: "user_label",
|
||||
Description: "Label given to users created with this invite.",
|
||||
Required: false,
|
||||
},
|
||||
{
|
||||
Type: dg.ApplicationCommandOptionString,
|
||||
Name: "profile",
|
||||
Description: "Profile to apply to the created user.",
|
||||
Required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
commands[1].Options[0].Choices = make([]*dg.ApplicationCommandOptionChoice, len(d.app.storage.lang.Telegram))
|
||||
d.commandDescriptions[1].Options[0].Choices = make([]*dg.ApplicationCommandOptionChoice, len(d.app.storage.lang.Telegram))
|
||||
i := 0
|
||||
for code := range d.app.storage.lang.Telegram {
|
||||
commands[1].Options[0].Choices[i] = &dg.ApplicationCommandOptionChoice{
|
||||
d.app.debug.Printf(lm.RegisterDiscordChoice, lm.Lang, d.app.storage.lang.Telegram[code].Meta.Name+":"+code)
|
||||
d.commandDescriptions[1].Options[0].Choices[i] = &dg.ApplicationCommandOptionChoice{
|
||||
Name: d.app.storage.lang.Telegram[code].Meta.Name,
|
||||
Value: code,
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
profiles := d.app.storage.GetProfiles()
|
||||
d.commandDescriptions[3].Options[3].Choices = make([]*dg.ApplicationCommandOptionChoice, len(profiles))
|
||||
for i, profile := range profiles {
|
||||
d.app.debug.Printf(lm.RegisterDiscordChoice, lm.Profile, profile.Name)
|
||||
d.commandDescriptions[3].Options[3].Choices[i] = &dg.ApplicationCommandOptionChoice{
|
||||
Name: profile.Name,
|
||||
Value: profile.Name,
|
||||
}
|
||||
}
|
||||
|
||||
// d.deregisterCommands()
|
||||
|
||||
d.commandIDs = make([]string, len(commands))
|
||||
d.commandIDs = make([]string, len(d.commandDescriptions))
|
||||
// cCommands, err := d.bot.ApplicationCommandBulkOverwrite(d.bot.State.User.ID, d.guildID, commands)
|
||||
// if err != nil {
|
||||
// d.app.err.Printf("Discord: Cannot create commands: %v", err)
|
||||
// }
|
||||
for i, cmd := range commands {
|
||||
|
||||
cCommands, err := d.bot.ApplicationCommandBulkOverwrite(d.bot.State.User.ID, d.guildID, d.commandDescriptions)
|
||||
if err != nil {
|
||||
if retry == nil || retry.LogFailures {
|
||||
d.app.err.Printf(lm.FailedRegisterDiscordCommand, "*", err)
|
||||
}
|
||||
if retry != nil {
|
||||
retry.Counter += 1
|
||||
if retry.Counter >= retry.RetryCount {
|
||||
return
|
||||
}
|
||||
time.Sleep(retry.RetryGap)
|
||||
d.registerCommands(retry)
|
||||
}
|
||||
} else {
|
||||
for i := range len(d.commandDescriptions) {
|
||||
d.commandIDs[i] = cCommands[i].ID
|
||||
}
|
||||
d.app.debug.Printf(lm.RegisterDiscordCommand, "*")
|
||||
}
|
||||
/* for i, cmd := range d.commandDescriptions {
|
||||
command, err := d.bot.ApplicationCommandCreate(d.bot.State.User.ID, d.guildID, cmd)
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Cannot create command \"%s\": %v", cmd.Name, err)
|
||||
d.app.err.Printf(lm.FailedRegisterDiscordCommand, cmd.Name, err)
|
||||
} else {
|
||||
d.app.debug.Printf("Discord: registered command \"%s\"", cmd.Name)
|
||||
d.app.debug.Printf(lm.RegisterDiscordCommand, cmd.Name)
|
||||
d.commandIDs[i] = command.ID
|
||||
}
|
||||
}
|
||||
} */
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) deregisterCommands() {
|
||||
existingCommands, err := d.bot.ApplicationCommands(d.bot.State.User.ID, d.guildID)
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to get commands: %v", err)
|
||||
d.app.err.Printf(lm.FailedGetDiscordCommands, err)
|
||||
return
|
||||
}
|
||||
for _, cmd := range existingCommands {
|
||||
if err := d.bot.ApplicationCommandDelete(d.bot.State.User.ID, "", cmd.ID); err != nil {
|
||||
d.app.err.Printf("Failed to deregister command: %v", err)
|
||||
if err := d.bot.ApplicationCommandDelete(d.bot.State.User.ID, d.guildID, cmd.ID); err != nil {
|
||||
d.app.err.Printf(lm.FailedDeregDiscordCommand, cmd.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateCommands updates commands which have defined lists of options, to be used when changes occur.
|
||||
func (d *DiscordDaemon) UpdateCommands() {
|
||||
// Reload Profile List
|
||||
profiles := d.app.storage.GetProfiles()
|
||||
d.commandDescriptions[3].Options[3].Choices = make([]*dg.ApplicationCommandOptionChoice, len(profiles))
|
||||
for i, profile := range profiles {
|
||||
d.app.debug.Printf(lm.RegisterDiscordChoice, lm.Profile, profile.Name)
|
||||
d.commandDescriptions[3].Options[3].Choices[i] = &dg.ApplicationCommandOptionChoice{
|
||||
Name: profile.Name,
|
||||
Value: profile.Name,
|
||||
}
|
||||
}
|
||||
cmd, err := d.bot.ApplicationCommandEdit(d.bot.State.User.ID, d.guildID, d.commandIDs[3], d.commandDescriptions[3])
|
||||
if err != nil {
|
||||
d.app.err.Printf(lm.FailedRegisterDiscordChoices, lm.Profile, err)
|
||||
} else {
|
||||
d.commandIDs[3] = cmd.ID
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) commandHandler(s *dg.Session, i *dg.InteractionCreate) {
|
||||
if h, ok := d.commandHandlers[i.ApplicationCommandData().Name]; ok {
|
||||
if i.GuildID != "" && d.channelName != "" {
|
||||
if d.channelID == "" {
|
||||
if i.GuildID != "" && d.Channel.Name != "" {
|
||||
if d.Channel.ID == "" {
|
||||
channel, err := s.Channel(i.ChannelID)
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Couldn't get channel, will monitor all: %v", err)
|
||||
d.channelName = ""
|
||||
d.app.err.Printf(lm.FailedGetDiscordChannel, i.ChannelID, err)
|
||||
d.app.err.Println(lm.MonitorAllDiscordChannels)
|
||||
d.Channel.Name = ""
|
||||
}
|
||||
if channel.Name == d.channelName {
|
||||
d.channelID = channel.ID
|
||||
if channel.Name == d.Channel.Name {
|
||||
d.Channel.ID = channel.ID
|
||||
}
|
||||
}
|
||||
if d.channelID != i.ChannelID {
|
||||
d.app.debug.Printf("Discord: Ignoring message as not in specified channel")
|
||||
if d.Channel.ID != i.ChannelID {
|
||||
d.app.debug.Printf(lm.IgnoreOutOfChannelMessage, lm.Discord)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -416,7 +571,7 @@ func (d *DiscordDaemon) commandHandler(s *dg.Session, i *dg.InteractionCreate) {
|
||||
func (d *DiscordDaemon) cmdStart(s *dg.Session, i *dg.InteractionCreate, lang string) {
|
||||
channel, err := s.UserChannelCreate(i.Interaction.Member.User.ID)
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to create private channel with \"%s\": %v", i.Interaction.Member.User.Username, err)
|
||||
d.app.err.Printf(lm.FailedCreateDiscordDMChannel, i.Interaction.Member.User.ID, err)
|
||||
return
|
||||
}
|
||||
user := d.MustGetUser(channel.ID, i.Interaction.Member.User.ID, i.Interaction.Member.User.Discriminator, i.Interaction.Member.User.Username)
|
||||
@@ -433,7 +588,7 @@ func (d *DiscordDaemon) cmdStart(s *dg.Session, i *dg.InteractionCreate, lang st
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to send reply: %v", err)
|
||||
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -451,7 +606,7 @@ func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang stri
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", i.Interaction.Member.User.Username, err)
|
||||
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
|
||||
}
|
||||
delete(d.tokens, pin)
|
||||
return
|
||||
@@ -465,7 +620,7 @@ func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang stri
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", i.Interaction.Member.User.Username, err)
|
||||
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
|
||||
}
|
||||
dcUser := d.users[i.Interaction.Member.User.ID]
|
||||
dcUser.JellyfinID = user.JellyfinID
|
||||
@@ -477,11 +632,11 @@ func (d *DiscordDaemon) cmdLang(s *dg.Session, i *dg.InteractionCreate, lang str
|
||||
code := i.ApplicationCommandData().Options[0].StringValue()
|
||||
if _, ok := d.app.storage.lang.Telegram[code]; ok {
|
||||
var user DiscordUser
|
||||
for jfID, u := range d.app.storage.GetDiscord() {
|
||||
for _, u := range d.app.storage.GetDiscord() {
|
||||
if u.ID == i.Interaction.Member.User.ID {
|
||||
u.Lang = code
|
||||
lang = code
|
||||
d.app.storage.SetDiscordKey(jfID, u)
|
||||
d.app.storage.SetDiscordKey(u.JellyfinID, u)
|
||||
user = u
|
||||
break
|
||||
}
|
||||
@@ -496,144 +651,129 @@ func (d *DiscordDaemon) cmdLang(s *dg.Session, i *dg.InteractionCreate, lang str
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to send reply: %v", err)
|
||||
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) messageHandler(s *dg.Session, m *dg.MessageCreate) {
|
||||
if m.GuildID != "" && d.channelName != "" {
|
||||
if d.channelID == "" {
|
||||
channel, err := s.Channel(m.ChannelID)
|
||||
func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang string) {
|
||||
channel, err := s.UserChannelCreate(i.Interaction.Member.User.ID)
|
||||
if err != nil {
|
||||
d.app.err.Printf(lm.FailedCreateDiscordDMChannel, i.Interaction.Member.User.ID, err)
|
||||
return
|
||||
}
|
||||
requester := d.MustGetUser(channel.ID, i.Interaction.Member.User.ID, i.Interaction.Member.User.Discriminator, i.Interaction.Member.User.Username)
|
||||
d.users[i.Interaction.Member.User.ID] = requester
|
||||
recipient := i.ApplicationCommandData().Options[0].UserValue(s)
|
||||
|
||||
// We don't reveal much in the message response itself so we can re-use this easily.
|
||||
sendResponse := func(langKey string) {
|
||||
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
|
||||
Type: dg.InteractionResponseChannelMessageWithSource,
|
||||
Data: &dg.InteractionResponseData{
|
||||
Content: d.app.storage.lang.Telegram[lang].Strings.get(langKey),
|
||||
Flags: 64, // Ephemeral
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// d.app.debug.Println(invuser)
|
||||
//label := i.ApplicationCommandData().Options[2].StringValue()
|
||||
//profile := i.ApplicationCommandData().Options[3].StringValue()
|
||||
//mins, err := strconv.Atoi(i.ApplicationCommandData().Options[1].StringValue())
|
||||
//if mins > 0 {
|
||||
// expmin = mins
|
||||
//}
|
||||
// We want the same criteria for running this command as accessing the admin page (i.e. an "admin" of some sort)
|
||||
if !(d.app.canAccessAdminPageByID(requester.JellyfinID)) {
|
||||
d.app.err.Printf(lm.FailedGenerateInvite, fmt.Sprintf(lm.NonAdminUser, requester.JellyfinID))
|
||||
sendResponse("noPermission")
|
||||
return
|
||||
}
|
||||
|
||||
var expiryMinutes int64 = 30
|
||||
userLabel := ""
|
||||
profileName := ""
|
||||
|
||||
for i, opt := range i.ApplicationCommandData().Options {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
switch opt.Name {
|
||||
case "expiry":
|
||||
expiryMinutes = opt.IntValue()
|
||||
case "user_label":
|
||||
userLabel = opt.StringValue()
|
||||
case "profile":
|
||||
profileName = opt.StringValue()
|
||||
}
|
||||
}
|
||||
|
||||
currentTime := time.Now()
|
||||
|
||||
validTill := currentTime.Add(time.Minute * time.Duration(expiryMinutes))
|
||||
|
||||
invite := Invite{
|
||||
Code: GenerateInviteCode(),
|
||||
Created: currentTime,
|
||||
RemainingUses: 1,
|
||||
UserExpiry: false,
|
||||
ValidTill: validTill,
|
||||
UserLabel: userLabel,
|
||||
Profile: "Default",
|
||||
Label: fmt.Sprintf("%s: %s", lm.Discord, RenderDiscordUsername(recipient)),
|
||||
}
|
||||
if profileName != "" {
|
||||
if _, ok := d.app.storage.GetProfileKey(profileName); ok {
|
||||
invite.Profile = profileName
|
||||
}
|
||||
}
|
||||
|
||||
if recipient != nil {
|
||||
err = nil
|
||||
|
||||
var invname *dg.Member = nil
|
||||
invname, err = d.bot.GuildMember(d.guildID, recipient.ID)
|
||||
|
||||
if err == nil && !(d.app.config.Section("invite_emails").Key("enabled").MustBool(false)) {
|
||||
err = errors.New(lm.InviteMessagesDisabled)
|
||||
}
|
||||
|
||||
var msg *Message
|
||||
if err == nil {
|
||||
msg, err = d.app.email.constructInvite(&invite, false)
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Couldn't get channel, will monitor all: %v", err)
|
||||
d.channelName = ""
|
||||
}
|
||||
if channel.Name == d.channelName {
|
||||
d.channelID = channel.ID
|
||||
// Print extra message, ideally we'd just print this, or get rid of it though.
|
||||
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
|
||||
Address: invname.User.Username,
|
||||
Reason: CheckLogs,
|
||||
})
|
||||
d.app.err.Printf(lm.FailedConstructInviteMessage, invite.Code, err)
|
||||
}
|
||||
}
|
||||
if d.channelID != m.ChannelID {
|
||||
d.app.debug.Printf("Discord: Ignoring message as not in specified channel")
|
||||
return
|
||||
|
||||
if err == nil {
|
||||
err = d.app.discord.SendDM(msg, recipient.ID)
|
||||
}
|
||||
}
|
||||
if m.Author.ID == s.State.User.ID {
|
||||
return
|
||||
}
|
||||
sects := strings.Split(m.Content, " ")
|
||||
if len(sects) == 0 {
|
||||
return
|
||||
}
|
||||
lang := d.app.storage.lang.chosenTelegramLang
|
||||
if user, ok := d.users[m.Author.ID]; ok {
|
||||
if _, ok := d.app.storage.lang.Telegram[user.Lang]; ok {
|
||||
lang = user.Lang
|
||||
|
||||
if err == nil {
|
||||
d.app.info.Printf(lm.SentInviteMessage, invite.Code, RenderDiscordUsername(recipient))
|
||||
invite.SentTo.Success = append(invite.SentTo.Success, invname.User.Username)
|
||||
sendResponse("sentInvite")
|
||||
}
|
||||
}
|
||||
switch msg := sects[0]; msg {
|
||||
case "!" + d.app.config.Section("discord").Key("start_command").MustString("start"):
|
||||
d.msgStart(s, m, lang)
|
||||
case "!lang":
|
||||
d.msgLang(s, m, sects, lang)
|
||||
default:
|
||||
d.msgPIN(s, m, sects, lang)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) msgStart(s *dg.Session, m *dg.MessageCreate, lang string) {
|
||||
channel, err := s.UserChannelCreate(m.Author.ID)
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to create private channel with \"%s\": %v", m.Author.Username, err)
|
||||
return
|
||||
}
|
||||
user := d.MustGetUser(channel.ID, m.Author.ID, m.Author.Discriminator, m.Author.Username)
|
||||
d.users[m.Author.ID] = user
|
||||
|
||||
_, err = d.bot.ChannelMessageSendReply(m.ChannelID, d.app.storage.lang.Telegram[lang].Strings.get("discordDMs"), m.Reference())
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to send reply to \"%s\": %v", m.Author.Username, err)
|
||||
return
|
||||
}
|
||||
|
||||
content := d.app.storage.lang.Telegram[lang].Strings.get("startMessage") + "\n"
|
||||
content += d.app.storage.lang.Telegram[lang].Strings.template("languageMessage", tmpl{"command": "!lang"})
|
||||
_, err = s.ChannelMessageSend(channel.ID, content)
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) msgLang(s *dg.Session, m *dg.MessageCreate, sects []string, lang string) {
|
||||
if len(sects) == 1 {
|
||||
list := "!lang <lang>\n"
|
||||
for code := range d.app.storage.lang.Telegram {
|
||||
list += fmt.Sprintf("%s: %s\n", code, d.app.storage.lang.Telegram[code].Meta.Name)
|
||||
}
|
||||
_, err := s.ChannelMessageSendReply(
|
||||
m.ChannelID,
|
||||
list,
|
||||
m.Reference(),
|
||||
)
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
|
||||
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, RenderDiscordUsername(recipient), err)
|
||||
sendResponse("sentInviteFailure")
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, ok := d.app.storage.lang.Telegram[sects[1]]; ok {
|
||||
var user DiscordUser
|
||||
for jfID, u := range d.app.storage.GetDiscord() {
|
||||
if u.ID == m.Author.ID {
|
||||
u.Lang = sects[1]
|
||||
d.app.storage.SetDiscordKey(jfID, u)
|
||||
user = u
|
||||
break
|
||||
}
|
||||
}
|
||||
d.users[m.Author.ID] = user
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) msgPIN(s *dg.Session, m *dg.MessageCreate, sects []string, lang string) {
|
||||
if _, ok := d.users[m.Author.ID]; ok {
|
||||
channel, err := s.Channel(m.ChannelID)
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to get channel: %v", err)
|
||||
return
|
||||
}
|
||||
if channel.Type != dg.ChannelTypeDM {
|
||||
d.app.debug.Println("Discord: Ignoring message as not a DM")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
d.app.debug.Println("Discord: Ignoring message as user was not found")
|
||||
return
|
||||
}
|
||||
user, ok := d.tokens[sects[0]]
|
||||
if !ok || time.Now().After(user.Expiry) {
|
||||
_, err := s.ChannelMessageSend(
|
||||
m.ChannelID,
|
||||
d.app.storage.lang.Telegram[lang].Strings.get("invalidPIN"),
|
||||
)
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
|
||||
}
|
||||
delete(d.tokens, sects[0])
|
||||
return
|
||||
}
|
||||
_, err := s.ChannelMessageSend(
|
||||
m.ChannelID,
|
||||
d.app.storage.lang.Telegram[lang].Strings.get("pinSuccess"),
|
||||
)
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
|
||||
}
|
||||
dcUser := d.users[m.Author.ID]
|
||||
dcUser.JellyfinID = user.JellyfinID
|
||||
d.verifiedTokens[sects[0]] = dcUser
|
||||
delete(d.tokens, sects[0])
|
||||
//if profile != "" {
|
||||
d.app.storage.SetInvitesKey(invite.Code, invite)
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) SendDM(message *Message, userID ...string) error {
|
||||
@@ -689,10 +829,10 @@ func (d *DiscordDaemon) Send(message *Message, channelID ...string) error {
|
||||
}
|
||||
|
||||
// UserVerified returns whether or not a token with the given PIN has been verified, and the user itself.
|
||||
func (d *DiscordDaemon) UserVerified(pin string) (user DiscordUser, ok bool) {
|
||||
user, ok = d.verifiedTokens[pin]
|
||||
func (d *DiscordDaemon) UserVerified(pin string) (ContactMethodUser, bool) {
|
||||
u, ok := d.verifiedTokens[pin]
|
||||
// delete(d.verifiedTokens, pin)
|
||||
return
|
||||
return &u, ok
|
||||
}
|
||||
|
||||
// AssignedUserVerified returns whether or not a user with the given PIN has been verified, and the token itself.
|
||||
@@ -707,18 +847,49 @@ func (d *DiscordDaemon) AssignedUserVerified(pin string, jfID string) (user Disc
|
||||
}
|
||||
|
||||
// UserExists returns whether or not a user with the given ID exists.
|
||||
func (d *DiscordDaemon) UserExists(id string) (ok bool) {
|
||||
ok = false
|
||||
for _, u := range d.app.storage.GetDiscord() {
|
||||
if u.ID == id {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return
|
||||
func (d *DiscordDaemon) UserExists(id string) bool {
|
||||
c, err := d.app.storage.db.Count(&DiscordUser{}, badgerhold.Where("ID").Eq(id))
|
||||
return err != nil || c > 0
|
||||
}
|
||||
|
||||
// DeleteVerifiedUser removes the token with the given PIN.
|
||||
func (d *DiscordDaemon) DeleteVerifiedUser(pin string) {
|
||||
delete(d.verifiedTokens, pin)
|
||||
// Exists returns whether or not the given user exists.
|
||||
func (d *DiscordDaemon) Exists(user ContactMethodUser) bool {
|
||||
return d.UserExists(user.MethodID().(string))
|
||||
}
|
||||
|
||||
// DeleteVerifiedToken removes the token with the given PIN.
|
||||
func (d *DiscordDaemon) DeleteVerifiedToken(PIN string) {
|
||||
delete(d.verifiedTokens, PIN)
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) PIN(req newUserDTO) string { return req.DiscordPIN }
|
||||
|
||||
func (d *DiscordDaemon) Name() string { return lm.Discord }
|
||||
|
||||
func (d *DiscordDaemon) Required() bool {
|
||||
return d.app.config.Section("discord").Key("required").MustBool(false)
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) UniqueRequired() bool {
|
||||
return d.app.config.Section("discord").Key("require_unique").MustBool(false)
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) PostVerificationTasks(PIN string, u ContactMethodUser) error {
|
||||
err := d.ApplyRole(u.MethodID().(string))
|
||||
if err != nil {
|
||||
return fmt.Errorf(lm.FailedSetDiscordMemberRole, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DiscordUser) Name() string { return RenderDiscordUsername(*d) }
|
||||
func (d *DiscordUser) SetMethodID(id any) { d.ID = id.(string) }
|
||||
func (d *DiscordUser) MethodID() any { return d.ID }
|
||||
func (d *DiscordUser) SetJellyfin(id string) { d.JellyfinID = id }
|
||||
func (d *DiscordUser) Jellyfin() string { return d.JellyfinID }
|
||||
func (d *DiscordUser) SetAllowContactFromDTO(req newUserDTO) { d.Contact = req.DiscordContact }
|
||||
func (d *DiscordUser) SetAllowContact(contact bool) { d.Contact = contact }
|
||||
func (d *DiscordUser) AllowContact() bool { return d.Contact }
|
||||
func (d *DiscordUser) Store(st *Storage) {
|
||||
st.SetDiscordKey(d.Jellyfin(), *d)
|
||||
}
|
||||
|
||||
83
easyproxy/easyproxy.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Package easyproxy provides a method to quickly create a http.Transport or net.Conn using given proxy details (SOCKS5 or HTTP).
|
||||
package easyproxy
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/magisterquis/connectproxy"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
type Protocol int
|
||||
|
||||
const (
|
||||
SOCKS5 Protocol = iota // SOCKS5
|
||||
HTTP // HTTP
|
||||
)
|
||||
|
||||
type ProxyConfig struct {
|
||||
Protocol Protocol
|
||||
Addr string
|
||||
User string
|
||||
Password string
|
||||
}
|
||||
|
||||
// NewTransport returns a http.Transport using the given proxy details. Leave user/pass blank if not needed.
|
||||
func NewTransport(c ProxyConfig) (*http.Transport, error) {
|
||||
t := &http.Transport{}
|
||||
if c.Protocol == HTTP {
|
||||
u := &url.URL{
|
||||
Scheme: "http",
|
||||
Host: c.Addr,
|
||||
}
|
||||
if c.User != "" && c.Password != "" {
|
||||
u.User = url.UserPassword(c.User, c.Password)
|
||||
}
|
||||
t.Proxy = http.ProxyURL(u)
|
||||
return t, nil
|
||||
}
|
||||
var auth *proxy.Auth = nil
|
||||
if c.User != "" && c.Password != "" {
|
||||
auth = &proxy.Auth{User: c.User, Password: c.Password}
|
||||
}
|
||||
dialer, err := proxy.SOCKS5("tcp", c.Addr, auth, proxy.Direct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Dial = dialer.Dial
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// NewConn returns a tls.Conn to "addr" using the given proxy details. Leave user/pass blank if not needed.
|
||||
func NewConn(c ProxyConfig, addr string, tlsConf *tls.Config) (*tls.Conn, error) {
|
||||
var proxyDialer proxy.Dialer
|
||||
var err error
|
||||
if c.Protocol == SOCKS5 {
|
||||
var auth *proxy.Auth = nil
|
||||
if c.User != "" && c.Password != "" {
|
||||
auth = &proxy.Auth{User: c.User, Password: c.Password}
|
||||
}
|
||||
proxyDialer, err = proxy.SOCKS5("tcp", c.Addr, auth, proxy.Direct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
u := &url.URL{
|
||||
Scheme: "http",
|
||||
Host: c.Addr,
|
||||
}
|
||||
if c.User != "" && c.Password != "" {
|
||||
u.User = url.UserPassword(c.User, c.Password)
|
||||
}
|
||||
proxyDialer, err = connectproxy.New(u, proxy.Direct)
|
||||
}
|
||||
|
||||
dialer, err := proxyDialer.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn := tls.Client(dialer, tlsConf)
|
||||
return conn, nil
|
||||
}
|
||||
7
easyproxy/go.mod
Normal file
@@ -0,0 +1,7 @@
|
||||
module github.com/hrfee/jfa-go/easyproxy
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require golang.org/x/net v0.47.0
|
||||
|
||||
require github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b
|
||||
4
easyproxy/go.sum
Normal file
@@ -0,0 +1,4 @@
|
||||
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b h1:xZ59n7Frzh8CwyfAapUZLSg+gXH5m63YEaFCMpDHhpI=
|
||||
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b/go.mod h1:uDd4sYVYsqcxAB8j+Q7uhL6IJCs/r1kxib1HV4bgOMg=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
491
email_test.go
Normal file
@@ -0,0 +1,491 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/hrfee/jfa-go/logger"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
var db *badgerhold.Store
|
||||
|
||||
func dbClose(e *Emailer) {
|
||||
e.storage.db.Close()
|
||||
e.storage.db = nil
|
||||
db = nil
|
||||
}
|
||||
|
||||
func Fatal(err any) {
|
||||
fmt.Printf("Fatal log function called: %+v\n", err)
|
||||
}
|
||||
|
||||
// NewTestEmailer initialises most of what the emailer depends on, which happens to be most of the app.
|
||||
func NewTestEmailer() (*Emailer, error) {
|
||||
emailer := &Emailer{
|
||||
fromAddr: "from@addr",
|
||||
fromName: "fromName",
|
||||
LoggerSet: LoggerSet{
|
||||
info: logger.NewLogger(os.Stdout, "[TEST INFO] ", log.Ltime, color.FgHiWhite),
|
||||
err: logger.NewLogger(os.Stdout, "[TEST ERROR] ", log.Ltime|log.Lshortfile, color.FgRed),
|
||||
debug: logger.NewLogger(os.Stdout, "[TEST DEBUG] ", log.Ltime|log.Lshortfile, color.FgYellow),
|
||||
},
|
||||
sender: &DummyClient{},
|
||||
}
|
||||
// Assume our working directory is the root of the repo
|
||||
wd, _ := os.Getwd()
|
||||
loadFilesystems(filepath.Join(wd, "build"), logger.NewEmptyLogger())
|
||||
dConfig, err := fs.ReadFile(localFS, "config-default.ini")
|
||||
if err != nil {
|
||||
return emailer, err
|
||||
}
|
||||
|
||||
// Force emailer to construct markdown
|
||||
discordEnabled = true
|
||||
noInfoLS := emailer.LoggerSet
|
||||
noInfoLS.info = logger.NewEmptyLogger()
|
||||
emailer.config, err = NewConfig(dConfig, "/tmp/jfa-go-test", noInfoLS)
|
||||
if err != nil {
|
||||
return emailer, err
|
||||
}
|
||||
emailer.storage = NewStorage("/tmp/db", emailer.debug, func(k string) DebugLogAction { return LogAll })
|
||||
emailer.storage.loadLang(langFS)
|
||||
|
||||
emailer.storage.lang.chosenAdminLang = emailer.config.Section("ui").Key("language-admin").MustString("en-us")
|
||||
emailer.storage.lang.chosenEmailLang = emailer.config.Section("email").Key("language").MustString("en-us")
|
||||
emailer.storage.lang.chosenPWRLang = emailer.config.Section("password_resets").Key("language").MustString("en-us")
|
||||
emailer.storage.lang.chosenTelegramLang = emailer.config.Section("telegram").Key("language").MustString("en-us")
|
||||
|
||||
opts := badgerhold.DefaultOptions
|
||||
opts.Dir = "/tmp/jfa-go-test-db"
|
||||
opts.ValueDir = opts.Dir
|
||||
opts.SyncWrites = false
|
||||
opts.Logger = nil
|
||||
emailer.storage.db, err = badgerhold.Open(opts)
|
||||
// emailer.info.Printf("DB Opened")
|
||||
db = emailer.storage.db
|
||||
if err != nil {
|
||||
return emailer, err
|
||||
}
|
||||
|
||||
emailer.lang = emailer.storage.lang.Email[emailer.storage.lang.chosenEmailLang]
|
||||
emailer.info.SetFatalFunc(Fatal)
|
||||
emailer.err.SetFatalFunc(Fatal)
|
||||
return emailer, err
|
||||
}
|
||||
|
||||
func testDummyEmailerInit(t *testing.T) *Emailer {
|
||||
e, err := NewTestEmailer()
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func TestDummyEmailerInit(t *testing.T) {
|
||||
dbClose(testDummyEmailerInit(t))
|
||||
}
|
||||
|
||||
func testContent(e *Emailer, cci CustomContentInfo, t *testing.T, testFunc func(t *testing.T)) {
|
||||
e.storage.DeleteCustomContentKey(cci.Name)
|
||||
t.Run(cci.Name, testFunc)
|
||||
cc := CustomContent{
|
||||
Name: cci.Name,
|
||||
Enabled: true,
|
||||
}
|
||||
cc.Content = "start test content "
|
||||
for _, v := range cci.Variables {
|
||||
cc.Content += "{" + v + "}"
|
||||
}
|
||||
cc.Content += " end test content"
|
||||
e.storage.SetCustomContentKey(cci.Name, cc)
|
||||
t.Run(cci.Name+" Custom", testFunc)
|
||||
e.storage.DeleteCustomContentKey(cci.Name)
|
||||
}
|
||||
|
||||
// constructConfirmation(code, username, key string, placeholders bool)
|
||||
func TestConfirmation(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
// non-blank key, link should therefore not be a /my/confirm one
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
testContent(e, customContent["EmailConfirmation"], t, func(t *testing.T) {
|
||||
code := shortuuid.New()
|
||||
username := shortuuid.New()
|
||||
key := shortuuid.New()
|
||||
msg, err := e.constructConfirmation(code, username, key, false)
|
||||
t.Run("FromInvite", func(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if strings.Contains(content, "/my/confirm") {
|
||||
t.Fatalf("/my/confirm link generated instead of invite confirm link: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, code) {
|
||||
t.Fatalf("code not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, key) {
|
||||
t.Fatalf("key not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
code = ""
|
||||
msg, err = e.constructConfirmation(code, username, key, false)
|
||||
t.Run("FromMyAccount", func(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, "/my/confirm") {
|
||||
t.Fatalf("/my/confirm link not generated: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, key) {
|
||||
t.Fatalf("key not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// constructInvite(invite Invite, placeholders bool)
|
||||
func TestInvite(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
// Fix date/time format
|
||||
datePattern = "%d/%m/%y"
|
||||
timePattern = "%H:%M"
|
||||
testContent(e, customContent["InviteEmail"], t, func(t *testing.T) {
|
||||
inv := Invite{
|
||||
Code: shortuuid.New(),
|
||||
Created: time.Now(),
|
||||
ValidTill: time.Now().Add(30 * time.Minute),
|
||||
}
|
||||
msg, err := e.constructInvite(&inv, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, inv.Code) {
|
||||
t.Fatalf("code not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, "30m") {
|
||||
t.Fatalf("expiry not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// constructExpiry(code string, invite Invite, placeholders bool)
|
||||
func TestExpiry(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
// Fix date/time format
|
||||
datePattern = "%d/%m/%y"
|
||||
timePattern = "%H:%M"
|
||||
testContent(e, customContent["InviteExpiry"], t, func(t *testing.T) {
|
||||
inv := Invite{
|
||||
Code: shortuuid.New(),
|
||||
Created: time.Time{},
|
||||
ValidTill: time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC),
|
||||
}
|
||||
// So we can easily check is the expiry time is included (which is 0001-01-01).
|
||||
for strings.Contains(inv.Code, "1") {
|
||||
inv.Code = shortuuid.New()
|
||||
}
|
||||
|
||||
msg, err := e.constructExpiry(inv, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, inv.Code) {
|
||||
t.Fatalf("code not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
|
||||
t.Fatalf("expiry not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// constructCreated(code, username, address string, invite Invite, placeholders bool)
|
||||
func TestCreated(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
// Fix date/time format
|
||||
datePattern = "%d/%m/%y"
|
||||
timePattern = "%H:%M"
|
||||
testContent(e, customContent["UserCreated"], t, func(t *testing.T) {
|
||||
inv := Invite{
|
||||
Code: shortuuid.New(),
|
||||
Created: time.Time{},
|
||||
ValidTill: time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC),
|
||||
}
|
||||
username := shortuuid.New()
|
||||
address := shortuuid.New()
|
||||
|
||||
msg, err := e.constructCreated(username, address, inv.ValidTill, inv, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, inv.Code) {
|
||||
t.Fatalf("code not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, address) {
|
||||
t.Fatalf("address not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
|
||||
t.Fatalf("expiry not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// constructReset(pwr PasswordReset, placeholders bool)
|
||||
func TestReset(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
// Fix date/time format
|
||||
datePattern = "%d/%m/%y"
|
||||
timePattern = "%H:%M"
|
||||
testContent(e, customContent["PasswordReset"], t, func(t *testing.T) {
|
||||
pwr := PasswordReset{
|
||||
Pin: shortuuid.New(),
|
||||
Username: shortuuid.New(),
|
||||
Expiry: time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC),
|
||||
Internal: false,
|
||||
}
|
||||
|
||||
msg, err := e.constructReset(pwr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, pwr.Pin) {
|
||||
t.Fatalf("pin not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, pwr.Username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
|
||||
t.Fatalf("expiry not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// constructDeleted(reason string, placeholders bool)
|
||||
func TestDeleted(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
testContent(e, customContent["UserDeleted"], t, func(t *testing.T) {
|
||||
reason := shortuuid.New()
|
||||
username := shortuuid.New()
|
||||
msg, err := e.constructDeleted(username, reason, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, reason) {
|
||||
t.Fatalf("reason not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// constructDisabled(reason string, placeholders bool)
|
||||
func TestDisabled(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
testContent(e, customContent["UserDeleted"], t, func(t *testing.T) {
|
||||
reason := shortuuid.New()
|
||||
username := shortuuid.New()
|
||||
msg, err := e.constructDisabled(username, reason, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, reason) {
|
||||
t.Fatalf("reason not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// constructEnabled(reason string, placeholders bool)
|
||||
func TestEnabled(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
testContent(e, customContent["UserDeleted"], t, func(t *testing.T) {
|
||||
reason := shortuuid.New()
|
||||
username := shortuuid.New()
|
||||
msg, err := e.constructEnabled(username, reason, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, reason) {
|
||||
t.Fatalf("reason not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// constructExpiryAdjusted(username string, expiry time.Time, reason string, placeholders bool)
|
||||
func TestExpiryAdjusted(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
// Fix date/time format
|
||||
datePattern = "%d/%m/%y"
|
||||
timePattern = "%H:%M"
|
||||
testContent(e, customContent["UserExpiryAdjusted"], t, func(t *testing.T) {
|
||||
username := shortuuid.New()
|
||||
expiry := time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC)
|
||||
reason := shortuuid.New()
|
||||
msg, err := e.constructExpiryAdjusted(username, expiry, reason, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, reason) {
|
||||
t.Fatalf("reason not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
|
||||
t.Fatalf("expiry not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// constructExpiryReminder(username string, expiry time.Time, placeholders bool)
|
||||
func TestExpiryReminder(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
// Fix date/time format
|
||||
datePattern = "%d/%m/%y"
|
||||
timePattern = "%H:%M"
|
||||
testContent(e, customContent["ExpiryReminder"], t, func(t *testing.T) {
|
||||
username := shortuuid.New()
|
||||
expiry := time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC)
|
||||
msg, err := e.constructExpiryReminder(username, expiry, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
|
||||
t.Fatalf("expiry not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// constructWelcome(username string, expiry time.Time, placeholders bool)
|
||||
func TestWelcome(t *testing.T) {
|
||||
e := testDummyEmailerInit(t)
|
||||
defer dbClose(e)
|
||||
if db == nil {
|
||||
t.Fatalf("db nil")
|
||||
}
|
||||
// Fix date/time format
|
||||
datePattern = "%d/%m/%y"
|
||||
timePattern = "%H:%M"
|
||||
testContent(e, customContent["WelcomeEmail"], t, func(t *testing.T) {
|
||||
username := shortuuid.New()
|
||||
expiry := time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC)
|
||||
msg, err := e.constructWelcome(username, expiry, false)
|
||||
t.Run("NoExpiry", func(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
// time.Time{} is 0001-01-01... so look for a 1 in there at least.
|
||||
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
|
||||
t.Fatalf("expiry not found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
username = shortuuid.New()
|
||||
expiry = time.Time{}
|
||||
msg, err = e.constructWelcome(username, expiry, false)
|
||||
t.Run("WithExpiry", func(t *testing.T) {
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("failed construct: %+v", err)
|
||||
}
|
||||
for _, content := range []string{msg.Text, msg.HTML} {
|
||||
if !strings.Contains(content, username) {
|
||||
t.Fatalf("username not found in output: %s", content)
|
||||
}
|
||||
if strings.Contains(content, "01/01/01") || strings.Contains(content, "00:00") {
|
||||
t.Fatalf("empty expiry found in output: %s", content)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
35
external.go
@@ -1,19 +1,19 @@
|
||||
//go:build external
|
||||
// +build external
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/hrfee/jfa-go/logger"
|
||||
)
|
||||
|
||||
const binaryType = "external"
|
||||
|
||||
var localFS dirFS
|
||||
var langFS dirFS
|
||||
func BuildTagsExternal() { buildTags = append(buildTags, "external") }
|
||||
|
||||
// When using os.DirFS, even on Windows the separator seems to be '/'.
|
||||
// func FSJoin(elem ...string) string { return filepath.Join(elem...) }
|
||||
@@ -29,23 +29,12 @@ func FSJoin(elem ...string) string {
|
||||
return strings.TrimSuffix(path, sep)
|
||||
}
|
||||
|
||||
type dirFS string
|
||||
|
||||
func (dir dirFS) Open(name string) (fs.File, error) {
|
||||
return os.Open(string(dir) + "/" + name)
|
||||
}
|
||||
|
||||
func (dir dirFS) ReadFile(name string) ([]byte, error) {
|
||||
return os.ReadFile(string(dir) + "/" + name)
|
||||
}
|
||||
|
||||
func (dir dirFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
return os.ReadDir(string(dir) + "/" + name)
|
||||
}
|
||||
|
||||
func loadFilesystems() {
|
||||
log.Println("Using external storage")
|
||||
executable, _ := os.Executable()
|
||||
localFS = dirFS(filepath.Join(filepath.Dir(executable), "data"))
|
||||
langFS = dirFS(filepath.Join(filepath.Dir(executable), "data", "lang"))
|
||||
func loadFilesystems(rootDir string, logger *logger.Logger) {
|
||||
logger.Println("Using external storage")
|
||||
if rootDir == "" {
|
||||
executable, _ := os.Executable()
|
||||
rootDir = filepath.Dir(executable)
|
||||
}
|
||||
localFS = dirFS(filepath.Join(rootDir, "data"))
|
||||
langFS = dirFS(filepath.Join(rootDir, "data", "lang"))
|
||||
}
|
||||
|
||||
29
fs.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
type genericFS interface {
|
||||
fs.FS
|
||||
fs.ReadDirFS
|
||||
fs.ReadFileFS
|
||||
}
|
||||
|
||||
var localFS genericFS
|
||||
var langFS genericFS
|
||||
|
||||
type dirFS string
|
||||
|
||||
func (dir dirFS) Open(name string) (fs.File, error) {
|
||||
return os.Open(string(dir) + "/" + name)
|
||||
}
|
||||
|
||||
func (dir dirFS) ReadFile(name string) ([]byte, error) {
|
||||
return os.ReadFile(string(dir) + "/" + name)
|
||||
}
|
||||
|
||||
func (dir dirFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
return os.ReadDir(string(dir) + "/" + name)
|
||||
}
|
||||
77
generic-d.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
)
|
||||
|
||||
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
|
||||
|
||||
type GenericDaemon struct {
|
||||
Stopped bool
|
||||
ShutdownChannel chan string
|
||||
TriggerChannel chan bool
|
||||
Interval time.Duration
|
||||
period time.Duration
|
||||
jobs []func(app *appContext)
|
||||
app *appContext
|
||||
name string
|
||||
}
|
||||
|
||||
func (d *GenericDaemon) appendJobs(jobs ...func(app *appContext)) {
|
||||
d.jobs = append(d.jobs, jobs...)
|
||||
}
|
||||
|
||||
// NewGenericDaemon returns a daemon which can be given jobs that utilize appContext.
|
||||
func NewGenericDaemon(interval time.Duration, app *appContext, jobs ...func(app *appContext)) *GenericDaemon {
|
||||
d := GenericDaemon{
|
||||
Stopped: false,
|
||||
ShutdownChannel: make(chan string),
|
||||
TriggerChannel: make(chan bool),
|
||||
Interval: interval,
|
||||
period: interval,
|
||||
app: app,
|
||||
name: "Generic Daemon",
|
||||
}
|
||||
d.jobs = jobs
|
||||
return &d
|
||||
|
||||
}
|
||||
|
||||
func (d *GenericDaemon) Name(name string) { d.name = name }
|
||||
|
||||
func (d *GenericDaemon) run() {
|
||||
d.app.info.Printf(lm.StartDaemon, d.name)
|
||||
for {
|
||||
select {
|
||||
case <-d.ShutdownChannel:
|
||||
d.ShutdownChannel <- "Down"
|
||||
return
|
||||
case <-d.TriggerChannel:
|
||||
break
|
||||
case <-time.After(d.period):
|
||||
break
|
||||
}
|
||||
started := time.Now()
|
||||
|
||||
for _, job := range d.jobs {
|
||||
job(d.app)
|
||||
}
|
||||
|
||||
finished := time.Now()
|
||||
duration := finished.Sub(started)
|
||||
d.period = d.Interval - duration
|
||||
}
|
||||
}
|
||||
|
||||
func (d *GenericDaemon) Trigger() {
|
||||
d.TriggerChannel <- true
|
||||
}
|
||||
|
||||
func (d *GenericDaemon) Shutdown() {
|
||||
d.Stopped = true
|
||||
d.ShutdownChannel <- "Down"
|
||||
<-d.ShutdownChannel
|
||||
close(d.ShutdownChannel)
|
||||
}
|
||||
175
go.mod
@@ -1,6 +1,6 @@
|
||||
module github.com/hrfee/jfa-go
|
||||
|
||||
go 1.20
|
||||
go 1.24.0
|
||||
|
||||
replace github.com/hrfee/jfa-go/docs => ./docs
|
||||
|
||||
@@ -10,106 +10,133 @@ replace github.com/hrfee/jfa-go/ombi => ./ombi
|
||||
|
||||
replace github.com/hrfee/jfa-go/logger => ./logger
|
||||
|
||||
replace github.com/hrfee/jfa-go/logmessages => ./logmessages
|
||||
|
||||
replace github.com/hrfee/jfa-go/linecache => ./linecache
|
||||
|
||||
replace github.com/hrfee/jfa-go/api => ./api
|
||||
|
||||
replace github.com/hrfee/jfa-go/easyproxy => ./easyproxy
|
||||
|
||||
replace github.com/hrfee/jfa-go/jellyseerr => ./jellyseerr
|
||||
|
||||
// replace github.com/hrfee/mediabrowser => ../mediabrowser
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/discordgo v0.27.1
|
||||
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a
|
||||
github.com/fatih/color v1.15.0
|
||||
github.com/fsnotify/fsnotify v1.6.0
|
||||
github.com/getlantern/systray v1.2.2
|
||||
github.com/gin-contrib/pprof v1.4.0
|
||||
github.com/gin-contrib/static v0.0.1
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/dgraph-io/badger/v4 v4.8.0
|
||||
github.com/emersion/go-autostart v0.0.0-20250403115856-34830d6457d2
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/gin-contrib/pprof v1.5.3
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
|
||||
github.com/goccy/go-yaml v1.18.0
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||
github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a
|
||||
github.com/hrfee/jfa-go/common v0.0.0-20230421170108-d800b97f69b6
|
||||
github.com/hrfee/jfa-go/docs v0.0.0-20230421170108-d800b97f69b6
|
||||
github.com/hrfee/jfa-go/linecache v0.0.0-20230421170108-d800b97f69b6
|
||||
github.com/hrfee/jfa-go/logger v0.0.0-20230421170108-d800b97f69b6
|
||||
github.com/hrfee/jfa-go/ombi v0.0.0-20230421170108-d800b97f69b6
|
||||
github.com/hrfee/mediabrowser v0.3.8
|
||||
github.com/itchyny/timefmt-go v0.1.5
|
||||
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a
|
||||
github.com/hrfee/jfa-go/common v0.0.0-20251123165523-7c9f91711460
|
||||
github.com/hrfee/jfa-go/docs v0.0.0-20251123165523-7c9f91711460
|
||||
github.com/hrfee/jfa-go/easyproxy v0.0.0-20251123165523-7c9f91711460
|
||||
github.com/hrfee/jfa-go/jellyseerr v0.0.0-20251123165523-7c9f91711460
|
||||
github.com/hrfee/jfa-go/linecache v0.0.0-20251123165523-7c9f91711460
|
||||
github.com/hrfee/jfa-go/logger v0.0.0-20251123165523-7c9f91711460
|
||||
github.com/hrfee/jfa-go/logmessages v0.0.0-20251123165523-7c9f91711460
|
||||
github.com/hrfee/jfa-go/ombi v0.0.0-20251123165523-7c9f91711460
|
||||
github.com/hrfee/mediabrowser v0.3.35
|
||||
github.com/hrfee/simple-template v1.1.0
|
||||
github.com/itchyny/timefmt-go v0.1.7
|
||||
github.com/lithammer/shortuuid/v3 v3.0.7
|
||||
github.com/mailgun/mailgun-go/v4 v4.9.0
|
||||
github.com/lutischan-ferenc/systray v1.2.1
|
||||
github.com/mailgun/mailgun-go/v4 v4.23.0
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/robert-nix/ansihtml v1.0.1
|
||||
github.com/steambap/captcha v1.4.1
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.0
|
||||
github.com/swaggo/gin-swagger v1.6.1
|
||||
github.com/timshannon/badgerhold/v4 v4.0.3
|
||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible
|
||||
github.com/xhit/go-simple-mail/v2 v2.13.0
|
||||
github.com/xhit/go-simple-mail/v2 v2.16.0
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
maunium.net/go/mautrix v0.15.2
|
||||
maunium.net/go/mautrix v0.26.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect
|
||||
github.com/getlantern/errors v1.0.3 // indirect
|
||||
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 // indirect
|
||||
github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc // indirect
|
||||
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 // indirect
|
||||
github.com/getlantern/ops v0.0.0-20230519221840-1283e026181c // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-logr/logr v1.2.4 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.6 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
github.com/go-openapi/spec v0.20.9 // indirect
|
||||
github.com/go-openapi/swag v0.22.4 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
||||
github.com/go-openapi/spec v0.22.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.3 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.1 // indirect
|
||||
github.com/go-stack/stack v1.8.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||
github.com/go-test/deep v1.1.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.0 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/gorilla/mux v1.8.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/google/flatbuffers v25.9.23+incompatible // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b // indirect
|
||||
github.com/mailgun/errors v0.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rs/zerolog v1.29.1 // indirect
|
||||
github.com/swaggo/swag v1.16.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.57.0 // indirect
|
||||
github.com/rs/zerolog v1.34.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/swaggo/swag v1.16.6 // indirect
|
||||
github.com/technoweenie/multipartstreamer v1.0.1 // indirect
|
||||
github.com/tidwall/gjson v1.14.4 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect
|
||||
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
go.opentelemetry.io/otel v1.16.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.16.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.16.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.24.0 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.9.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect
|
||||
golang.org/x/image v0.7.0 // indirect
|
||||
golang.org/x/net v0.10.0 // indirect
|
||||
golang.org/x/sys v0.8.0 // indirect
|
||||
golang.org/x/text v0.9.0 // indirect
|
||||
golang.org/x/tools v0.9.3 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
maunium.net/go/maulogger/v2 v2.4.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mau.fi/util v0.9.3 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
|
||||
golang.org/x/image v0.33.0 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
|
||||
557
go.sum
@@ -1,248 +1,308 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY=
|
||||
github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a h1:M88ob4TyDnEqNuL3PgsE/p3bDujfspnulR+0dQWNYZs=
|
||||
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a/go.mod h1:buzQsO8HHkZX2Q45fdfGH1xejPjuDQaXH8btcYMFzPM=
|
||||
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 h1:0JZ+dUmQeA8IIVUMzysrX4/AKuQwWhV2dYQuPZdvdSQ=
|
||||
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=
|
||||
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A=
|
||||
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=
|
||||
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 h1:E2s37DuLxFhQDg5gKsWoLBOB0n+ZW8s599zru8FJ2/Y=
|
||||
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
|
||||
github.com/dgraph-io/badger/v4 v4.1.0/go.mod h1:P50u28d39ibBRmIJuQC/NSdBOg46HnHw7al2SW5QRHg=
|
||||
github.com/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs=
|
||||
github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w=
|
||||
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emersion/go-autostart v0.0.0-20250403115856-34830d6457d2 h1:CgF8+TNFvlnxEbplSgS70ZI4IUFEzVkY+ICNqTVE/AM=
|
||||
github.com/emersion/go-autostart v0.0.0-20250403115856-34830d6457d2/go.mod h1:buzQsO8HHkZX2Q45fdfGH1xejPjuDQaXH8btcYMFzPM=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
|
||||
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
|
||||
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
|
||||
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
|
||||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
|
||||
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 h1:oEZYEpZo28Wdx+5FZo4aU7JFXu0WG/4wJWese5reQSA=
|
||||
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201/go.mod h1:Y9WZUHEb+mpra02CbQ/QczLUe6f0Dezxaw5DCJlJQGo=
|
||||
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
|
||||
github.com/getlantern/errors v1.0.1/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
|
||||
github.com/getlantern/errors v1.0.3 h1:Ne4Ycj7NI1BtSyAfVeAT/DNoxz7/S2BUc3L2Ht1YSHE=
|
||||
github.com/getlantern/errors v1.0.3/go.mod h1:m8C7H1qmouvsGpwQqk/6NUpIVMpfzUPn608aBZDYV04=
|
||||
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
|
||||
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 h1:NlQedYmPI3pRAXJb+hLVVDGqfvvXGRPV8vp7XOjKAZ0=
|
||||
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65/go.mod h1:+ZU1h+iOVqWReBpky6d5Y2WL0sF2Llxu+QcxJFs2+OU=
|
||||
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
|
||||
github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc h1:sue+aeVx7JF5v36H1HfvcGFImLpSD5goj8d+MitovDU=
|
||||
github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc/go.mod h1:D9RWpXy/EFPYxiKUURo2TB8UBosbqkiLhttRrZYtvqM=
|
||||
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
|
||||
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 h1:cSrD9ryDfTV2yaur9Qk3rHYD414j3Q1rl7+L0AylxrE=
|
||||
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770/go.mod h1:GOQsoDnEHl6ZmNIL+5uVo+JWRFWozMEp18Izcb++H+A=
|
||||
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
|
||||
github.com/getlantern/ops v0.0.0-20220713155959-1315d978fff7/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
|
||||
github.com/getlantern/ops v0.0.0-20230519221840-1283e026181c h1:qcPAzA1ZDnwx618jAgQmxo6UvJkw2SkM1L4ofncmEhI=
|
||||
github.com/getlantern/ops v0.0.0-20230519221840-1283e026181c/go.mod h1:g2ueCncOwWenlAr56Fh90FwsACkelqqtFUDLAHg1mng=
|
||||
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
|
||||
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/gin-contrib/gzip v0.0.1/go.mod h1:fGBJBCdt6qCZuCAOwWuFhBB4OOq9EFqlo5dEaFhhu5w=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
github.com/gin-contrib/pprof v1.4.0 h1:XxiBSf5jWZ5i16lNOPbMTVdgHBdhfGRD5PZ1LWazzvg=
|
||||
github.com/gin-contrib/pprof v1.4.0/go.mod h1:RrehPJasUVBPK6yTUwOl8/NP6i0vbUgmxtis+Z5KE90=
|
||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/pprof v1.5.3 h1:Bj5SxJ3kQDVez/s/+f9+meedJIqLS+xlkIVDe/lcvgM=
|
||||
github.com/gin-contrib/pprof v1.5.3/go.mod h1:0+LQSZ4SLO0B6+2n6JBzaEygpTBxe/nI+YEYpfQQ6xY=
|
||||
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
|
||||
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-contrib/static v0.0.1 h1:JVxuvHPuUfkoul12N7dtQw7KRn/pSMq7Ue1Va9Swm1U=
|
||||
github.com/gin-contrib/static v0.0.1/go.mod h1:CSxeF+wep05e0kCOsqWdAWbSszmc31zTIbD8TvWl7Hs=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y=
|
||||
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
|
||||
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
|
||||
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
|
||||
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M=
|
||||
github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
|
||||
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
|
||||
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
||||
github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I=
|
||||
github.com/go-openapi/jsonreference v0.19.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I=
|
||||
github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc=
|
||||
github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8=
|
||||
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
|
||||
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
||||
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
||||
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
|
||||
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
|
||||
github.com/go-openapi/spec v0.19.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI=
|
||||
github.com/go-openapi/spec v0.19.4/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo=
|
||||
github.com/go-openapi/spec v0.20.9 h1:xnlYNQAwKd2VQRRfwTEI0DcK+2cbuvI/0c7jx3gA8/8=
|
||||
github.com/go-openapi/spec v0.20.9/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
|
||||
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
|
||||
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
|
||||
github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg=
|
||||
github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||
github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU=
|
||||
github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag/conv v0.25.3 h1:PcB18wwfba7MN5BVlBIV+VxvUUeC2kEuCEyJ2/t2X7E=
|
||||
github.com/go-openapi/swag/conv v0.25.3/go.mod h1:n4Ibfwhn8NJnPXNRhBO5Cqb9ez7alBR40JS4rbASUPU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A=
|
||||
github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.3 h1:kV7wer79KXUM4Ea4tBdAVTU842Rg6tWstX3QbM4fGdw=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.3/go.mod h1:ILcKqe4HC1VEZmJx51cVuZQ6MF8QvdfXsQfiaCs0z9o=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3 h1:/i3E9hBujtXfHy91rjtwJ7Fgv5TuDHgnSrYjhFxwxOw=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3/go.mod h1:8kYfCR2rHyOj25HVvxL5Nm8wkfzggddgjZm6RgjT8Ao=
|
||||
github.com/go-openapi/swag/loading v0.25.3 h1:Nn65Zlzf4854MY6Ft0JdNrtnHh2bdcS/tXckpSnOb2Y=
|
||||
github.com/go-openapi/swag/loading v0.25.3/go.mod h1:xajJ5P4Ang+cwM5gKFrHBgkEDWfLcsAKepIuzTmOb/c=
|
||||
github.com/go-openapi/swag/stringutils v0.25.3 h1:nAmWq1fUTWl/XiaEPwALjp/8BPZJun70iDHRNq/sH6w=
|
||||
github.com/go-openapi/swag/stringutils v0.25.3/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/typeutils v0.25.3 h1:2w4mEEo7DQt3V4veWMZw0yTPQibiL3ri2fdDV4t2TQc=
|
||||
github.com/go-openapi/swag/typeutils v0.25.3/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.3 h1:LKTJjCn/W1ZfMec0XDL4Vxh8kyAnv1orH5F2OREDUrg=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.3/go.mod h1:Y7QN6Wc5DOBXK14/xeo1cQlq0EA0wvLoSv13gDQoCao=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
|
||||
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
|
||||
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
|
||||
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
|
||||
github.com/go-playground/validator/v10 v10.14.1 h1:9c50NUPC30zyuKprjL3vNZ0m5oG+jU0zvx4AqHGnv4k=
|
||||
github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
|
||||
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM=
|
||||
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8=
|
||||
github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/glog v1.1.1/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a h1:AWZzzFrqyjYlRloN6edwTLTUbKxf5flLXNuTBDm3Ews=
|
||||
github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/flatbuffers v23.5.9+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/flatbuffers v25.9.23+incompatible h1:rGZKv+wOb6QPzIdkM2KxhBZCDrA0DeN6DNmRDrqIsQU=
|
||||
github.com/google/flatbuffers v25.9.23+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hrfee/mediabrowser v0.3.8 h1:y0iBCb6jE3QKcsiCJSYva2fFPHRn4UA+sGRzoPuJ/Dk=
|
||||
github.com/hrfee/mediabrowser v0.3.8/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
|
||||
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
|
||||
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hrfee/mediabrowser v0.3.33 h1:kjUFZc46hNhbOEU4xZNyhGVNjfZ5lENmX95Md1thxiA=
|
||||
github.com/hrfee/mediabrowser v0.3.33/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
|
||||
github.com/hrfee/mediabrowser v0.3.34 h1:AKnd1V9wt+KWZmHDjj1GMkCgcgcpBKxPw5iUcYgD6Tg=
|
||||
github.com/hrfee/mediabrowser v0.3.34/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
|
||||
github.com/hrfee/mediabrowser v0.3.35 h1:xEq4cL96Di0G+S3ONBH1HHeQJU6IfUMZiaeGeuJSFS8=
|
||||
github.com/hrfee/mediabrowser v0.3.35/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
|
||||
github.com/hrfee/simple-template v1.1.0 h1:PNQDTgc2H0s19/pWuhRh4bncuNJjPrW0fIX77YtY78M=
|
||||
github.com/hrfee/simple-template v1.1.0/go.mod h1:s9a5QgfqbmT7j9WCC3GD5JuEqvihBEohyr+oYZmr4bA=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
|
||||
github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
|
||||
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
|
||||
github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
||||
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
|
||||
github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
|
||||
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||
github.com/mailgun/mailgun-go/v4 v4.9.0 h1:wRbxvVQ5QObFewLxc1uVvipA16D8gxeiO+cBOca51Iw=
|
||||
github.com/mailgun/mailgun-go/v4 v4.9.0/go.mod h1:FJlF9rI5cQT+mrwujtJjPMbIVy3Ebor9bKTVsJ0QU40=
|
||||
github.com/lutischan-ferenc/systray v1.2.1 h1:gPNrEpmg4hMwXyKNSlrkuuXqvxgqCYPjF5H/pG9I1+c=
|
||||
github.com/lutischan-ferenc/systray v1.2.1/go.mod h1:YYaJ28AVuhMrlI5JfqrMsYMIl3Aa4Q02bpXXCl9caqo=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b h1:xZ59n7Frzh8CwyfAapUZLSg+gXH5m63YEaFCMpDHhpI=
|
||||
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b/go.mod h1:uDd4sYVYsqcxAB8j+Q7uhL6IJCs/r1kxib1HV4bgOMg=
|
||||
github.com/mailgun/errors v0.4.0 h1:6LFBvod6VIW83CMIOT9sYNp28TCX0NejFPP4dSX++i8=
|
||||
github.com/mailgun/errors v0.4.0/go.mod h1:xGBaaKdEdQT0/FhwvoXv4oBaqqmVZz9P1XEnvD/onc0=
|
||||
github.com/mailgun/mailgun-go/v4 v4.23.0 h1:jPEMJzzin2s7lvehcfv/0UkyBu18GvcURPr2+xtZRbk=
|
||||
github.com/mailgun/mailgun-go/v4 v4.23.0/go.mod h1:imTtizoFtpfZqPqGP8vltVBB6q9yWcv6llBhfFeElZU=
|
||||
github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
|
||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
|
||||
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a h1:VweslR2akb/ARhXfqSfRbj1vpWwYXf3eeAUyw/ndms0=
|
||||
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
|
||||
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
|
||||
github.com/robert-nix/ansihtml v1.0.1 h1:VTiyQ6/+AxSJoSSLsMecnkh8i0ZqOEdiRl/odOc64fc=
|
||||
github.com/robert-nix/ansihtml v1.0.1/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yDXcNeHHKZINE=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc=
|
||||
github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||
github.com/steambap/captcha v1.4.1 h1:OmMdxLCWCqJvsFaFYwRpvMckIuvI6s8s1LsBrBw97P0=
|
||||
github.com/steambap/captcha v1.4.1/go.mod h1:oC9T7IfEgnrhzjDz5Djf1H7GPffCzRMbsQfFkJmhlnk=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
@@ -251,95 +311,106 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/gin-swagger v1.2.0/go.mod h1:qlH2+W7zXGZkczuL+r2nEBR2JTT+/lX05Nn6vPhc7OI=
|
||||
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
|
||||
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
|
||||
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
|
||||
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
|
||||
github.com/swaggo/swag v1.5.1/go.mod h1:1Bl9F/ZBpVWh22nY0zmYyASPO1lI/zIwRDrpZU+tv8Y=
|
||||
github.com/swaggo/swag v1.6.7/go.mod h1:xDhTyuFIujYiN3DKWC/H/83xcfHp+UE/IzWWampG7Zc=
|
||||
github.com/swaggo/swag v1.16.1 h1:fTNRhKstPKxcnoKsytm4sahr8FaYzUcT7i1/3nd/fBg=
|
||||
github.com/swaggo/swag v1.16.1/go.mod h1:9/LMvHycG3NFHfR6LwvikHv5iFvmPADQ359cKikGxto=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
|
||||
github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
|
||||
github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c h1:coVla7zpsycc+kA9NXpcvv2E4I7+ii6L5hZO2S6C3kw=
|
||||
github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K07Tzg=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
|
||||
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM=
|
||||
github.com/timshannon/badgerhold/v4 v4.0.3 h1:W6pd2qckoXw2cl8eH0ZCV/9CXNaXvaM26tzFi5Tj+v8=
|
||||
github.com/timshannon/badgerhold/v4 v4.0.3/go.mod h1:IkZIr0kcZLMdD7YJfW/G6epb6ZXHD/h0XR2BTk/VZg8=
|
||||
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
|
||||
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA=
|
||||
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
|
||||
github.com/ugorji/go v1.1.5-pre/go.mod h1:FwP/aQVg39TXzItUBMwnWp9T9gPQnXw4Poh4/oBQZ/0=
|
||||
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
|
||||
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
|
||||
github.com/ugorji/go/codec v0.0.0-20181022190402-e5e69e061d4f/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/ugorji/go/codec v1.1.5-pre/go.mod h1:tULtS6Gy1AE1yCENaw4Vb//HLH5njI2tfCQDUqRd8fI=
|
||||
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
||||
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
|
||||
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
|
||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
|
||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE=
|
||||
github.com/xhit/go-simple-mail/v2 v2.13.0 h1:OANWU9jHZrVfBkNkvLf8Ww0fexwpQVF/v/5f96fFTLI=
|
||||
github.com/xhit/go-simple-mail/v2 v2.13.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA=
|
||||
github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/otel v1.9.0/go.mod h1:np4EoPGzoPs3O67xUVNoPPcmSvsfOxNlNA4F4AC+0Eo=
|
||||
go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s=
|
||||
go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4=
|
||||
go.opentelemetry.io/otel/metric v1.16.0 h1:RbrpwVG1Hfv85LgnZ7+txXioPDoh6EdbZHo26Q3hqOo=
|
||||
go.opentelemetry.io/otel/metric v1.16.0/go.mod h1:QE47cpOmkwipPiefDwo2wDzwJrlfxxNYodqc4xnGCo4=
|
||||
go.opentelemetry.io/otel/trace v1.9.0/go.mod h1:2737Q0MuG8q1uILYm2YYVkAyLtOofiTNGg6VODnOiPo=
|
||||
go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs=
|
||||
go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
|
||||
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
|
||||
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
go.mau.fi/util v0.9.3 h1:aqNF8KDIN8bFpFbybSk+mEBil7IHeBwlujfyTnvP0uU=
|
||||
go.mau.fi/util v0.9.3/go.mod h1:krWWfBM1jWTb5f8NCa2TLqWMQuM81X7TGQjhMjBeXmQ=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=
|
||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
|
||||
golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw=
|
||||
golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
|
||||
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
@@ -347,96 +418,128 @@ golang.org/x/net v0.0.0-20190611141213-3f473d35a33a/go.mod h1:z5CRVTTTmAJ677TzLL
|
||||
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190610200419-93c9922d18ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190606050223-4d9ae51c2468/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190611222205-d73e1c7e250b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.9.3 h1:Gn1I8+64MsuTb/HpH+LmQtNas23LhUVr3rYZ0eKuaMM=
|
||||
golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
|
||||
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
|
||||
maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
|
||||
maunium.net/go/mautrix v0.15.2 h1:fUiVajeoOR92uJoSShHbCvh7uG6lDY4ZO4Mvt90LbjU=
|
||||
maunium.net/go/mautrix v0.15.2/go.mod h1:h4NwfKqE4YxGTLSgn/gawKzXAb2sF4qx8agL6QEFtGg=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
maunium.net/go/mautrix v0.26.0 h1:valc2VmZF+oIY4bMq4Cd5H9cEKMRe8eP4FM7iiaYLxI=
|
||||
maunium.net/go/mautrix v0.26.0/go.mod h1:NWMv+243NX/gDrLofJ2nNXJPrG8vzoM+WUCWph85S6Q=
|
||||
|
||||
164
housekeeping-d.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/dgraph-io/badger/v4"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
// clearEmails removes stored emails for users which no longer exist.
|
||||
// meant to be called with other such housekeeping functions, so assumes
|
||||
// the user cache is fresh.
|
||||
func (app *appContext) clearEmails() {
|
||||
app.debug.Println(lm.HousekeepingEmail)
|
||||
emails := app.storage.GetEmails()
|
||||
for _, email := range emails {
|
||||
_, err := app.jf.UserByID(email.JellyfinID, false)
|
||||
// Make sure the user doesn't exist, and no other error has occured
|
||||
switch err.(type) {
|
||||
case mediabrowser.ErrUserNotFound:
|
||||
app.storage.DeleteEmailsKey(email.JellyfinID)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clearDiscord does the same as clearEmails, but for Discord Users.
|
||||
func (app *appContext) clearDiscord() {
|
||||
app.debug.Println(lm.HousekeepingDiscord)
|
||||
discordUsers := app.storage.GetDiscord()
|
||||
removeRoleOnDisable := app.config.Section("discord").Key("disable_enable_role").MustBool(false)
|
||||
for _, discordUser := range discordUsers {
|
||||
user, err := app.jf.UserByID(discordUser.JellyfinID, false)
|
||||
// Make sure the user doesn't exist, and no other error has occured
|
||||
switch err.(type) {
|
||||
case mediabrowser.ErrUserNotFound:
|
||||
// Remove role in case their account was deleted oustide of jfa-go
|
||||
app.discord.RemoveRole(discordUser.MethodID().(string))
|
||||
app.storage.DeleteDiscordKey(discordUser.JellyfinID)
|
||||
default:
|
||||
if removeRoleOnDisable && user.Policy.IsDisabled {
|
||||
app.discord.RemoveRole(discordUser.MethodID().(string))
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clearMatrix does the same as clearEmails, but for Matrix Users.
|
||||
func (app *appContext) clearMatrix() {
|
||||
app.debug.Println(lm.HousekeepingMatrix)
|
||||
matrixUsers := app.storage.GetMatrix()
|
||||
for _, matrixUser := range matrixUsers {
|
||||
_, err := app.jf.UserByID(matrixUser.JellyfinID, false)
|
||||
// Make sure the user doesn't exist, and no other error has occured
|
||||
switch err.(type) {
|
||||
case mediabrowser.ErrUserNotFound:
|
||||
app.storage.DeleteMatrixKey(matrixUser.JellyfinID)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clearTelegram does the same as clearEmails, but for Telegram Users.
|
||||
func (app *appContext) clearTelegram() {
|
||||
app.debug.Println(lm.HousekeepingTelegram)
|
||||
telegramUsers := app.storage.GetTelegram()
|
||||
for _, telegramUser := range telegramUsers {
|
||||
_, err := app.jf.UserByID(telegramUser.JellyfinID, false)
|
||||
// Make sure the user doesn't exist, and no other error has occured
|
||||
switch err.(type) {
|
||||
case mediabrowser.ErrUserNotFound:
|
||||
app.storage.DeleteTelegramKey(telegramUser.JellyfinID)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (app *appContext) clearPWRCaptchas() {
|
||||
app.debug.Println(lm.HousekeepingCaptcha)
|
||||
captchas := map[string]Captcha{}
|
||||
for k, capt := range app.pwrCaptchas {
|
||||
if capt.Generated.Add(CAPTCHA_VALIDITY * time.Second).After(time.Now()) {
|
||||
captchas[k] = capt
|
||||
}
|
||||
}
|
||||
app.pwrCaptchas = captchas
|
||||
}
|
||||
|
||||
func (app *appContext) clearActivities() {
|
||||
app.debug.Println(lm.HousekeepingActivity)
|
||||
keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000)
|
||||
maxAgeDays := app.config.Section("activity_log").Key("delete_after_days").MustInt(90)
|
||||
minAge := time.Now().AddDate(0, 0, -maxAgeDays)
|
||||
err := error(nil)
|
||||
errorSource := 0
|
||||
if maxAgeDays != 0 {
|
||||
err = app.storage.db.DeleteMatching(&Activity{}, badgerhold.Where("Time").Lt(minAge))
|
||||
}
|
||||
if err == nil && keepCount != 0 {
|
||||
// app.debug.Printf("Keeping %d records", keepCount)
|
||||
err = app.storage.db.DeleteMatching(&Activity{}, (&badgerhold.Query{}).Reverse().SortBy("Time").Skip(keepCount))
|
||||
if err != nil {
|
||||
errorSource = 1
|
||||
}
|
||||
}
|
||||
if err == badger.ErrTxnTooBig {
|
||||
app.debug.Printf(lm.ActivityLogTxnTooBig)
|
||||
list := []Activity{}
|
||||
if errorSource == 0 {
|
||||
app.storage.db.Find(&list, badgerhold.Where("Time").Lt(minAge))
|
||||
} else {
|
||||
app.storage.db.Find(&list, (&badgerhold.Query{}).Reverse().SortBy("Time").Skip(keepCount))
|
||||
}
|
||||
for _, record := range list {
|
||||
app.storage.DeleteActivityKey(record.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newHousekeepingDaemon(interval time.Duration, app *appContext) *GenericDaemon {
|
||||
d := NewGenericDaemon(interval, app,
|
||||
func(app *appContext) {
|
||||
app.debug.Println(lm.HousekeepingInvites)
|
||||
app.checkInvites()
|
||||
},
|
||||
func(app *appContext) { app.clearActivities() },
|
||||
)
|
||||
|
||||
d.Name("Housekeeping")
|
||||
|
||||
clearEmail := app.config.Section("email").Key("require_unique").MustBool(false)
|
||||
clearDiscord := discordEnabled && (app.config.Section("discord").Key("require_unique").MustBool(false) || app.config.Section("discord").Key("disable_enable_role").MustBool(false))
|
||||
clearTelegram := telegramEnabled && (app.config.Section("telegram").Key("require_unique").MustBool(false))
|
||||
clearMatrix := matrixEnabled && (app.config.Section("matrix").Key("require_unique").MustBool(false))
|
||||
clearPWR := app.config.Section("captcha").Key("enabled").MustBool(false) && !app.config.Section("captcha").Key("recaptcha").MustBool(false)
|
||||
|
||||
if clearEmail || clearDiscord || clearTelegram || clearMatrix {
|
||||
d.appendJobs(func(app *appContext) { app.InvalidateJellyfinCache() })
|
||||
}
|
||||
|
||||
if clearEmail {
|
||||
d.appendJobs(func(app *appContext) { app.clearEmails() })
|
||||
}
|
||||
if clearDiscord {
|
||||
d.appendJobs(func(app *appContext) { app.clearDiscord() })
|
||||
}
|
||||
if clearTelegram {
|
||||
d.appendJobs(func(app *appContext) { app.clearTelegram() })
|
||||
}
|
||||
if clearMatrix {
|
||||
d.appendJobs(func(app *appContext) { app.clearMatrix() })
|
||||
}
|
||||
if clearPWR {
|
||||
d.appendJobs(func(app *appContext) { app.clearPWRCaptchas() })
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="{{ .cssClass }}">
|
||||
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="{{ .cssClass }}">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="css/{{ .cssVersion }}bundle.css">
|
||||
{{ template "header.html" . }}
|
||||
<title>404 - jfa-go</title>
|
||||
{{ template "header.txt" . }}
|
||||
</head>
|
||||
<body class="section">
|
||||
<div class="page-container">
|
||||
<div class="page-container m-2 lg:my-20 lg:mx-64">
|
||||
<div class="card">
|
||||
<h1 class="heading">Page not found.</h1>
|
||||
<p class="content">
|
||||
|
||||
16
html/account-linking-discord.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{{ if .discordEnabled }}
|
||||
<div id="modal-discord" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 flex flex-col gap-4">
|
||||
<span class="heading">{{ .strings.linkDiscord }}</span>
|
||||
<p class="content"> {{ .discordSendPINMessage }}</p>
|
||||
<h1 class="text-center text-2xl pin"></h1>
|
||||
<div class="flex flex-row gap-2 justify-center items-center">
|
||||
<a class="hover:underline flex flex-row gap-4 items-center">
|
||||
<span>{{ .strings.joinTheServer }}</span>
|
||||
<span id="discord-invite" class="flex flex-row gap-2 items-center"></span>
|
||||
</a>
|
||||
</div>
|
||||
<span class="button ~info @low full-width center" id="discord-waiting">{{ .strings.success }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
18
html/account-linking-matrix.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{{ if .matrixEnabled }}
|
||||
<div id="modal-matrix" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 flex flex-col gap-4">
|
||||
<span class="heading">{{ .strings.linkMatrix }}</span>
|
||||
<p class="content"> {{ .strings.matrixEnterUser }}</p>
|
||||
<input type="text" class="input ~neutral @high" placeholder="@user:riot.im" id="matrix-userid">
|
||||
<div class="subheading flex flex-row gap-2 justify-center items-center">
|
||||
<span class="shield ~info">
|
||||
<span class="icon">
|
||||
<i class="ri-chat-3-line"></i>
|
||||
</span>
|
||||
</span>
|
||||
<span>{{ .matrixUser }}</span>
|
||||
</div>
|
||||
<span class="button ~info @low full-width center" id="matrix-send">{{ .strings.submit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
18
html/account-linking-telegram.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{{ if .telegramEnabled }}
|
||||
<div id="modal-telegram" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 flex flex-col gap-4">
|
||||
<span class="heading">{{ .strings.linkTelegram }}</span>
|
||||
<p class="content">{{ .strings.sendPIN }}</p>
|
||||
<p class="text-center text-2xl pin"></p>
|
||||
<a class="subheading link flex flex-row gap-2 justify-center items-center" href="{{ .telegramURL }}" target="_blank">
|
||||
<span class="shield ~info">
|
||||
<span class="icon">
|
||||
<i class="ri-telegram-line"></i>
|
||||
</span>
|
||||
</span>
|
||||
<span class="hover:underline">@<span class="username">{{ .telegramUsername }}</span></span>
|
||||
</a>
|
||||
<span class="button ~info @low full-width center" id="telegram-waiting">{{ .strings.success }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -1,52 +1,3 @@
|
||||
{{ if .discordEnabled }}
|
||||
<div id="modal-discord" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
||||
<span class="heading mb-4">{{ .strings.linkDiscord }}</span>
|
||||
<p class="content mb-4"> {{ .discordSendPINMessage }}</p>
|
||||
<h1 class="text-center text-2xl mb-2 pin"></h1>
|
||||
<div class="row center">
|
||||
<a class="my-5 hover:underline">
|
||||
<span class="mr-2">{{ .strings.joinTheServer }}</span>
|
||||
<span id="discord-invite"></span>
|
||||
</a>
|
||||
</div>
|
||||
<span class="button ~info @low full-width center mt-4" id="discord-waiting">{{ .strings.success }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .telegramEnabled }}
|
||||
<div id="modal-telegram" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
||||
<span class="heading mb-4">{{ .strings.linkTelegram }}</span>
|
||||
<p class="content mb-4">{{ .strings.sendPIN }}</p>
|
||||
<p class="text-center text-2xl mb-2 pin"></p>
|
||||
<a class="subheading link-center" href="{{ .telegramURL }}" target="_blank">
|
||||
<span class="shield ~info mr-4">
|
||||
<span class="icon">
|
||||
<i class="ri-telegram-line"></i>
|
||||
</span>
|
||||
</span>
|
||||
@{{ .telegramUsername }}
|
||||
</a>
|
||||
<span class="button ~info @low full-width center mt-4" id="telegram-waiting">{{ .strings.success }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .matrixEnabled }}
|
||||
<div id="modal-matrix" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
||||
<span class="heading mb-4">{{ .strings.linkMatrix }}</span>
|
||||
<p class="content mb-4"> {{ .strings.matrixEnterUser }}</p>
|
||||
<input type="text" class="input ~neutral @high" placeholder="@user:riot.im" id="matrix-userid">
|
||||
<div class="subheading link-center mt-4">
|
||||
<span class="shield ~info mr-4">
|
||||
<span class="icon">
|
||||
<i class="ri-chat-3-line"></i>
|
||||
</span>
|
||||
</span>
|
||||
{{ .matrixUser }}
|
||||
</div>
|
||||
<span class="button ~info @low full-width center mt-4" id="matrix-send">{{ .strings.submit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ template "account-linking-discord.html" . }}
|
||||
{{ template "account-linking-telegram.html" . }}
|
||||
{{ template "account-linking-matrix.html" . }}
|
||||
|
||||
957
html/admin.html
@@ -1,45 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}">
|
||||
<head>
|
||||
<link inline rel="stylesheet" type="text/css" href="bundle.css">
|
||||
{{ template "header.html" . }}
|
||||
<!--- This CSS is inlined so we should keep this here! -->
|
||||
<link inline rel="stylesheet" type="text/css" href="web/css/v0.6.0bundle.css">
|
||||
{{ template "header.txt" . }}
|
||||
<title>Crash report</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-container">
|
||||
<div class="page-container m-2 lg:my-20 lg:mx-64">
|
||||
<div class="card ~critical sectioned">
|
||||
<section class="section ~critical">
|
||||
<section class="section ~critical flex flex-col gap-2">
|
||||
<span class="heading">Crash report for jfa-go</span>
|
||||
{{ if .Err }}
|
||||
<div class="font-mono bg-inherit pre-line mt-4 mb-4">
|
||||
<div class="font-mono bg-inherit pre-line">
|
||||
Error: {{ .Err }}
|
||||
</div>
|
||||
{{ end }}
|
||||
<a class="button ~critical mb-4" target="_blank" href="https://github.com/hrfee/jfa-go/issues/new/choose">Create an Issue</a>
|
||||
<a class="button ~critical w-full center" target="_blank" href="https://github.com/hrfee/jfa-go/issues/new/choose">Create an Issue</a>
|
||||
</section>
|
||||
<section class="section ~neutral @low">
|
||||
<div class="flex-expand">
|
||||
<span class="subheading">Full Log</span>
|
||||
<span class="button ~urge ml-4" id="copy-log">Copy</span>
|
||||
<section class="section ~neutral @low flex flex-col gap-4">
|
||||
<div class="flex flex-row justify-between gap-4">
|
||||
<span class="subheading font-medium">Full Log</span>
|
||||
<span class="button ~urge" id="copy-log">Copy</span>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<label class="col mr-4">
|
||||
<span class="button ~neutral @high supra full-width center" id="button-log-normal">Normal</span>
|
||||
</label>
|
||||
<label class="col mr-4">
|
||||
<span class="button ~neutral @low supra full-width center" id="button-log-sanitized">Sanitized</span>
|
||||
</label>
|
||||
<div class="flex flex-row gap-2 justify-between">
|
||||
<button class="button ~neutral @high supra w-full center" id="button-log-normal">Normal</button>
|
||||
<button class="button ~neutral @low supra w-full center" id="button-log-sanitized">Sanitized</button>
|
||||
</div>
|
||||
<div id="log-normal">
|
||||
<pre class="font-mono bg-inherit pre-line">{{ .Log }}</pre>
|
||||
<pre class="card font-mono bg-inherit pre-line">{{ .Log }}</pre>
|
||||
</div>
|
||||
<div id="log-sanitized" class="unfocused">
|
||||
<p class="subheading">An attempt has been made to remove sensitive info, but make sure to check yourself.</p>
|
||||
<pre class="font-mono bg-inherit pre-line">{{ .SanitizedLog }}</pre>
|
||||
<div id="log-sanitized" class="flex flex-col gap-2 unfocused">
|
||||
<p class="support subheading">An attempt has been made to remove sensitive info, but make sure to check yourself.</p>
|
||||
<pre class="card font-mono bg-inherit pre-line">{{ .SanitizedLog }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<script inline src="crash.js"></script>
|
||||
<script inline src="web/js/crash.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="{{ .cssClass }}">
|
||||
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="{{ .cssClass }}">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="{{ .urlBase }}/css/{{ .cssVersion }}bundle.css">
|
||||
{{ template "header.html" . }}
|
||||
{{ template "header.txt" . }}
|
||||
<title>{{ .strings.successHeader }} - jfa-go</title>
|
||||
</head>
|
||||
<body class="section">
|
||||
<div class="page-container">
|
||||
<div class="page-container m-2 lg:my-20 lg:mx-64">
|
||||
<div class="card ~neutral @low mb-4">
|
||||
<span class="heading mb-4">{{ .strings.successHeader }}</span>
|
||||
<p class="content my-4">{{ .successMessage }}</p>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
window.usernameEnabled = {{ .username }};
|
||||
window.validationStrings = JSON.parse({{ .validationStrings }});
|
||||
window.invalidPassword = "{{ .strings.reEnterPasswordInvalid }}";
|
||||
window.URLBase = "{{ .urlBase }}";
|
||||
window.code = "{{ .code }}";
|
||||
window.language = "{{ .langName }}";
|
||||
window.messages = JSON.parse({{ .notifications }});
|
||||
@@ -14,16 +13,13 @@
|
||||
window.userExpiryHours = {{ .userExpiryHours }};
|
||||
window.userExpiryMinutes = {{ .userExpiryMinutes }};
|
||||
window.userExpiryMessage = {{ .userExpiryMessage }};
|
||||
window.telegramEnabled = {{ .telegramEnabled }};
|
||||
window.telegramRequired = {{ .telegramRequired }};
|
||||
window.telegramPIN = "{{ .telegramPIN }}";
|
||||
window.emailRequired = {{ .emailRequired }};
|
||||
window.discordEnabled = {{ .discordEnabled }};
|
||||
window.discordRequired = {{ .discordRequired }};
|
||||
window.discordPIN = "{{ .discordPIN }}";
|
||||
window.discordInviteLink = {{ .discordInviteLink }};
|
||||
window.discordServerName = "{{ .discordServerName }}";
|
||||
window.matrixEnabled = {{ .matrixEnabled }};
|
||||
window.matrixRequired = {{ .matrixRequired }};
|
||||
window.matrixUserID = "{{ .matrixUser }}";
|
||||
window.captcha = {{ .captcha }};
|
||||
@@ -31,11 +27,21 @@
|
||||
window.reCAPTCHASiteKey = "{{ .reCAPTCHASiteKey }}";
|
||||
window.userPageEnabled = {{ .userPageEnabled }};
|
||||
window.userPageAddress = "{{ .userPageAddress }}";
|
||||
window.collectEmail = {{ .collectEmail }};
|
||||
{{ if index . "customSuccessCard" }}
|
||||
window.customSuccessCard = {{ .customSuccessCard }};
|
||||
{{ else }}
|
||||
window.customSuccessCard = false;
|
||||
{{ end }}
|
||||
</script>
|
||||
{{ if .passwordReset }}
|
||||
<script src="js/pwr.js" type="module"></script>
|
||||
<script>
|
||||
window.pwrPIN = "{{ .pwrPIN }}";
|
||||
</script>
|
||||
{{ else }}
|
||||
<script src="js/form.js" type="module"></script>
|
||||
{{ end }}
|
||||
{{ if .reCAPTCHA }}
|
||||
<script>
|
||||
var reCAPTCHACallback = () => {
|
||||
@@ -49,4 +55,3 @@
|
||||
<script src="https://www.google.com/recaptcha/api.js?onload=reCAPTCHACallback&render=explicit" async defer></script>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="{{ .cssClass }}">
|
||||
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="{{ .cssClass }}">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="css/{{ .cssVersion }}bundle.css">
|
||||
{{ template "header.html" . }}
|
||||
{{ template "header.txt" . }}
|
||||
{{ if .passwordReset }}
|
||||
<title>{{ .strings.passwordReset }}</title>
|
||||
{{ else }}
|
||||
@@ -14,12 +13,19 @@
|
||||
</head>
|
||||
<body class="max-w-full overflow-x-hidden section">
|
||||
<div id="modal-success" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
||||
<span class="heading mb-4">{{ if .passwordReset }}{{ .strings.passwordReset }}{{ else }}{{ .strings.successHeader }}{{ end }}</span>
|
||||
<p class="content mb-4">{{ if .passwordReset }}{{ .strings.youCanLoginPassword }}{{ else }}{{ .successMessage }}{{ end }}</p>
|
||||
{{ if .userPageEnabled }}<p class="content mb-4" id="modal-success-user-page-area" my-account-term="{{ .strings.myAccount }}">{{ .strings.userPageSuccessMessage }}</p>{{ end }}
|
||||
<a class="button ~urge @low full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a>
|
||||
</div>
|
||||
{{ if .customSuccessCard }}
|
||||
<div class="card @low dark:~d_neutral content break-words relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
||||
{{ .customSuccessCardContent }}
|
||||
<a class="button ~urge @low full-width center supra submit my-2" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
||||
<span class="heading mb-4">{{ if .passwordReset }}{{ .strings.passwordReset }}{{ else }}{{ .strings.successHeader }}{{ end }}</span>
|
||||
<p class="content mb-4">{{ if .passwordReset }}{{ .strings.youCanLoginPassword }}{{ else }}{{ .successMessage }}{{ end }}</p>
|
||||
{{ if .userPageEnabled }}<p class="content mb-4" id="modal-success-user-page-area" my-account-term="{{ .strings.myAccount }}">{{ .strings.userPageSuccessMessage }}</p>{{ end }}
|
||||
<a class="button ~urge @low full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div id="modal-confirmation" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
||||
@@ -28,23 +34,14 @@
|
||||
</div>
|
||||
</div>
|
||||
{{ template "account-linking.html" . }}
|
||||
<div class="top-4 left-4 absolute">
|
||||
<span class="dropdown" tabindex="0" id="lang-dropdown">
|
||||
<span class="button ~urge dropdown-button">
|
||||
<i class="ri-global-line"></i>
|
||||
<span class="ml-2 chev"></span>
|
||||
</span>
|
||||
<div class="dropdown-display">
|
||||
<div class="card ~neutral @low" id="lang-list">
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div id="notification-box"></div>
|
||||
<div class="page-container">
|
||||
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4">
|
||||
<div class="top-2 inset-x-2 lg:absolute flex flex-row justify-between">
|
||||
{{ template "lang-select.html" . }}
|
||||
</div>
|
||||
<div class="card dark:~d_neutral @low">
|
||||
<div class="flex flex-col md:flex-row gap-3 inline align-baseline">
|
||||
<span class="heading mr-5">
|
||||
<div class="flex flex-col md:flex-row gap-3 items-baseline mb-2">
|
||||
<span class="heading">
|
||||
{{ if .passwordReset }}
|
||||
{{ .strings.passwordReset }}
|
||||
{{ else }}
|
||||
@@ -53,9 +50,9 @@
|
||||
</span>
|
||||
<span class="subheading">
|
||||
{{ if .passwordReset }}
|
||||
{{ .strings.enterYourPassword }}
|
||||
{{ .strings.enterYourPassword }}
|
||||
{{ else }}
|
||||
{{ .helpMessage }}
|
||||
{{ .helpMessage }}
|
||||
{{ end }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -71,8 +68,10 @@
|
||||
<input type="text" class="input ~neutral @high mt-2 mb-4" placeholder="{{ .strings.username }}" id="create-username" aria-label="{{ .strings.username }}">
|
||||
</label>
|
||||
|
||||
<label class="label supra" for="create-email">{{ .strings.emailAddress }}</label>
|
||||
<input type="email" class="input ~neutral @high mt-2 mb-4" placeholder="{{ .strings.emailAddress }}" id="create-email" aria-label="{{ .strings.emailAddress }}" value="{{ .email }}">
|
||||
<div>
|
||||
<label class="label supra" for="create-email">{{ .strings.emailAddress }}</label>
|
||||
<input type="email" class="input ~neutral @high mt-2 mb-4" placeholder="{{ .strings.emailAddress }}" id="create-email" aria-label="{{ .strings.emailAddress }}" value="{{ .email }}">
|
||||
</div>
|
||||
{{ if .telegramEnabled }}
|
||||
<span class="button ~info @low full-width center mb-4" id="link-telegram">{{ .strings.linkTelegram }} {{ if .telegramRequired }}({{ .strings.required }}){{ end }}</span>
|
||||
{{ end }}
|
||||
@@ -83,23 +82,23 @@
|
||||
<span class="button ~info @low full-width center mb-4" id="link-matrix">{{ .strings.linkMatrix }} {{ if .matrixRequired }}({{ .strings.required }}){{ end }}</span>
|
||||
{{ end }}
|
||||
{{ if or (.telegramEnabled) (or .discordEnabled .matrixEnabled) }}
|
||||
<div id="contact-via" class="unfocused">
|
||||
<label class="row switch pb-4 unfocused">
|
||||
<input type="radio" name="contact-via" value="email" id="contact-via-email" class="mr-2"><span>Contact through Email</span>
|
||||
<div id="contact-via" class="unfocused flex flex-col gap-2">
|
||||
<label class="flex flex-row gap-2 switch unfocused">
|
||||
<input type="checkbox" name="contact-via" value="email" id="contact-via-email"><span>Contact through Email</span>
|
||||
</label>
|
||||
{{ if .telegramEnabled }}
|
||||
<label class="row switch pb-4 unfocused">
|
||||
<input type="radio" name="contact-via" value="telegram" id="contact-via-telegram" class="mr-2"><span>Contact through Telegram</span>
|
||||
<label class="flex flex-row gap-2 switch unfocused">
|
||||
<input type="checkbox" name="contact-via" value="telegram" id="contact-via-telegram"><span>Contact through Telegram</span>
|
||||
</label>
|
||||
{{ end }}
|
||||
{{ if .discordEnabled }}
|
||||
<label class="row switch pb-4 unfocused">
|
||||
<input type="radio" name="contact-via" value="discord" id="contact-via-discord" class="mr-2"><span>Contact through Discord</span>
|
||||
<label class="flex flex-row gap-2 switch unfocused">
|
||||
<input type="checkbox" name="contact-via" value="discord" id="contact-via-discord"><span>Contact through Discord</span>
|
||||
</label>
|
||||
{{ end }}
|
||||
{{ if .matrixEnabled }}
|
||||
<label class="row switch pb-4 unfocused">
|
||||
<input type="radio" name="contact-via" value="matrix" id="contact-via-matrix" class="mr-2"><span>Contact through Matrix</span>
|
||||
<label class="flex flex-row gap-2 switch unfocused">
|
||||
<input type="checkbox" name="contact-via" value="matrix" id="contact-via-matrix"><span>Contact through Matrix</span>
|
||||
</label>
|
||||
{{ end }}
|
||||
</div>
|
||||
@@ -123,6 +122,14 @@
|
||||
</form>
|
||||
</div>
|
||||
<div class="flex-initial">
|
||||
{{ if .fromUser }}
|
||||
<aside class="col aside sm ~positive mb-4" id="invite-from-user" data-from="{{ .fromUser }}">{{ .strings.invitedBy }}</aside>
|
||||
{{ end }}
|
||||
{{ if .preSignupCard }}
|
||||
<div class="card @low dark:~d_neutral break-words content">
|
||||
{{ .preSignupCardContent }}
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="card ~neutral @low mb-4">
|
||||
<span class="label supra">{{ .strings.passwordRequirementsHeader }}</span>
|
||||
<ul>
|
||||
@@ -134,16 +141,24 @@
|
||||
</ul>
|
||||
</div>
|
||||
{{ if .captcha }}
|
||||
<div class="card ~neutral @low mb-4">
|
||||
<span class="label supra mb-2">CAPTCHA {{ if not .reCAPTCHA }}<span id="captcha-regen" title="{{ .strings.refresh }}" class="badge lg @low ~info ml-2 float-right"><i class="ri-refresh-line"></i></span><span id="captcha-success" class="badge lg @low ~critical ml-2 float-right"><i class="ri-close-line"></i></span>{{ end }}</span>
|
||||
<div id="captcha-img" class="mt-2 mb-2 {{ if .reCAPTCHA }}g-recaptcha{{ end }}"></div>
|
||||
<div class="card ~neutral @low mb-4 flex flex-col gap-2">
|
||||
<div class="flex flex-row justify-between gap-2">
|
||||
<span class="label supra">CAPTCHA</span>
|
||||
{{ if not .reCAPTCHA }}
|
||||
<div class="flex flex-row gap-2">
|
||||
<button id="captcha-regen" aria-label="{{ .strings.refresh }}" title="{{ .strings.refresh }}" class="badge lg @low ~info"><i class="ri-refresh-line"></i></button>
|
||||
<span id="captcha-success" class="badge lg @low ~critical"><i class="ri-close-line"></i></span>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div id="captcha-img" class="{{ if .reCAPTCHA }}g-recaptcha{{ end }}"></div>
|
||||
{{ if not .reCAPTCHA }}
|
||||
<input class="field ~neutral @low" id="captcha-input" class="mt-2" placeholder="CAPTCHA">
|
||||
<input class="field ~neutral @low" id="captcha-input" placeholder="CAPTCHA">
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .contactMessage }}
|
||||
<aside class="col aside sm ~info mt-4">{{ .contactMessage }}</aside>
|
||||
<aside class="col aside sm ~info mt-4">{{ .contactMessage }}</aside>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="Description" content="jfa-go, a better way to manage Jellyfin users.">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ .urlBase }}/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ .urlBase }}/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ .urlBase }}/favicon-16x16.png">
|
||||
<link rel="manifest" href="{{ .urlBase }}/site.webmanifest">
|
||||
<link rel="mask-icon" href="{{ .urlBase }}/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#603cba">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
36
html/header.txt
Normal file
@@ -0,0 +1,36 @@
|
||||
<link rel="stylesheet" type="text/css" href="{{ .pages.Base }}/css/{{ .cssVersion }}bundle.css">
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="Description" content="jfa-go, a better way to manage Jellyfin users.">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#101010" media="(prefers-color-scheme: dark)">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<meta name="robots" content="noindex">
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ .pages.Base }}/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ .pages.Base }}/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ .pages.Base }}/favicon-16x16.png">
|
||||
<link rel="manifest" href="{{ .pages.Base }}/site.webmanifest">
|
||||
<link rel="mask-icon" href="{{ .pages.Base }}/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#603cba">
|
||||
<script>
|
||||
window.pages = {
|
||||
"Base": "{{ .pages.Base }}",
|
||||
"TrueBase": "{{ .pages.TrueBase }}",
|
||||
"ExternalURI": "{{ .pages.ExternalURI }}",
|
||||
"Current": "{{ .pages.Current }}",
|
||||
"Admin": "{{ .pages.Admin }}",
|
||||
"MyAccount": "{{ .pages.MyAccount }}",
|
||||
"Form": "{{ .pages.Form }}"
|
||||
};
|
||||
window.emailEnabled = {{ .emailEnabled }};
|
||||
window.discordEnabled = {{ .discordEnabled }};
|
||||
window.telegramEnabled = {{ .telegramEnabled }};
|
||||
window.matrixEnabled = {{ .matrixEnabled }};
|
||||
window.notificationsEnabled = {{ .notifications }};
|
||||
window.ombiEnabled = {{ .ombiEnabled }};
|
||||
window.jellyseerrEnabled = {{ .jellyseerrEnabled }};
|
||||
window.referralsEnabled = {{ .referralsEnabled }};
|
||||
window.pwrEnabled = {{ .pwrEnabled }};
|
||||
</script>
|
||||
@@ -1,12 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="{{ .cssClass }}">
|
||||
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="{{ .cssClass }}">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="{{ .urlBase }}/css/{{ .cssVersion }}bundle.css">
|
||||
{{ template "header.html" . }}
|
||||
{{ template "header.txt" . }}
|
||||
<title>Invalid Code - jfa-go</title>
|
||||
</head>
|
||||
<body class="section">
|
||||
<div class="page-container">
|
||||
<div class="page-container m-2 lg:my-20 lg:mx-64">
|
||||
<div class="card">
|
||||
<h1 class="text-3xl font-semibold">Invalid invite code.</h1>
|
||||
<p class="content">The code above was either incorrect, or has expired.</p>
|
||||
|
||||
19
html/lang-select.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<span class="dropdown z-[11]" tabindex="0" id="lang-dropdown">
|
||||
<span class="button ~urge dropdown-button flex flex-row gap-2 h-full" title="{{ .strings.language }}" aria-label="{{ .strings.language }}">
|
||||
<i class="icon ri-global-line"></i>
|
||||
<i class="icon ri-arrow-down-s-line"></i>
|
||||
</span>
|
||||
<div class="dropdown-display">
|
||||
<div class="card ~neutral @low flex flex-col gap-2">
|
||||
<label class="switch">
|
||||
<input type="radio" name="lang-time" id="lang-12h">
|
||||
<span>{{ .strings.time12h }}</span>
|
||||
</label>
|
||||
<label class="switch">
|
||||
<input type="radio" name="lang-time" id="lang-24h">
|
||||
<span>{{ .strings.time24h }}</span>
|
||||
</label>
|
||||
<div id="lang-list" class="flex flex-col gap-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
@@ -1,25 +1,37 @@
|
||||
<span class="lg:w-[55%]"></span> <!-- the if statement around the 55% width below messes up tailwind, so we force include it here --!>
|
||||
<div id="modal-login" class="modal">
|
||||
<div class="my-[10%] row items-stretch relative mx-auto w-[40%] lg:w-[60%]">
|
||||
<div class="my-[10%] row items-stretch relative mx-auto w-11/12 sm:w-4/5 lg:w-1/2">
|
||||
{{ $hasTwoCards := 0 }}
|
||||
{{ if index . "LoginMessageEnabled" }}
|
||||
{{ if .LoginMessageEnabled }}
|
||||
<div class="card mx-2 flex-initial w-[100%] xl:w-[35%] mb-4 xl:mb-0 dark:~d_neutral @low content">
|
||||
{{ $hasTwoCards = 1 }}
|
||||
<div class="card mx-2 flex-initial w-full lg:w-[35%] mb-4 lg:mb-0 dark:~d_neutral @low content">
|
||||
{{ .LoginMessageContent }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<form class="card mx-2 flex-auto form-login w-[100%] xl:w-[55%] mb-0" href="">
|
||||
{{ if index . "userPageEnabled" }}
|
||||
{{ if and .userPageEnabled .showUserPageLink }}
|
||||
{{ $hasTwoCards = 1 }}
|
||||
<div class="card mx-2 flex-initial w-full lg:w-[35%] mb-4 lg:mb-0 dark:~d_neutral @low content">
|
||||
<span class="heading row">{{ .strings.loginNotAdmin }}</span>
|
||||
<a class="button ~info h-12 w-full flex flex-row gap-2" href="{{ .pages.Base }}{{ .pages.MyAccount }}"><i class="ri-account-circle-fill"></i>{{ .strings.myAccount }}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<form class="card mx-2 form-login w-full flex flex-col gap-2 {{ if eq $hasTwoCards 1 }}lg:w-[55%]{{ end }} mb-0" href="">
|
||||
<span class="heading">{{ .strings.login }}</span>
|
||||
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.username }}" id="login-user">
|
||||
<input type="password" class="field input ~neutral @high mb-4" placeholder="{{ .strings.password }}" id="login-password">
|
||||
<input type="text" class="field input ~neutral @high" placeholder="{{ .strings.username }}" id="login-user">
|
||||
<input type="password" class="field input ~neutral @high" placeholder="{{ .strings.password }}" id="login-password">
|
||||
<label>
|
||||
<input type="submit" class="unfocused">
|
||||
<span class="button ~urge @low full-width center supra submit">{{ .strings.login }}</span>
|
||||
{{ if index . "pwrEnabled" }}
|
||||
{{ if .pwrEnabled }}
|
||||
<span class="button ~info @low full-width center supra submit my-2" id="modal-login-pwr">{{ .strings.resetPassword }}</span>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</label>
|
||||
{{ if index . "pwrEnabled" }}
|
||||
{{ if .pwrEnabled }}
|
||||
<span class="button ~info @low full-width center supra" id="modal-login-pwr">{{ .strings.resetPassword }}</span>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="{{ .cssClass }}">
|
||||
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="{{ .cssClass }}">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="css/{{ .cssVersion }}bundle.css">
|
||||
{{ template "header.html" . }}
|
||||
{{ template "header.txt" . }}
|
||||
<title>{{ .strings.passwordReset }} - jfa-go</title>
|
||||
</head>
|
||||
<body class="section">
|
||||
@@ -11,7 +10,7 @@
|
||||
<span id="copy-notification" class="unfocused">{{ .strings.copied }}</span>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="page-container">
|
||||
<div class="page-container m-2 lg:my-20 lg:mx-64">
|
||||
<div class="card ~neutral @low mb-4">
|
||||
<span class="heading mb-4">
|
||||
{{ if .success }}
|
||||
@@ -35,11 +34,11 @@
|
||||
<aside class="aside ~warning">
|
||||
{{ .strings.changeYourPassword }}
|
||||
</aside>
|
||||
<span class="button ~urge @low w-100 text-center text-xl p-1 mt-4" id="pin" title="{{ .strings.copy }}">{{ .pin }}</span>
|
||||
<span class="button ~urge @low w-full text-center text-xl p-1 mt-4" id="pin" title="{{ .strings.copy }}">{{ .pin }}</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
<i class="content">{{ .contactMessage }}</i>
|
||||
</div>
|
||||
<script src="{{ .urlBase }}/js/pwr-pin.js" type="module"></script>
|
||||
<script src="{{ .pages.Base }}/js/pwr-pin.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
872
html/setup.html
@@ -1,270 +1,340 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="light">
|
||||
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="light">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="css/{{ .cssVersion }}bundle.css">
|
||||
{{ template "header.html" . }}
|
||||
{{ template "header.txt" . }}
|
||||
<title>{{ .lang.Strings.pageTitle }}</title>
|
||||
</head>
|
||||
<body class="max-w-full overflow-x-hidden section">
|
||||
<div id="notification-box"></div>
|
||||
<div class="top-4 left-4 absolute">
|
||||
<span class="dropdown" tabindex="0" id="lang-dropdown">
|
||||
<span class="button ~urge dropdown-button">
|
||||
<i class="ri-global-line"></i>
|
||||
<span class="ml-2 chev"></span>
|
||||
</span>
|
||||
<div class="dropdown-display">
|
||||
<div class="card ~neutral @low" id="lang-list">
|
||||
</div>
|
||||
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4">
|
||||
<div class="top-2 inset-x-2 lg:absolute flex flex-row justify-between">
|
||||
<div class="flex flex-row gap-2">
|
||||
{{ template "lang-select.html" . }}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div class="page-container" id="page-container">
|
||||
<div class="card ~neutral @low mb-2">
|
||||
<div class="row">
|
||||
<img class="banner header" src="banner.svg" alt="jfa-go" />
|
||||
</div>
|
||||
<div class="row col flex center">
|
||||
<span class="heading welcome">{{ .lang.StartPage.welcome }}</span>
|
||||
</div>
|
||||
<div class="row col flex center">
|
||||
<p class="content my-2">{{ .lang.StartPage.pressStart }}</p>
|
||||
</div>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<span class="button ~warning" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
|
||||
</div>
|
||||
<div class="card lg:container sectioned ~neutral @low flex flex-col gap-4 justify-between items-center">
|
||||
<img class="w-[105%] max-w-none" src="banner.svg" alt="jfa-go" />
|
||||
<span class="heading welcome">{{ .lang.StartPage.welcome }}</span>
|
||||
<p class="content text-center">{{ .lang.StartPage.pressStart }}</p>
|
||||
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
|
||||
<span class="support">{{ .lang.StartPage.httpsNotice }}</span>
|
||||
<span class="button ~urge @low next">{{ .lang.StartPage.start }}</span>
|
||||
</section>
|
||||
</div>
|
||||
<div class="card ~neutral @low mb-2 unfocused">
|
||||
<span class="heading">{{ .lang.Language.title }}</span>
|
||||
<p class="content my-2" id="language-description"></p>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Language.defaultAdminLang }}</span>
|
||||
<div class="select ~neutral @low mt-4 mb-2">
|
||||
<select id="ui-language-admin">
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Language.defaultFormLang }}</span>
|
||||
<div class="select ~neutral @low mt-4 mb-2">
|
||||
<select id="ui-language-form">
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Language.defaultEmailLang }}</span>
|
||||
<div class="select ~neutral @low mt-4 mb-2">
|
||||
<select id="email-language">
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<div class="card lg:container sectioned ~neutral @low unfocused">
|
||||
<section class="section flex flex-col gap-2 justify-between">
|
||||
<span class="heading">{{ .lang.Language.title }}</span>
|
||||
<p class="content" id="language-description"></p>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Language.defaultAdminLang }}</span>
|
||||
<div class="select ~neutral @low">
|
||||
<select id="ui-language-admin">
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Language.defaultFormLang }}</span>
|
||||
<div class="select ~neutral @low">
|
||||
<select id="ui-language-form">
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Language.defaultEmailLang }}</span>
|
||||
<div class="select ~neutral @low">
|
||||
<select id="email-language">
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
</section>
|
||||
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
</section>
|
||||
</div>
|
||||
<div class="card ~neutral @low mb-2 unfocused">
|
||||
<span class="heading">{{ .lang.General.title }}</span>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.General.listenAddress }}</span>
|
||||
<input type="url" class="input ~neutral @low mt-4 mb-2" id="ui-host" value="0.0.0.0">
|
||||
</label>
|
||||
<label class="row switch">
|
||||
<input type="checkbox" class="mr-2" id="advanced-tls"><span>{{ .lang.General.useHTTPS }}</span>
|
||||
</label>
|
||||
<p class="support mb-2 mt-1">{{ .lang.General.useHTTPSNotice }}</p>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.General.pathToCertificate }}</span>
|
||||
<input type="text" class="input ~neutral @low mt-4 mb-2" id="advanced-tls_cert">
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.General.pathToKeyFile }}</span>
|
||||
<input type="text" class="input ~neutral @low mt-4 mb-2" id="advanced-tls_key">
|
||||
</label>
|
||||
<span class="heading">{{ .lang.Updates.title }}</span>
|
||||
<p class="content my-2" id="updates-description"></p>
|
||||
<label class="row switch pb-4">
|
||||
<input type="checkbox" class="mr-2" id="updates-enabled" checked><span>{{ .lang.Strings.enabled }}</span>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span>{{ .lang.Updates.updateChannel }}</span>
|
||||
<div class="select ~neutral @low mt-4 mb-2">
|
||||
<select id="updates-channel">
|
||||
<option value="stable">{{ .lang.Updates.stable }}</option>
|
||||
<option value="unstable">{{ .lang.Updates.unstable }}</option>
|
||||
</select>
|
||||
<div class="card lg:container sectioned ~neutral @low unfocused">
|
||||
<section class="section flex flex-col gap-2 justify-between">
|
||||
<span class="heading">{{ .lang.General.title }}</span>
|
||||
<div class="flex flex-row gap-2 justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-2 justify-between">
|
||||
<label class="label flex flex-col gap-2 grow">
|
||||
<span>{{ .lang.General.listenAddress }}</span>
|
||||
<input type="url" class="input ~neutral @low" id="ui-host" value="0.0.0.0">
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.port }}</span>
|
||||
<input type="number" class="input ~neutral @low" id="ui-port" value="8056">
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="checkbox" id="advanced-tls"><span>{{ .lang.General.useHTTPS }}</span></div>
|
||||
<p class="support">{{ .lang.General.useHTTPSNotice }}</p>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.General.pathToCertificate }}</span>
|
||||
<input type="text" class="input ~neutral @low" id="advanced-tls_cert">
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.General.pathToKeyFile }}</span>
|
||||
<input type="text" class="input ~neutral @low" id="advanced-tls_key">
|
||||
</label>
|
||||
<span class="heading">{{ .lang.Updates.title }}</span>
|
||||
<p class="content" id="updates-description"></p>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="checkbox" id="updates-enabled" checked><span>{{ .lang.Strings.enabled }}</span></div>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Updates.updateChannel }}</span>
|
||||
<div class="select ~neutral @low">
|
||||
<select id="updates-channel">
|
||||
<option value="stable">{{ .lang.Updates.stable }}</option>
|
||||
<option value="unstable">{{ .lang.Updates.unstable }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.General.httpsPort }}</span>
|
||||
<input type="number" class="input ~neutral @low" id="advanced-tls_port" value="8057">
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.General.urlBase }} ({{ .lang.Strings.optional }})</span>
|
||||
<input type="text" class="input ~neutral @low" id="ui-url_base" placeholder="/mysubfolder">
|
||||
<p class="support">{{ .lang.General.urlBaseNotice }}</p>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.General.externalURL }} ({{ .lang.Strings.required }})</span>
|
||||
<input type="text" class="input ~neutral @low" id="ui-jfa_url" placeholder="https://jellyf.in/mysubfolder">
|
||||
<p class="support">{{ .lang.General.externalURLNotice }}</p>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.theme }}</span>
|
||||
<div class="select ~neutral @low">
|
||||
<select id="ui-theme">
|
||||
<option value="Jellyfin (Dark)">{{ .lang.General.darkTheme }}</option>
|
||||
<option value="Default (Light)">{{ .lang.General.lightTheme }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<span class="heading">{{ .lang.Proxy.title }}</span>
|
||||
<p class="content" id="proxy-description">{{ .lang.Proxy.description }}</p>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="checkbox" id="advanced-proxy"><span>{{ .lang.Strings.enabled }}</span></div>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Proxy.protocol }}</span>
|
||||
<div class="select ~neutral @low">
|
||||
<select id="advanced-proxy_protocol">
|
||||
<option value="http">HTTP</option>
|
||||
<option value="socks">SOCKS5</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Proxy.address }}</span>
|
||||
<input type="text" class="input ~neutral @low" id="advanced-proxy_address">
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.username }}</span>
|
||||
<input type="text" class="input ~neutral @low" id="advanced-proxy_user">
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.password }}</span>
|
||||
<input type="text" class="input ~neutral @low" id="advanced-proxy_password">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Strings.port }}</span>
|
||||
<input type="number" class="input ~neutral @low mt-4 mb-2" id="ui-port" value="8056">
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.General.httpsPort }}</span>
|
||||
<input type="number" class="input ~neutral @low mt-4 mb-2" id="advanced-tls_port" value="8057">
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.General.urlBase }} ({{ .lang.Strings.optional }})</span>
|
||||
<input type="url" class="input ~neutral @low mt-4" id="ui-url_base">
|
||||
<p class="support mb-2 mt-1">{{ .lang.General.urlBaseNotice }}</p>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span>{{ .lang.Strings.theme }}</span>
|
||||
<div class="select ~neutral @low mt-4 mb-2">
|
||||
<select id="ui-theme">
|
||||
<option value="Jellyfin (Dark)">{{ .lang.General.darkTheme }}</option>
|
||||
<option value="Default (Light)">{{ .lang.General.lightTheme }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
</section>
|
||||
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
</section>
|
||||
</div>
|
||||
<div class="card ~neutral @low mb-2 unfocused">
|
||||
<span class="heading">{{ .lang.Login.title }}</span>
|
||||
<p class="content my-2">{{ .lang.Login.description }}</p>
|
||||
<div class="pl-4">
|
||||
<label class="row switch pb-4">
|
||||
<input type="radio" class="mr-2" name="ui-jellyfin_login" value="true" checked><span>{{ .lang.Login.authorizeWithJellyfin }}</span>
|
||||
</label>
|
||||
<label class="row switch pl-4 pb-4">
|
||||
<input type="checkbox" class="mr-2" id="ui-admin_only" checked><span>{{ .lang.Login.adminOnly }}</span>
|
||||
</label>
|
||||
<label class="row switch pl-4 pb-2">
|
||||
<input type="checkbox" class="mr-2" id="ui-allow_all"><span>{{ .lang.Login.allowAll }}</span>
|
||||
</label>
|
||||
<p class="support pb-4 pl-4 mt-1" id="description-ui-allow_all">{{ .lang.Login.allowAllDescription }}</p>
|
||||
<label class="row switch pb-4">
|
||||
<input type="radio" class="mr-2" name="ui-jellyfin_login" value="false"><span>{{ .lang.Login.authorizeManual }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="login-manual">
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Strings.username }}</span>
|
||||
<input type="text" id="ui-username" class="input ~neutral @low mt-4 mb-2" placeholder="{{ .lang.Strings.username }}">
|
||||
</label>
|
||||
<label class="label">
|
||||
<span>{{ .lang.Strings.password }}</span>
|
||||
<input type="password" id="ui-password" class="input ~neutral @low mt-4 mb-2" placeholder="{{ .lang.Strings.password }}">
|
||||
</label>
|
||||
<label class="label">
|
||||
<span>{{ .lang.Strings.emailAddress }} ({{ .lang.Strings.optional }})</span>
|
||||
<input type="email" id="ui-email" class="input ~neutral @low mt-4" placeholder="email@address">
|
||||
<span class="support mb-2 mt-1">{{ .lang.Login.emailNotice }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
</section>
|
||||
</div>
|
||||
<div class="card ~neutral @low mb-2 unfocused">
|
||||
<span class="heading">{{ .lang.JellyfinEmby.title }}</span>
|
||||
<p class="content my-2">{{ .lang.JellyfinEmby.description }}</p>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label class="label">
|
||||
<span>{{ .lang.Strings.serverType }}</span>
|
||||
<div class="select ~neutral @low mt-4">
|
||||
<select id="jellyfin-type">
|
||||
<option value="jellyfin">Jellyfin</option>
|
||||
<option value="emby">Emby</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="support mb-2 mt-1">{{ .lang.JellyfinEmby.embyNotice }}</p>
|
||||
<div class="card lg:container sectioned ~neutral @low unfocused">
|
||||
<section class="section flex flex-col gap-2 justify-between">
|
||||
<span class="heading">{{ .lang.Login.title }}</span>
|
||||
<p class="content">{{ .lang.Login.description }}</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="radio" name="ui-jellyfin_login" value="true" checked><span>{{ .lang.Login.authorizeWithJellyfin }}</span></div>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.JellyfinEmby.replaceJellyfin }} ({{ .lang.Strings.optional }})</span>
|
||||
<input type="text" class="input ~neutral @low mt-4" id="jellyfin-substitute_jellyfin_strings">
|
||||
<p class="support mb-2 mt-1">{{ .lang.JellyfinEmby.replaceJellyfinNotice }}</p>
|
||||
<div class="pl-4 flex flex-col gap-2">
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="checkbox" id="ui-admin_only" checked><span>{{ .lang.Login.adminOnly }}</span></div>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="checkbox" id="ui-allow_all"><span>{{ .lang.Login.allowAll }}</span></div>
|
||||
<p class="support" id="description-ui-allow_all">{{ .lang.Login.allowAllDescription }}</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="radio" name="ui-jellyfin_login" value="false"><span>{{ .lang.Login.authorizeManual }}</span></div>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Strings.username }}</span>
|
||||
<input type="text" id="jellyfin-username" class="input ~neutral @low mt-4 mb-2" placeholder="{{ .lang.Strings.username }}">
|
||||
<p class="support">{{ .lang.Login.authorizeManualUserPageNotice }}</p>
|
||||
</div>
|
||||
<div class ="flex flex-col gap-2" id="login-manual">
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.username }}</span>
|
||||
<input type="text" id="ui-username" class="input ~neutral @low" placeholder="{{ .lang.Strings.username }}">
|
||||
</label>
|
||||
<label class="label">
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.password }}</span>
|
||||
<input type="password" id="jellyfin-password" class="input ~neutral @low mt-4 mb-2" placeholder="{{ .lang.Strings.password }}">
|
||||
<input type="password" id="ui-password" class="input ~neutral @low" placeholder="{{ .lang.Strings.password }}">
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.emailAddress }} ({{ .lang.Strings.optional }})</span>
|
||||
<input type="email" id="ui-email" class="input ~neutral @low" placeholder="email@address">
|
||||
<span class="support">{{ .lang.Login.emailNotice }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Strings.serverAddress }} ({{ .lang.JellyfinEmby.internal }})</span>
|
||||
<input type="url" class="input ~neutral @low mt-4 mb-2" id="jellyfin-server" placeholder="http://jellyf.in:80">
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Strings.serverAddress }} ({{ .lang.JellyfinEmby.external }})</span>
|
||||
<input type="url" class="input ~neutral @low mt-4" id="jellyfin-public_server" placeholder="https://jellyf.in">
|
||||
<p class="support mb-2 mt-1">{{ .lang.JellyfinEmby.addressExternalNotice }}</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
</section>
|
||||
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
</section>
|
||||
</div>
|
||||
<div class="card lg:container sectioned ~neutral @low unfocused">
|
||||
<section class="section flex flex-col gap-2 justify-between">
|
||||
<span class="heading">{{ .lang.JellyfinEmby.title }}</span>
|
||||
<p class="content">{{ .lang.JellyfinEmby.description }}</p>
|
||||
<div class="flex flex-row gap-2 justify-between">
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.serverType }}</span>
|
||||
<div class="select ~neutral @low">
|
||||
<select id="jellyfin-type">
|
||||
<option value="jellyfin">Jellyfin</option>
|
||||
<option value="emby">Emby</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="support">{{ .lang.JellyfinEmby.embyNotice }}</p>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.JellyfinEmby.replaceJellyfin }} ({{ .lang.Strings.optional }})</span>
|
||||
<input type="text" class="input ~neutral @low" id="jellyfin-substitute_jellyfin_strings">
|
||||
<p class="support">{{ .lang.JellyfinEmby.replaceJellyfinNotice }}</p>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.username }}</span>
|
||||
<input type="text" id="jellyfin-username" class="input ~neutral @low" placeholder="{{ .lang.Strings.username }}">
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.password }}</span>
|
||||
<input type="password" id="jellyfin-password" class="input ~neutral @low" placeholder="{{ .lang.Strings.password }}">
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 grow ">
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.serverAddress }} ({{ .lang.JellyfinEmby.internal }})</span>
|
||||
<input type="url" class="input ~neutral @low" id="jellyfin-server" placeholder="http://jellyf.in:80">
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.serverAddress }} ({{ .lang.JellyfinEmby.external }})</span>
|
||||
<input type="url" class="input ~neutral @low" id="jellyfin-public_server" placeholder="https://jellyf.in">
|
||||
<p class="support">{{ .lang.JellyfinEmby.addressExternalNotice }}</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div class="flex flex-row gap-2">
|
||||
<span class="button ~urge @low" id="jellyfin-test-connection">{{ .lang.JellyfinEmby.testConnection }}</span>
|
||||
<span class="button ~urge @low next" disabled>{{ .lang.Strings.next }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="card ~neutral @low mb-2 unfocused">
|
||||
<span class="heading">{{ .lang.Ombi.title }}</span>
|
||||
<p class="content my-2">{{ .lang.Ombi.description }}</p>
|
||||
<label class="row switch pb-4">
|
||||
<input type="checkbox" class="mr-2" id="ombi-enabled"><span>{{ .lang.Strings.enabled }}</span>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Strings.serverAddress }}</span>
|
||||
<input type="url" class="input ~neutral @low mt-4 mb-2" id="ombi-server" placeholder="ombi.jellyf.in">
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Strings.apiKey }}</span>
|
||||
<input type="text" class="input ~neutral @low mt-4" id="ombi-api_key">
|
||||
<p class="support mb-2 mt-1">{{ .lang.Ombi.apiKeyNotice }}</p>
|
||||
</label>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<div class="card lg:container sectioned ~neutral @low unfocused">
|
||||
<section class="section flex flex-col gap-2 justify-between">
|
||||
<span class="heading">{{ .lang.Ombi.title }}</span>
|
||||
<p class="content">{{ .lang.Ombi.description }}</p>
|
||||
<aside class="aside ~warning" id="ombi-stability-warning">{{ .lang.Ombi.stabilityWarning }}</aside>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="checkbox" id="ombi-enabled"><span>{{ .lang.Strings.enabled }}</span></div>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.serverAddress }}</span>
|
||||
<input type="url" class="input ~neutral @low" id="ombi-server" placeholder="ombi.jellyf.in">
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.apiKey }}</span>
|
||||
<input type="text" class="input ~neutral @low" id="ombi-api_key">
|
||||
<p class="support">{{ .lang.Ombi.apiKeyNotice }}</p>
|
||||
</label>
|
||||
<span class="heading">{{ .lang.Jellyseerr.title }}</span>
|
||||
<p class="content">{{ .lang.Jellyseerr.description }}</p>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="checkbox" id="jellyseerr-enabled"><span>{{ .lang.Strings.enabled }}</span></div>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.serverAddress }}</span>
|
||||
<input type="url" class="input ~neutral @low" id="jellyseerr-server" placeholder="https://jellyseerr.jellyf.in:5055">
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.apiKey }}</span>
|
||||
<input type="text" class="input ~neutral @low" id="jellyseerr-api_key">
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="checkbox" id="jellyseerr-import_existing" checked><span>{{ .lang.Jellyseerr.importExisting }}</span></div>
|
||||
<p class="support">{{ .lang.Jellyseerr.importExistingDescription }}</p>
|
||||
</label>
|
||||
</section>
|
||||
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="card ~neutral @low mb-2 unfocused">
|
||||
<span class="heading">{{ .lang.Messages.title }}</span>
|
||||
<p class="content my-2" id="messages-description"></p>
|
||||
<label class="row switch pb-4">
|
||||
<input type="checkbox" class="mr-2" id="messages-enabled" checked><span>{{ .lang.Strings.enabled }}</span>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Email.dateFormat }}</span>
|
||||
<input type="text" class="input ~neutral @low mt-4" id="email-date_format" value="%d/%m/%y">
|
||||
<p class="support mb-2 mt-1" id="email-dateformat-notice"></p>
|
||||
</label>
|
||||
<div>
|
||||
<label class="row switch pb-4">
|
||||
<input type="radio" class="mr-2" name="email-24h" value="true" checked><span>{{ .lang.Strings.time24h }}</span>
|
||||
<div class="card lg:container sectioned ~neutral @low unfocused">
|
||||
<section class="section flex flex-col gap-2 justify-between">
|
||||
<span class="heading">{{ .lang.UserPage.title }}</span>
|
||||
<p class="content">{{ .lang.UserPage.description }}</p>
|
||||
<p class="content">{{ .lang.UserPage.customizeMessages }}</p>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="checkbox" id="userpage-enabled"><span>{{ .lang.Strings.enabled }}</span></div>
|
||||
<p class="support">{{ .lang.UserPage.requiredSettings }}</p>
|
||||
</label>
|
||||
<label class="row switch pb-4">
|
||||
<input type="radio" class="mr-2" name="email-24h" value="false"><span>{{ .lang.Strings.time12h }}</span>
|
||||
</section>
|
||||
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="card lg:container sectioned ~neutral @low unfocused">
|
||||
<section class="section flex flex-col gap-2 justify-between">
|
||||
<span class="heading">{{ .lang.Messages.title }}</span>
|
||||
<p class="content" id="messages-description"></p>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="checkbox" id="messages-enabled" checked><span>{{ .lang.Strings.enabled }}</span></div>
|
||||
</label>
|
||||
</div>
|
||||
<div id="email-sect">
|
||||
<span class="heading">{{ .lang.Email.title }}</span>
|
||||
<p class="content my-2" id="email-description"></p>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label class="label">
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Email.dateFormat }}</span>
|
||||
<input type="text" class="input ~neutral @low" id="email-date_format" value="%d/%m/%y">
|
||||
<p class="support" id="email-dateformat-notice"></p>
|
||||
</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch flex flex-row gap-2"><input type="radio" name="email-24h" value="true" checked><span>{{ .lang.Strings.time24h }}</span></div>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch flex flex-row gap-2"><input type="radio" name="email-24h" value="false"><span>{{ .lang.Strings.time12h }}</span></div>
|
||||
</label>
|
||||
</div>
|
||||
<div id="email-sect" class="flex flex-row gap-2 justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="heading">{{ .lang.Email.title }}</span>
|
||||
<p class="content" id="email-description"></p>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Email.method }}</span>
|
||||
<div class="select ~neutral @low mt-4 mb-2">
|
||||
<div class="select ~neutral @low">
|
||||
<select id="email-method">
|
||||
<option value="">{{ .lang.Strings.disabled }}</option>
|
||||
<option value="smtp">SMTP</option>
|
||||
@@ -272,223 +342,228 @@
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label class="row switch">
|
||||
<input type="checkbox" class="mr-2" id="email-no_username"><span>{{ .lang.Email.useEmailAsUsername }}</span>
|
||||
<p class="support mb-2 mt-1">{{ .lang.Email.useEmailAsUsernameNotice }}</p>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="checkbox" id="email-no_username"><span>{{ .lang.Email.useEmailAsUsername }}</span></div>
|
||||
<p class="support">{{ .lang.Email.useEmailAsUsernameNotice }}</p>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Email.fromAddress }}</span>
|
||||
<input type="email" class="input ~neutral @low mt-4 mb-2" id="email-address" placeholder="mail@jellyf.in">
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Email.fromAddress }}</span>
|
||||
<input type="email" class="input ~neutral @low" id="email-address" placeholder="mail@jellyf.in">
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Email.senderName }}</span>
|
||||
<input type="text" class="input ~neutral @low mt-4 mb-2" id="email-from" value="Jellyfin">
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Email.senderName }}</span>
|
||||
<input type="text" class="input ~neutral @low" id="email-from" value="Jellyfin">
|
||||
</label>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div id="email-smtp">
|
||||
<p class="text-2xl font-semibold mb-2">SMTP</p>
|
||||
<label class="label">
|
||||
<span>{{ .lang.Email.encryption }}</span>
|
||||
<div class="select ~neutral @low mt-4 mb-2">
|
||||
<select id="smtp-encryption">
|
||||
<option value="starttls">STARTTLS ({{ .lang.Strings.port }} 587)</option>
|
||||
<option value="ssl_tls">SSL/TLS ({{ .lang.Strings.port }} 465)</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Strings.serverAddress }}</span>
|
||||
<input type="url" class="input ~neutral @low mt-4 mb-2" id="smtp-server" placeholder="smtp.jellyf.in">
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Strings.port }}</span>
|
||||
<input type="number" class="input ~neutral @low mt-4 mb-2" id="smtp-port" placeholder="587">
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Strings.username }}</span>
|
||||
<input type="text" class="input ~neutral @low mt-4 mb-2" id="smtp-username">
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Strings.password }}</span>
|
||||
<input type="password" class="input ~neutral @low mt-4 mb-2" id="smtp-password">
|
||||
</label>
|
||||
</div>
|
||||
<div id="email-mailgun">
|
||||
<p class="text-2xl font-semibold mb-2">Mailgun</p>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Email.mailgunApiURL }}</span>
|
||||
<input type="url" class="input ~neutral @low mt-4 mb-2" id="mailgun-api_url" placeholder="https://api.eu.mailgun.net/v3/mail.jellyf.in/messages">
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Strings.apiKey }}</span>
|
||||
<input type="text" class="input ~neutral @low mt-4 mb-2" id="mailgun-api_key">
|
||||
</label>
|
||||
</div>
|
||||
<div id="email-smtp" class="flex flex-col gap-2 min-w-[40%]">
|
||||
<p class="text-2xl font-semibold">SMTP</p>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Email.encryption }}</span>
|
||||
<div class="select ~neutral @low">
|
||||
<select id="smtp-encryption">
|
||||
<option value="starttls">STARTTLS ({{ .lang.Strings.port }} 587)</option>
|
||||
<option value="ssl_tls">SSL/TLS ({{ .lang.Strings.port }} 465)</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.serverAddress }}</span>
|
||||
<input type="url" class="input ~neutral @low" id="smtp-server" placeholder="smtp.jellyf.in">
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.port }}</span>
|
||||
<input type="number" class="input ~neutral @low" id="smtp-port" placeholder="587">
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.username }}</span>
|
||||
<input type="text" class="input ~neutral @low" id="smtp-username">
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.password }}</span>
|
||||
<input type="password" class="input ~neutral @low" id="smtp-password">
|
||||
</label>
|
||||
</div>
|
||||
<div id="email-mailgun" class="flex flex-col gap-2 min-w-[40%]">
|
||||
<p class="text-2xl font-semibold">Mailgun</p>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Email.mailgunApiURL }}</span>
|
||||
<input type="url" class="input ~neutral @low" id="mailgun-api_url" placeholder="https://api.eu.mailgun.net/v3/mail.jellyf.in/messages">
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.apiKey }}</span>
|
||||
<input type="text" class="input ~neutral @low" id="mailgun-api_key">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
</section>
|
||||
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="card ~neutral @low mb-2 unfocused related-to-email">
|
||||
<span class="heading">{{ .lang.Notifications.title }}</span>
|
||||
<p class="content my-2">{{ .lang.Notifications.description }}</p>
|
||||
<label class="row switch pb-4">
|
||||
<input type="checkbox" class="mr-2" id="notifications-enabled"><span>{{ .lang.Strings.enabled }}</span>
|
||||
</label>
|
||||
<span class="heading">{{ .lang.WelcomeEmails.title }}</span>
|
||||
<p class="content my-2">{{ .lang.WelcomeEmails.description }}</p>
|
||||
<label class="row switch pb-4">
|
||||
<input type="checkbox" class="mr-2" id="welcome_email-enabled"><span>{{ .lang.Strings.enabled }}</span>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Strings.emailSubject }}</span>
|
||||
<input type="text" class="input ~neutral @low mt-4 mb-2" id="welcome_email-subject" placeholder="{{ .emailLang.WelcomeEmail.title }}">
|
||||
</label>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<div class="card lg:container sectioned ~neutral @low unfocused related-to-email">
|
||||
<section class="section flex flex-col gap-2 justify-between">
|
||||
<span class="heading">{{ .lang.Notifications.title }}</span>
|
||||
<p class="content">{{ .lang.Notifications.description }}</p>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="checkbox" id="notifications-enabled"><span>{{ .lang.Strings.enabled }}</span></div>
|
||||
</label>
|
||||
<span class="heading">{{ .lang.WelcomeEmails.title }}</span>
|
||||
<p class="content">{{ .lang.WelcomeEmails.description }}</p>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="checkbox" id="welcome_email-enabled"><span>{{ .lang.Strings.enabled }}</span></div>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.emailSubject }}</span>
|
||||
<input type="text" class="input ~neutral @low" id="welcome_email-subject" placeholder="{{ .emailLang.WelcomeEmail.title }}">
|
||||
</label>
|
||||
</section>
|
||||
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="card ~neutral @low mb-2 unfocused related-to-email">
|
||||
<span class="heading">{{ .lang.InviteEmails.title }}</span>
|
||||
<p class="content my-2">{{ .lang.InviteEmails.description }}</p>
|
||||
<label class="row switch pb-4">
|
||||
<input type="checkbox" class="mr-2" id="invite_emails-enabled"><span>{{ .lang.Strings.enabled }}</span>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Strings.URL }}</span>
|
||||
<input type="url" class="input ~neutral @low mt-4 mb-2" id="invite_emails-url_base" placeholder="https://accounts.jellyf.in/invite">
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Strings.emailSubject }}</span>
|
||||
<input type="text" class="input ~neutral @low mt-4 mb-2" id="invite_emails-subject" placeholder="{{ .emailLang.InviteEmail.title }}">
|
||||
</label>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<div class="card lg:container sectioned ~neutral @low unfocused related-to-email">
|
||||
<section class="section flex flex-col gap-2 justify-between">
|
||||
<span class="heading">{{ .lang.InviteEmails.title }}</span>
|
||||
<p class="content">{{ .lang.InviteEmails.description }}</p>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="checkbox" id="invite_emails-enabled"><span>{{ .lang.Strings.enabled }}</span></div>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.emailSubject }}</span>
|
||||
<input type="text" class="input ~neutral @low" id="invite_emails-subject" placeholder="{{ .emailLang.InviteEmail.title }}">
|
||||
</label>
|
||||
</section>
|
||||
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div id="password-resets" class="card ~neutral @low mb-2 unfocused related-to-email">
|
||||
<span class="heading">{{ .lang.PasswordResets.title }}</span>
|
||||
<p class="content my-2">{{ .lang.PasswordResets.description }}</p>
|
||||
<label class="row switch pb-4">
|
||||
<input type="checkbox" class="mr-2" id="password_resets-enabled"><span>{{ .lang.Strings.enabled }}</span>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.PasswordResets.pathToJellyfin }}</span>
|
||||
<input type="text" class="input ~neutral @low mt-4" id="password_resets-watch_directory" placeholder="/config/jellyfin">
|
||||
<p class="support mb-2 mt-1">{{ .lang.PasswordResets.pathToJellyfinNotice }}</p>
|
||||
</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" class="mr-2" id="password_resets-link_reset"><span>{{ .lang.PasswordResets.resetLinks }}</span>
|
||||
<p class="support mb-2 mt-1">{{ .lang.PasswordResets.resetLinksNotice }}</p>
|
||||
</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" class="mr-2" id="password_resets-set_password"><span>{{ .lang.PasswordResets.setPassword }}</span>
|
||||
<p class="support mb-2 mt-1">{{ .lang.PasswordResets.setPasswordNotice }}</p>
|
||||
</label>
|
||||
<label class="label">
|
||||
<p class="mt-4">{{ .lang.PasswordResets.resetLinksLanguage }}</p>
|
||||
<div class="select ~neutral @low mt-4 mb-2">
|
||||
<select id="password_resets-language">
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label class="row label">
|
||||
<span class="mt-4">{{ .lang.Strings.emailSubject }}</span>
|
||||
<input type="text" class="input ~neutral @low mt-4 mb-2" id="password_resets-subject" placeholder="{{ .emailLang.PasswordReset.title }}">
|
||||
</label>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<div id="password-resets" class="card lg:container sectioned ~neutral @low unfocused related-to-email">
|
||||
<section class="section flex flex-col gap-2 justify-between">
|
||||
<span class="heading">{{ .lang.PasswordResets.title }}</span>
|
||||
<p class="content">{{ .lang.PasswordResets.description }}</p>
|
||||
<p class="content" id="password_resets-more-info">{{ .lang.PasswordResets.moreInfo }}</p>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="checkbox" id="password_resets-enabled"><span>{{ .lang.Strings.enabled }}</span></div>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.PasswordResets.pathToJellyfin }}</span>
|
||||
<input type="text" class="input ~neutral @low" id="password_resets-watch_directory" placeholder="/config/jellyfin">
|
||||
<p class="support">{{ .lang.PasswordResets.pathToJellyfinNotice }}</p>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="checkbox" id="password_resets-link_reset"><span>{{ .lang.PasswordResets.resetLinks }}</span></div>
|
||||
<p class="support">{{ .lang.PasswordResets.resetLinksNotice }} {{ .lang.PasswordResets.resetLinksRequiredForUserPage }}</p>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="checkbox" id="password_resets-set_password"><span>{{ .lang.PasswordResets.setPassword }}</span></div>
|
||||
<p class="support">{{ .lang.PasswordResets.setPasswordNotice }}</p>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.PasswordResets.resetLinksLanguage }}</span>
|
||||
<div class="select ~neutral @low">
|
||||
<select id="password_resets-language">
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.Strings.emailSubject }}</span>
|
||||
<input type="text" class="input ~neutral @low" id="password_resets-subject" placeholder="{{ .emailLang.PasswordReset.title }}">
|
||||
</label>
|
||||
</section>
|
||||
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="card ~neutral @low mb-2 unfocused">
|
||||
<span class="heading">{{ .lang.PasswordValidation.title }}</span>
|
||||
<p class="content my-2">{{ .lang.PasswordValidation.description }}</p>
|
||||
<label class="row switch pb-4">
|
||||
<input type="checkbox" class="mr-2" id="password_validation-enabled" checked><span>{{ .lang.Strings.enabled }}</span>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.PasswordValidation.length }}</span>
|
||||
<input type="number" class="input ~neutral @low mt-4 mb-2" id="password_validation-min_length" value="8">
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.PasswordValidation.uppercase }}</span>
|
||||
<input type="number" class="input ~neutral @low mt-4 mb-2" id="password_validation-upper" value="1">
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.PasswordValidation.lowercase }}</span>
|
||||
<input type="number" class="input ~neutral @low mt-4 mb-2" id="password_validation-lower" value="0">
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.PasswordValidation.numbers }}</span>
|
||||
<input type="number" class="input ~neutral @low mt-4 mb-2" id="password_validation-number" value="0">
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.PasswordValidation.special }}</span>
|
||||
<input type="number" class="input ~neutral @low mt-4 mb-2" id="password_validation-special" value="0">
|
||||
</label>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<div class="card lg:container sectioned ~neutral @low unfocused">
|
||||
<section class="section flex flex-col gap-2 justify-between">
|
||||
<span class="heading">{{ .lang.PasswordValidation.title }}</span>
|
||||
<p class="content">{{ .lang.PasswordValidation.description }}</p>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="checkbox" id="password_validation-enabled" checked><span>{{ .lang.Strings.enabled }}</span></div>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.PasswordValidation.length }}</span>
|
||||
<input type="number" class="input ~neutral @low" id="password_validation-min_length" value="8">
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.PasswordValidation.uppercase }}</span>
|
||||
<input type="number" class="input ~neutral @low" id="password_validation-upper" value="1">
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.PasswordValidation.lowercase }}</span>
|
||||
<input type="number" class="input ~neutral @low" id="password_validation-lower" value="0">
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.PasswordValidation.numbers }}</span>
|
||||
<input type="number" class="input ~neutral @low" id="password_validation-number" value="0">
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.PasswordValidation.special }}</span>
|
||||
<input type="number" class="input ~neutral @low" id="password_validation-special" value="0">
|
||||
</label>
|
||||
</section>
|
||||
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="card ~neutral @low mb-2 unfocused">
|
||||
<span class="heading">{{ .lang.HelpMessages.title }}</span>
|
||||
<p class="content my-2">{{ .lang.HelpMessages.description }}</p>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.HelpMessages.contactMessage }}</span>
|
||||
<input type="text" class="input ~neutral @low mt-4" id="ui-contact_message">
|
||||
<p class="support mb-2 mt-1">{{ .lang.HelpMessages.contactMessageNotice }}</p>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.HelpMessages.helpMessage }}</span>
|
||||
<input type="text" class="input ~neutral @low mt-4" id="ui-help_message">
|
||||
<p class="support mb-2 mt-1">{{ .lang.HelpMessages.helpMessageNotice }}</p>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.HelpMessages.successMessage }}</span>
|
||||
<input type="text" class="input ~neutral @low mt-4" id="ui-success_message">
|
||||
<p class="support mb-2 mt-1">{{ .lang.HelpMessages.successMessageNotice }}</p>
|
||||
</label>
|
||||
<label class="label related-to-email">
|
||||
<span class="mt-4">{{ .lang.HelpMessages.emailMessage }}</span>
|
||||
<input type="text" class="input ~neutral @low mt-4" id="email-message">
|
||||
<p class="support mb-2 mt-1">{{ .lang.HelpMessages.emailMessageNotice }}</p>
|
||||
</label>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<div class="card lg:container sectioned ~neutral @low unfocused">
|
||||
<section class="section flex flex-col gap-2 justify-between">
|
||||
<span class="heading">{{ .lang.HelpMessages.title }}</span>
|
||||
<p class="content">{{ .lang.HelpMessages.description }}</p>
|
||||
<p class="content">{{ .lang.HelpMessages.markdownMessageNotice }}</p>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.HelpMessages.contactMessage }}</span>
|
||||
<input type="text" class="input ~neutral @low" id="ui-contact_message">
|
||||
<p class="support">{{ .lang.HelpMessages.contactMessageNotice }}</p>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.HelpMessages.helpMessage }}</span>
|
||||
<input type="text" class="input ~neutral @low" id="ui-help_message">
|
||||
<p class="support">{{ .lang.HelpMessages.helpMessageNotice }}</p>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span>{{ .lang.HelpMessages.successMessage }}</span>
|
||||
<input type="text" class="input ~neutral @low" id="ui-success_message">
|
||||
<p class="support">{{ .lang.HelpMessages.successMessageNotice }}</p>
|
||||
</label>
|
||||
<label class="label related-to-email">
|
||||
<span>{{ .lang.HelpMessages.emailMessage }}</span>
|
||||
<input type="text" class="input ~neutral @low" id="email-message">
|
||||
<p class="support">{{ .lang.HelpMessages.emailMessageNotice }}</p>
|
||||
</label>
|
||||
</section>
|
||||
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="card ~neutral @low mb-2 unfocused">
|
||||
<div class="row col flex center">
|
||||
<div class="card lg:container sectioned ~neutral @low unfocused">
|
||||
<section class="section flex flex-col gap-2 justify-center items-center">
|
||||
<span class="heading">{{ .lang.EndPage.finished }}</span>
|
||||
</div>
|
||||
<div class="row col flex center">
|
||||
<p class="content my-2">{{ .lang.EndPage.restartMessage }}</p>
|
||||
</div>
|
||||
<div class="row col flex center">
|
||||
<span class="button ~neutral @low back mr-4">{{ .lang.Strings.back }}</span>
|
||||
<p class="content text-center">{{ .lang.EndPage.moreFeatures }} {{ .lang.EndPage.restartReload }} {{ .lang.EndPage.ifFailedLoad }}</p>
|
||||
</section>
|
||||
<section class="section w-full ~neutral footer flex flex-row justify-center items-center gap-2">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<span class="button ~urge @low" id="restart">{{ .lang.Strings.submit }}</span>
|
||||
<span class="button ~urge @low unfocused" id="refresh">{{ .lang.EndPage.refreshPage }}</span>
|
||||
<a class="button ~urge @low flex flex-col gap-0.5 unfocused" id="refresh-internal"></a>
|
||||
<a class="button ~urge @low flex flex-col gap-0.5 unfocused" id="refresh-external"></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -499,4 +574,3 @@
|
||||
<script src="js/setup.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
3
html/syntaxhighlighting.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
<link rel="stylesheet" type="text/css" href="{{ .pages.Base }}/css/{{ .cssVersion }}highlightjs-light.css" data-theme="light">
|
||||
<link rel="stylesheet" type="text/css" href="{{ .pages.Base }}/css/{{ .cssVersion }}highlightjs-dark.css" data-theme="dark">
|
||||
<link rel="stylesheet" type="text/css" href="{{ .pages.Base }}/css/{{ .cssVersion }}code-input.css">
|
||||
148
html/user.html
@@ -1,70 +1,66 @@
|
||||
<html lang="en" class="light">
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="light">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="{{ .urlBase }}/css/{{ .cssVersion }}bundle.css">
|
||||
<script>
|
||||
window.URLBase = "{{ .urlBase }}";
|
||||
window.notificationsEnabled = {{ .notifications }};
|
||||
window.ombiEnabled = {{ .ombiEnabled }};
|
||||
window.langFile = JSON.parse({{ .language }});
|
||||
window.pwrEnabled = {{ .pwrEnabled }};
|
||||
window.linkResetEnabled = {{ .linkResetEnabled }};
|
||||
window.language = "{{ .langName }}";
|
||||
window.telegramEnabled = {{ .telegramEnabled }};
|
||||
window.telegramRequired = {{ .telegramRequired }};
|
||||
window.telegramUsername = {{ .telegramUsername }};
|
||||
window.telegramURL = {{ .telegramURL }};
|
||||
window.emailEnabled = {{ .emailEnabled }};
|
||||
window.emailRequired = {{ .emailRequired }};
|
||||
window.discordEnabled = {{ .discordEnabled }};
|
||||
window.discordRequired = {{ .discordRequired }};
|
||||
window.discordServerName = "{{ .discordServerName }}";
|
||||
window.discordInviteLink = {{ .discordInviteLink }};
|
||||
window.discordSendPINMessage = "{{ .discordSendPINMessage }}";
|
||||
window.matrixEnabled = {{ .matrixEnabled }};
|
||||
window.matrixRequired = {{ .matrixRequired }};
|
||||
window.matrixUserID = "{{ .matrixUser }}";
|
||||
window.validationStrings = JSON.parse({{ .validationStrings }});
|
||||
</script>
|
||||
{{ template "header.html" . }}
|
||||
{{ template "header.txt" . }}
|
||||
<title>{{ .strings.myAccount }}</title>
|
||||
</head>
|
||||
<body class="max-w-full overflow-x-hidden section">
|
||||
<div id="modal-email" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
||||
<div class="content">
|
||||
<span class="heading mb-4 my-2"></span>
|
||||
<label class="label supra row m-1" for="modal-email-input">{{ .strings.emailAddress }}</label>
|
||||
<div class="row">
|
||||
<input type="email" class="col sm field ~neutral @low input" id="modal-email-input" placeholder="{{ .strings.emailAddress }}">
|
||||
</div>
|
||||
<button class="button ~urge @low supra full-width center lg my-2 modal-submit">{{ .strings.submit }}</button>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="heading"></span>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span class="supra">{{ .strings.emailAddress }}</span>
|
||||
<input type="email" class="field ~neutral @low input" id="modal-email-input" placeholder="{{ .strings.emailAddress }}">
|
||||
</label>
|
||||
<button class="button ~urge @low supra full-width center lg modal-submit">{{ .strings.submit }}</button>
|
||||
</div>
|
||||
<div class="confirmation-required unfocused">
|
||||
<span class="heading mb-4">{{ .strings.confirmationRequired }} <span class="modal-close">×</span></span>
|
||||
<p class="content mb-4">{{ .strings.confirmationRequiredMessage }}</p>
|
||||
<div class="confirmation-required unfocused flex flex-col gap-2">
|
||||
<span class="heading">{{ .strings.confirmationRequired }} <span class="modal-close">×</span></span>
|
||||
<p class="content">{{ .strings.confirmationRequiredMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ if .pwrEnabled }}
|
||||
<div id="modal-pwr" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low flex flex-col gap-2">
|
||||
<span class="heading">{{ .strings.resetPassword }}</span>
|
||||
<p class="content my-2">
|
||||
{{ if .linkResetEnabled }}
|
||||
{{ .strings.resetPasswordThroughLink }}
|
||||
{{ else }}
|
||||
{{ .strings.resetPasswordThroughJellyfin }}
|
||||
{{ end }}
|
||||
</p>
|
||||
<div class="row">
|
||||
<input type="text" class="col sm field ~neutral @low input" id="pwr-address" placeholder="username | example@example.com | user#1234 | @user:host | @username">
|
||||
<div class="content">
|
||||
{{ if .linkResetEnabled }}
|
||||
<p>{{ .strings.resetPasswordThroughLinkStart }}</p>
|
||||
<ul class="content">
|
||||
{{ if .resetPasswordUsername }}<li>{{ .strings.resetPasswordUsername }}</li>{{ end }}
|
||||
{{ if .resetPasswordEmail }}<li>{{ .strings.resetPasswordEmail }}</li>{{ end }}
|
||||
{{ if .resetPasswordContactMethod }}<li>{{ .strings.resetPasswordContactMethod }}</li>{{ end }}
|
||||
</ul>
|
||||
<p>{{ .strings.resetPasswordThroughLinkEnd }}</p>
|
||||
{{ else }}
|
||||
<p>{{ .strings.resetPasswordThroughJellyfin }}</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
<input type="text" class="col sm field ~neutral @low input" id="pwr-address" placeholder="username | example@example.com | user#1234 | @user:host | @username">
|
||||
{{ if .linkResetEnabled }}
|
||||
<span class="button ~info @low full-width center mt-4" id="pwr-submit">
|
||||
<span class="button ~info @low full-width center" id="pwr-submit">
|
||||
{{ .strings.submit }}
|
||||
</span>
|
||||
{{ else }}
|
||||
<a class="button ~info @low full-width center mt-4" href="{{ .jfLink }}" target="_blank">{{ .strings.continue }}</a>
|
||||
<a class="button ~info @low full-width center" href="{{ .jfLink }}" target="_blank">{{ .strings.continue }}</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,53 +68,35 @@
|
||||
{{ template "login-modal.html" . }}
|
||||
{{ template "account-linking.html" . }}
|
||||
<div id="notification-box"></div>
|
||||
<div class="top-4 left-4 absolute">
|
||||
<span class="dropdown" tabindex="0" id="lang-dropdown">
|
||||
<span class="button ~urge dropdown-button">
|
||||
<i class="ri-global-line"></i>
|
||||
<span class="ml-2 chev"></span>
|
||||
</span>
|
||||
<div class="dropdown-display">
|
||||
<div class="card ~neutral @low">
|
||||
<label class="switch pb-4">
|
||||
<input type="radio" name="lang-time" id="lang-12h">
|
||||
<span>{{ .strings.time12h }}</span>
|
||||
</label>
|
||||
<label class="switch pb-4">
|
||||
<input type="radio" name="lang-time" id="lang-24h">
|
||||
<span>{{ .strings.time24h }}</span>
|
||||
</label>
|
||||
<div id="lang-list"></div>
|
||||
</div>
|
||||
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4 unfocused">
|
||||
<div class="top-2 inset-x-2 lg:absolute flex flex-row justify-between">
|
||||
<div class="flex flex-row gap-2">
|
||||
{{ template "lang-select.html" . }}
|
||||
<span class="button ~warning h-min" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
|
||||
<span class="button ~critical @low unfocused" id="logout-button">{{ .strings.logout }}</span>
|
||||
</div>
|
||||
</span>
|
||||
<span class="button ~warning" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
|
||||
<span class="button ~critical @low mb-4 unfocused" id="logout-button">{{ .strings.logout }}</span>
|
||||
</div>
|
||||
<div class="top-4 right-4 absolute">
|
||||
<a class="button ~info unfocused" href="/" id="admin-back-button"><i class="ri-arrow-left-fill mr-2"></i>{{ .strings.admin }}</a>
|
||||
</div>
|
||||
<div class="page-container unfocused">
|
||||
<div class="card @low dark:~d_neutral mb-4" id="card-user">
|
||||
<span class="heading mb-2"></span>
|
||||
<a class="button ~info unfocused h-min flex flex-row gap-2" href="/" id="admin-back-button"><i class="ri-arrow-left-fill"></i>{{ .strings.admin }}</a>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div class="card @low dark:~d_neutral" id="card-user">
|
||||
<span class="heading flex flex-row gap-4"></span>
|
||||
</div>
|
||||
<div class="columns-1 sm:columns-2 gap-4" id="user-cardlist">
|
||||
{{ if index . "PageMessageEnabled" }}
|
||||
{{ if .PageMessageEnabled }}
|
||||
<div class="card @low dark:~d_neutral content" id="card-message">
|
||||
<div class="card @low dark:~d_neutral content break-words" id="card-message">
|
||||
{{ .PageMessageContent }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<div class="card @low dark:~d_neutral flex-col" id="card-contact">
|
||||
<span class="heading mb-2">{{ .strings.contactMethods }}</span>
|
||||
<div class="card @low dark:~d_neutral flex flex-col gap-2" id="card-contact">
|
||||
<span class="heading">{{ .strings.contactMethods }}</span>
|
||||
<div class="content flex justify-between flex-col h-100"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="card @low dark:~d_neutral content" id="card-password">
|
||||
<span class="heading row mb-2">{{ .strings.changePassword }}</span>
|
||||
<div class="">
|
||||
<div class="my-2">
|
||||
<div class="card @low dark:~d_neutral flex flex-col gap-2" id="card-password">
|
||||
<span class="heading">{{ .strings.changePassword }}</span>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="content">
|
||||
<span class="label supra row">{{ .strings.passwordRequirementsHeader }}</span>
|
||||
<ul>
|
||||
{{ range $key, $value := .requirements }}
|
||||
@@ -128,15 +106,15 @@
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="my-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="label supra" for="user-old-password">{{ .strings.oldPassword }}</label>
|
||||
<input type="password" class="input ~neutral @low mt-2 mb-4" placeholder="{{ .strings.password }}" id="user-old-password" aria-label="{{ .strings.oldPassword }}">
|
||||
<input type="password" class="input ~neutral @low" placeholder="{{ .strings.password }}" id="user-old-password" aria-label="{{ .strings.oldPassword }}">
|
||||
<label class="label supra" for="user-new-password">{{ .strings.newPassword }}</label>
|
||||
<input type="password" class="input ~neutral @low mt-2 mb-4" placeholder="{{ .strings.password }}" id="user-new-password" aria-label="{{ .strings.newPassword }}">
|
||||
<input type="password" class="input ~neutral @low" placeholder="{{ .strings.password }}" id="user-new-password" aria-label="{{ .strings.newPassword }}">
|
||||
|
||||
<label class="label supra" for="user-reenter-password">{{ .strings.reEnterPassword }}</label>
|
||||
<input type="password" class="input ~neutral @low mt-2 mb-4" placeholder="{{ .strings.password }}" id="user-reenter-new-password" aria-label="{{ .strings.reEnterPassword }}">
|
||||
<span class="button ~info @low full-width center mt-4" id="user-password-submit">
|
||||
<input type="password" class="input ~neutral @low" placeholder="{{ .strings.password }}" id="user-reenter-new-password" aria-label="{{ .strings.reEnterPassword }}">
|
||||
<span class="button ~info @low full-width center" id="user-password-submit">
|
||||
{{ .strings.changePassword }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -144,15 +122,29 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="card @low dark:~d_neutral unfocused" id="card-status">
|
||||
<span class="heading mb-2">{{ .strings.expiry }}</span>
|
||||
<aside class="aside ~warning user-expiry my-4"></aside>
|
||||
<div class="card @low dark:~d_neutral unfocused flex flex-col gap-2" id="card-status">
|
||||
<span class="heading">{{ .strings.expiry }}</span>
|
||||
<aside class="aside ~warning user-expiry"></aside>
|
||||
<div class="user-expiry-countdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{ if .referralsEnabled }}
|
||||
<div>
|
||||
<div class="card @low dark:~d_neutral unfocused flex flex-col gap-2" id="card-referrals">
|
||||
<span class="heading">{{ .strings.referrals }}</span>
|
||||
<aside class="aside ~neutral col user-referrals-description"></aside>
|
||||
<div class="flex flex-row justify-between gap-2">
|
||||
<div class="user-referrals-info flex flex-col gap-2"></div>
|
||||
<div class="grid">
|
||||
<button type="button" class="user-referrals-button button ~info dark:~d_info @low flex flex-row gap-2" title="Copy">{{ .strings.copyReferral }}<i class="ri-file-copy-line"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ .urlBase }}/js/user.js" type="module"></script>
|
||||
<script src="{{ .pages.Base }}/js/user.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Images
|
||||
|
||||
This holds any images on the main README, and the base files for the icons and banner. The font used, like Jellyfin, is [Quicksand](https://fonts.google.com/specimen/Quicksand) by Andrew Paglinawan.
|
||||
This holds any images on the main README, and the base files for the icons and banner. The font used pre-0.5.0, like Jellyfin, is [Quicksand](https://fonts.google.com/specimen/Quicksand) by Andrew Paglinawan. These old versions are prefixed with `-quicksand` in `src/`.
|
||||
|
||||
Post-0.5.0, the font used is Hanken Grotesk, available under SIL OFL 1.1 License.
|
||||
https://scripts.sil.org/cms/scripts/page.php?item_id=OFL_web
|
||||
|
||||
"Go" text logo and Gopher image: Copyright 2018 The Go Authors. All rights reserved.
|
||||
https://creativecommons.org/licenses/by/3.0/legalcode
|
||||
|
||||
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 411 KiB |
0
images/jfa-go-icon.png
Executable file → Normal file
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
0
images/jfa-go-icon.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
BIN
images/myaccount.png
Normal file
|
After Width: | Height: | Size: 535 KiB |
424
images/src/banner-hanken.svg
Normal file
|
After Width: | Height: | Size: 72 KiB |
348
images/src/banner-jakarta.svg
Normal file
|
After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
736
images/src/jfa-go-social-hanken.svg
Normal file
|
After Width: | Height: | Size: 91 KiB |
668
images/src/jfa-go-social-jakarta.svg
Normal file
|
After Width: | Height: | Size: 100 KiB |
337
images/src/jfa-go-social-quicksand.svg
Normal file
|
After Width: | Height: | Size: 59 KiB |
17
internal.go
@@ -1,3 +1,4 @@
|
||||
//go:build !external
|
||||
// +build !external
|
||||
|
||||
package main
|
||||
@@ -5,20 +6,20 @@ package main
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
|
||||
"github.com/hrfee/jfa-go/logger"
|
||||
)
|
||||
|
||||
const binaryType = "internal"
|
||||
|
||||
//go:embed data data/html data/web data/web/css data/web/js
|
||||
func BuildTagsExternal() {}
|
||||
|
||||
//go:embed build/data build/data/html build/data/web build/data/web/css build/data/web/js
|
||||
var loFS embed.FS
|
||||
|
||||
//go:embed lang/common lang/admin lang/email lang/form lang/setup lang/pwreset lang/telegram
|
||||
var laFS embed.FS
|
||||
|
||||
var langFS rewriteFS
|
||||
var localFS rewriteFS
|
||||
|
||||
type rewriteFS struct {
|
||||
fs embed.FS
|
||||
prefix string
|
||||
@@ -35,8 +36,8 @@ func FSJoin(elem ...string) string {
|
||||
return out[:len(out)-1]
|
||||
}
|
||||
|
||||
func loadFilesystems() {
|
||||
func loadFilesystems(rootDir string, logger *logger.Logger) {
|
||||
langFS = rewriteFS{laFS, "lang/"}
|
||||
localFS = rewriteFS{loFS, "data/"}
|
||||
log.Println("Using internal storage")
|
||||
localFS = rewriteFS{loFS, "build/data/"}
|
||||
logger.Println("Using internal storage")
|
||||
}
|
||||
|
||||
110
jellyseerr-d.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hrfee/jfa-go/jellyseerr"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
)
|
||||
|
||||
type JellyseerrInitialSyncStatus struct {
|
||||
Done bool
|
||||
}
|
||||
|
||||
// Ensure the Jellyseerr cache is up to date before calling.
|
||||
func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
|
||||
user, imported, err := app.js.GetOrImportUser(jfID, true)
|
||||
if err != nil {
|
||||
app.debug.Printf(lm.FailedImportUser, lm.Jellyseerr, jfID, err)
|
||||
return
|
||||
}
|
||||
if imported {
|
||||
app.debug.Printf(lm.ImportJellyseerrUser, jfID, user.ID)
|
||||
}
|
||||
notif, err := app.js.GetNotificationPreferencesByID(user.ID)
|
||||
if err != nil {
|
||||
app.debug.Printf(lm.FailedGetJellyseerrNotificationPrefs, jfID, err)
|
||||
return
|
||||
}
|
||||
|
||||
contactMethods := map[jellyseerr.NotificationsField]any{}
|
||||
email, ok := app.storage.GetEmailsKey(jfID)
|
||||
if ok && email.Addr != "" && user.Email != email.Addr {
|
||||
err = app.js.ModifyMainUserSettings(jfID, jellyseerr.MainUserSettings{Email: email.Addr})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "INVALID_EMAIL") {
|
||||
app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, jfID, err.Error()+"\""+email.Addr+"\"")
|
||||
} else {
|
||||
app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, jfID, err)
|
||||
}
|
||||
} else {
|
||||
contactMethods[jellyseerr.FieldEmailEnabled] = email.Contact
|
||||
}
|
||||
}
|
||||
if discordEnabled {
|
||||
dcUser, ok := app.storage.GetDiscordKey(jfID)
|
||||
if ok && dcUser.ID != "" && notif.DiscordID != dcUser.ID {
|
||||
contactMethods[jellyseerr.FieldDiscord] = dcUser.ID
|
||||
contactMethods[jellyseerr.FieldDiscordEnabled] = dcUser.Contact
|
||||
}
|
||||
}
|
||||
if telegramEnabled {
|
||||
tgUser, ok := app.storage.GetTelegramKey(jfID)
|
||||
chatID, _ := strconv.ParseInt(notif.TelegramChatID, 10, 64)
|
||||
if ok && tgUser.ChatID != 0 && chatID != tgUser.ChatID {
|
||||
u, _ := app.storage.GetTelegramKey(jfID)
|
||||
contactMethods[jellyseerr.FieldTelegram] = strconv.FormatInt(u.ChatID, 10)
|
||||
contactMethods[jellyseerr.FieldTelegramEnabled] = tgUser.Contact
|
||||
}
|
||||
}
|
||||
if len(contactMethods) != 0 {
|
||||
err := app.js.ModifyNotifications(jfID, contactMethods)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (app *appContext) SynchronizeJellyseerrUsers() {
|
||||
jsSync := JellyseerrInitialSyncStatus{}
|
||||
app.storage.db.Get("jellyseerr_inital_sync_status", &jsSync)
|
||||
if jsSync.Done {
|
||||
return
|
||||
}
|
||||
|
||||
users, err := app.jf.GetUsers(false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
|
||||
return
|
||||
}
|
||||
app.js.ReloadCache()
|
||||
// I'm sure Jellyseerr can handle it,
|
||||
// but past issues with the Jellyfin db scare me from
|
||||
// running these concurrently. W/e, its a bg task anyway.
|
||||
for _, user := range users {
|
||||
app.SynchronizeJellyseerrUser(user.ID)
|
||||
}
|
||||
// Don't run again until this flag is unset
|
||||
// Stored in the DB as it's not something the user needs to see.
|
||||
app.storage.db.Upsert("jellyseerr_inital_sync_status", JellyseerrInitialSyncStatus{true})
|
||||
}
|
||||
|
||||
// Not really a normal daemon, since it'll only fire once when the feature is enabled.
|
||||
func newJellyseerrDaemon(interval time.Duration, app *appContext) *GenericDaemon {
|
||||
d := NewGenericDaemon(interval, app,
|
||||
func(app *appContext) {
|
||||
app.SynchronizeJellyseerrUsers()
|
||||
},
|
||||
)
|
||||
d.Name("Jellyseerr import")
|
||||
|
||||
jsSync := JellyseerrInitialSyncStatus{}
|
||||
app.storage.db.Get("jellyseerr_inital_sync_status", &jsSync)
|
||||
if jsSync.Done {
|
||||
return nil
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
9
jellyseerr/go.mod
Normal file
@@ -0,0 +1,9 @@
|
||||
module github.com/hrfee/jfa-go/jellyseerr
|
||||
|
||||
replace github.com/hrfee/jfa-go/common => ../common
|
||||
|
||||
go 1.18
|
||||
|
||||
require github.com/hrfee/jfa-go/common v0.0.0-20240728190513-dabef831d769
|
||||
|
||||
require github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a // indirect
|
||||
2
jellyseerr/go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a h1:qbXZgCqb9eaPSJfLEXczQD2lxTv6jb6silMPIWW9j6o=
|
||||
github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a/go.mod h1:c5HKkLayo0GrEUDlJwT12b67BL9cdPjP271Xlv/KDRQ=
|
||||
475
jellyseerr/jellyseerr.go
Normal file
@@ -0,0 +1,475 @@
|
||||
package jellyseerr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
co "github.com/hrfee/jfa-go/common"
|
||||
)
|
||||
|
||||
const (
|
||||
API_SUFFIX = "/api/v1"
|
||||
BogusIdentifier = "123412341234123456"
|
||||
)
|
||||
|
||||
// Jellyseerr represents a running Jellyseerr instance.
|
||||
type Jellyseerr struct {
|
||||
server, key string
|
||||
header map[string]string
|
||||
httpClient *http.Client
|
||||
userCache map[string]User // Map of jellyfin IDs to users
|
||||
jsToJfID map[int64]string // Map of jellyseerr IDs to jellyfin IDs
|
||||
invalidatedUsers map[int64]bool // Map of jellyseerr IDs needing a re-caching
|
||||
cacheExpiry time.Time
|
||||
cacheLength time.Duration
|
||||
timeoutHandler co.TimeoutHandler
|
||||
LogRequestBodies bool
|
||||
AutoImportUsers bool
|
||||
}
|
||||
|
||||
// NewJellyseerr returns an Ombi object.
|
||||
func NewJellyseerr(server, key string, timeoutHandler co.TimeoutHandler) *Jellyseerr {
|
||||
if !strings.HasSuffix(server, API_SUFFIX) {
|
||||
server = server + API_SUFFIX
|
||||
}
|
||||
return &Jellyseerr{
|
||||
server: server,
|
||||
key: key,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
header: map[string]string{
|
||||
"X-Api-Key": key,
|
||||
},
|
||||
cacheLength: time.Duration(30) * time.Minute,
|
||||
cacheExpiry: time.Now(),
|
||||
timeoutHandler: timeoutHandler,
|
||||
userCache: map[string]User{},
|
||||
jsToJfID: map[int64]string{},
|
||||
invalidatedUsers: map[int64]bool{},
|
||||
LogRequestBodies: false,
|
||||
}
|
||||
}
|
||||
|
||||
// SetTransport sets the http.Transport to use for requests. Can be used to set a proxy.
|
||||
func (js *Jellyseerr) SetTransport(t *http.Transport) {
|
||||
js.httpClient.Transport = t
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) req(mode string, uri string, data any, queryParams url.Values, headers map[string]string, response bool) (string, int, error) {
|
||||
var params []byte
|
||||
if data != nil {
|
||||
params, _ = json.Marshal(data)
|
||||
}
|
||||
if js.LogRequestBodies {
|
||||
fmt.Printf("Jellyseerr API Client: Sending Data \"%s\" to \"%s\"\n", string(params), uri)
|
||||
}
|
||||
if qp := queryParams.Encode(); qp != "" {
|
||||
uri += "?" + qp
|
||||
}
|
||||
var req *http.Request
|
||||
if data != nil {
|
||||
req, _ = http.NewRequest(mode, uri, bytes.NewBuffer(params))
|
||||
} else {
|
||||
req, _ = http.NewRequest(mode, uri, nil)
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
for name, value := range js.header {
|
||||
req.Header.Add(name, value)
|
||||
}
|
||||
if headers != nil {
|
||||
for name, value := range headers {
|
||||
req.Header.Add(name, value)
|
||||
}
|
||||
}
|
||||
resp, err := js.httpClient.Do(req)
|
||||
err = co.GenericErrFromResponse(resp, err)
|
||||
defer js.timeoutHandler()
|
||||
var responseText string
|
||||
defer resp.Body.Close()
|
||||
if response || err != nil {
|
||||
var decodeErr error
|
||||
responseText, decodeErr = js.decodeResp(resp)
|
||||
if decodeErr != nil {
|
||||
return responseText, resp.StatusCode, err
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
var msg ErrorDTO
|
||||
err = json.Unmarshal([]byte(responseText), &msg)
|
||||
if err != nil {
|
||||
return responseText, resp.StatusCode, err
|
||||
}
|
||||
if msg.Message != "" {
|
||||
err = fmt.Errorf("got %d: %s", resp.StatusCode, msg.Message)
|
||||
}
|
||||
return responseText, resp.StatusCode, err
|
||||
}
|
||||
return responseText, resp.StatusCode, err
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) decodeResp(resp *http.Response) (string, error) {
|
||||
var out io.Reader
|
||||
switch resp.Header.Get("Content-Encoding") {
|
||||
case "gzip":
|
||||
out, _ = gzip.NewReader(resp.Body)
|
||||
default:
|
||||
out = resp.Body
|
||||
}
|
||||
buf := new(strings.Builder)
|
||||
_, err := io.Copy(buf, out)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) get(uri string, data any, params url.Values) (string, int, error) {
|
||||
return js.req(http.MethodGet, uri, data, params, nil, true)
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) post(uri string, data any, response bool) (string, int, error) {
|
||||
return js.req(http.MethodPost, uri, data, url.Values{}, nil, response)
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) put(uri string, data any, response bool) (string, int, error) {
|
||||
return js.req(http.MethodPut, uri, data, url.Values{}, nil, response)
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) delete(uri string, data any) (int, error) {
|
||||
_, status, err := js.req(http.MethodDelete, uri, data, url.Values{}, nil, false)
|
||||
return status, err
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) ImportFromJellyfin(jfIDs ...string) ([]User, error) {
|
||||
params := map[string]interface{}{
|
||||
"jellyfinUserIds": jfIDs,
|
||||
}
|
||||
resp, _, err := js.post(js.server+"/user/import-from-jellyfin", params, true)
|
||||
var data []User
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
err = json.Unmarshal([]byte(resp), &data)
|
||||
for _, u := range data {
|
||||
if u.JellyfinUserID != "" {
|
||||
js.userCache[u.JellyfinUserID] = u
|
||||
js.jsToJfID[u.ID] = u.JellyfinUserID
|
||||
}
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) getUsers() error {
|
||||
if js.cacheExpiry.After(time.Now()) {
|
||||
return nil
|
||||
if len(js.invalidatedUsers) != 0 {
|
||||
return js.getInvalidatedUsers()
|
||||
}
|
||||
}
|
||||
js.cacheExpiry = time.Now().Add(js.cacheLength)
|
||||
userCache := map[string]User{}
|
||||
jsToJfID := map[int64]string{}
|
||||
pageCount := 1
|
||||
pageIndex := 0
|
||||
for {
|
||||
res, err := js.getUserPage(pageIndex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, u := range res.Results {
|
||||
if u.JellyfinUserID == "" {
|
||||
continue
|
||||
}
|
||||
userCache[u.JellyfinUserID] = u
|
||||
jsToJfID[u.ID] = u.JellyfinUserID
|
||||
}
|
||||
pageCount = res.Page.Pages
|
||||
pageIndex++
|
||||
if pageIndex >= pageCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
js.userCache = userCache
|
||||
js.jsToJfID = jsToJfID
|
||||
js.invalidatedUsers = map[int64]bool{}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) getUserPage(page int) (GetUsersDTO, error) {
|
||||
params := url.Values{}
|
||||
params.Add("take", "30")
|
||||
params.Add("skip", strconv.Itoa(page*30))
|
||||
params.Add("sort", "created")
|
||||
if js.LogRequestBodies {
|
||||
fmt.Printf("Jellyseerr API Client: Sending with URL params \"%+v\"\n", params)
|
||||
}
|
||||
resp, _, err := js.get(js.server+"/user", nil, params)
|
||||
var data GetUsersDTO
|
||||
if err == nil {
|
||||
err = json.Unmarshal([]byte(resp), &data)
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) MustGetUser(jfID string) (User, error) {
|
||||
u, _, err := js.GetOrImportUser(jfID, false)
|
||||
return u, err
|
||||
}
|
||||
|
||||
// GetImportedUser provides the same function as ImportFromJellyfin, but will always return the user,
|
||||
// even if they already existed. Also returns whether the user was imported or not,
|
||||
func (js *Jellyseerr) GetOrImportUser(jfID string, fixedCache bool) (u User, imported bool, err error) {
|
||||
imported = false
|
||||
u, err = js.GetExistingUser(jfID, fixedCache)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
var users []User
|
||||
users, err = js.ImportFromJellyfin(jfID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(users) != 0 {
|
||||
u = users[0]
|
||||
err = nil
|
||||
return
|
||||
}
|
||||
err = fmt.Errorf("user not found or imported")
|
||||
return
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) GetExistingUser(jfID string, fixedCache bool) (u User, err error) {
|
||||
js.getUsers()
|
||||
ok := false
|
||||
err = nil
|
||||
u, ok = js.userCache[jfID]
|
||||
_, invalidated := js.invalidatedUsers[u.ID]
|
||||
if ok && !invalidated {
|
||||
return
|
||||
}
|
||||
if invalidated {
|
||||
err = js.getInvalidatedUsers()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else if !fixedCache {
|
||||
js.cacheExpiry = time.Now()
|
||||
js.getUsers()
|
||||
}
|
||||
if u, ok = js.userCache[jfID]; ok {
|
||||
err = nil
|
||||
return
|
||||
}
|
||||
err = fmt.Errorf("user not found")
|
||||
return
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) getUser(jfID string) (User, error) {
|
||||
if js.AutoImportUsers {
|
||||
return js.MustGetUser(jfID)
|
||||
}
|
||||
return js.GetExistingUser(jfID, false)
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) Me() (User, error) {
|
||||
resp, _, err := js.get(js.server+"/auth/me", nil, url.Values{})
|
||||
var data User
|
||||
data.ID = -1
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
err = json.Unmarshal([]byte(resp), &data)
|
||||
return data, err
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) getInvalidatedUsers() error {
|
||||
// FIXME: Collect errors and return
|
||||
for jellyseerrID, _ := range js.invalidatedUsers {
|
||||
jfID, ok := js.jsToJfID[jellyseerrID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
user, err := js.UserByID(jellyseerrID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
js.userCache[jfID] = user
|
||||
js.jsToJfID[jellyseerrID] = jfID
|
||||
delete(js.invalidatedUsers, jellyseerrID)
|
||||
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) GetPermissions(jfID string) (Permissions, error) {
|
||||
data := permissionsDTO{Permissions: -1}
|
||||
u, err := js.getUser(jfID)
|
||||
if err != nil {
|
||||
return data.Permissions, err
|
||||
}
|
||||
|
||||
resp, _, err := js.get(fmt.Sprintf(js.server+"/user/%d/settings/permissions", u.ID), nil, url.Values{})
|
||||
if err != nil {
|
||||
return data.Permissions, err
|
||||
}
|
||||
err = json.Unmarshal([]byte(resp), &data)
|
||||
return data.Permissions, err
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) SetPermissions(jfID string, perm Permissions) error {
|
||||
u, err := js.getUser(jfID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _, err = js.post(fmt.Sprintf(js.server+"/user/%d/settings/permissions", u.ID), permissionsDTO{Permissions: perm}, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Permissions = perm
|
||||
js.userCache[jfID] = u
|
||||
js.jsToJfID[u.ID] = jfID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) ApplyTemplateToUser(jfID string, tmpl UserTemplate) error {
|
||||
u, err := js.getUser(jfID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _, err = js.put(fmt.Sprintf(js.server+"/user/%d", u.ID), tmpl, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.UserTemplate = tmpl
|
||||
js.userCache[jfID] = u
|
||||
js.jsToJfID[u.ID] = jfID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) ModifyUser(jfID string, conf map[UserField]any) error {
|
||||
if _, ok := conf[FieldEmail]; ok {
|
||||
return fmt.Errorf("email is read only, set with ModifyMainUserSettings instead")
|
||||
}
|
||||
u, err := js.getUser(jfID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _, err = js.put(fmt.Sprintf(js.server+"/user/%d", u.ID), conf, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
js.invalidatedUsers[u.ID] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) DeleteUser(jfID string) error {
|
||||
u, err := js.getUser(jfID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = js.delete(fmt.Sprintf(js.server+"/user/%d", u.ID), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delete(js.userCache, jfID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) GetNotificationPreferences(jfID string) (Notifications, error) {
|
||||
u, err := js.getUser(jfID)
|
||||
if err != nil {
|
||||
return Notifications{}, err
|
||||
}
|
||||
return js.GetNotificationPreferencesByID(u.ID)
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) GetNotificationPreferencesByID(jellyseerrID int64) (Notifications, error) {
|
||||
var data Notifications
|
||||
resp, _, err := js.get(fmt.Sprintf(js.server+"/user/%d/settings/notifications", jellyseerrID), nil, url.Values{})
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
err = json.Unmarshal([]byte(resp), &data)
|
||||
return data, err
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) ApplyNotificationsTemplateToUser(jfID string, tmpl NotificationsTemplate) error {
|
||||
// This behaviour is not desired, this being all-zero means no notifications, which is a settings state we'd want to store!
|
||||
/* if tmpl.NotifTypes.Empty() {
|
||||
tmpl.NotifTypes = nil
|
||||
}*/
|
||||
u, err := js.getUser(jfID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _, err = js.post(fmt.Sprintf(js.server+"/user/%d/settings/notifications", u.ID), tmpl, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) ModifyNotifications(jfID string, conf map[NotificationsField]any) error {
|
||||
u, err := js.getUser(jfID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _, err = js.post(fmt.Sprintf(js.server+"/user/%d/settings/notifications", u.ID), conf, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) GetUsers() (map[string]User, error) {
|
||||
err := js.getUsers()
|
||||
return js.userCache, err
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) UserByID(jellyseerrID int64) (User, error) {
|
||||
resp, _, err := js.get(js.server+fmt.Sprintf("/user/%d", jellyseerrID), nil, url.Values{})
|
||||
var data User
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
err = json.Unmarshal([]byte(resp), &data)
|
||||
return data, err
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) ModifyMainUserSettings(jfID string, conf MainUserSettings) error {
|
||||
u, err := js.getUser(jfID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return js.ModifyMainUserSettingsByID(u.ID, conf)
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) ModifyMainUserSettingsByID(jellyseerrID int64, conf MainUserSettings) error {
|
||||
_, _, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/main", jellyseerrID), conf, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
js.invalidatedUsers[jellyseerrID] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) ReloadCache() error {
|
||||
js.cacheExpiry = time.Now()
|
||||
return js.getUsers()
|
||||
}
|
||||
69
jellyseerr/jellyseerr_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package jellyseerr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hrfee/jfa-go/common"
|
||||
)
|
||||
|
||||
const (
|
||||
API_KEY = "MTcyMjI2MDM2MTYyMzMxNDZkZmYyLTE4MzMtNDUyNy1hODJlLTI0MTZkZGUyMDg2Ng=="
|
||||
URI = "http://localhost:5055"
|
||||
PERM = 2097184
|
||||
)
|
||||
|
||||
func client() *Jellyseerr {
|
||||
return NewJellyseerr(URI, API_KEY, common.NewTimeoutHandler("Jellyseerr", URI, false))
|
||||
}
|
||||
|
||||
func TestMe(t *testing.T) {
|
||||
js := client()
|
||||
u, err := js.Me()
|
||||
if err != nil {
|
||||
t.Fatalf("returned error %+v", err)
|
||||
}
|
||||
if u.ID < 0 {
|
||||
t.Fatalf("returned no user %+v\n", u)
|
||||
}
|
||||
}
|
||||
|
||||
/* func TestImportFromJellyfin(t *testing.T) {
|
||||
js := client()
|
||||
list, err := js.ImportFromJellyfin("6b75e189efb744f583aa2e8e9cee41d3")
|
||||
if err != nil {
|
||||
t.Fatalf("returned error %+v", err)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
t.Fatalf("returned no users")
|
||||
}
|
||||
} */
|
||||
|
||||
func TestMustGetUser(t *testing.T) {
|
||||
js := client()
|
||||
u, err := js.MustGetUser("8c9d25c070d641cd8ad9cf825f622a16")
|
||||
if err != nil {
|
||||
t.Fatalf("returned error %+v", err)
|
||||
}
|
||||
if u.ID < 0 {
|
||||
t.Fatalf("returned no users")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPermissions(t *testing.T) {
|
||||
js := client()
|
||||
err := js.SetPermissions("6b75e189efb744f583aa2e8e9cee41d3", PERM)
|
||||
if err != nil {
|
||||
t.Fatalf("returned error %+v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPermissions(t *testing.T) {
|
||||
js := client()
|
||||
perm, err := js.GetPermissions("6b75e189efb744f583aa2e8e9cee41d3")
|
||||
if err != nil {
|
||||
t.Fatalf("returned error %+v", err)
|
||||
}
|
||||
if perm != PERM {
|
||||
t.Fatalf("got unexpected perm code %d", perm)
|
||||
}
|
||||
}
|
||||