Compare commits
895 Commits
tailwind
...
user-pagin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
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 | ||
|
|
9bcbffde5d | ||
|
|
c37735f2e8 | ||
|
|
165abc7bea | ||
|
|
7aaafb90e3 | ||
|
|
f07c60afb0 | ||
|
|
6adbba54ce | ||
|
|
97db4d714a | ||
|
|
12ce669566 | ||
|
|
4496e1d509 | ||
|
|
3b3f37365a | ||
|
|
22c91be127 | ||
|
|
3ec3e9672e | ||
|
|
86daa70ccb | ||
|
|
db97c3b2d4 | ||
|
|
4f298bbc8c | ||
|
|
8113f794ab | ||
|
|
14c18bd668 | ||
|
|
f779f0345e | ||
|
|
ebacfd43be | ||
|
|
e4a7172517 | ||
|
|
3747eaa3a7 | ||
|
|
761d8d1c03 | ||
|
|
4e7f720214 | ||
|
|
757c3a8aed | ||
|
|
87b0ae6614 | ||
|
|
920161b920 | ||
|
|
e7f7dcbb78 | ||
|
|
cc4a97db28 | ||
|
|
b546aeb440 | ||
|
|
99679a800d | ||
|
|
7b9b0d8a84 | ||
|
|
8e153cd92f | ||
|
|
d509abdd5c | ||
|
|
96c51af15a | ||
|
|
68004e1d34 | ||
|
|
fcedea110d | ||
|
|
68aedf07ae | ||
|
|
094f7cea94 | ||
|
|
765a749959 | ||
|
|
cf7983ca11 | ||
|
|
609039baeb | ||
|
|
03f1a3dbc0 | ||
|
|
75dc9d4d1d | ||
|
|
5beeeb958b | ||
|
|
a22f032924 | ||
|
|
3e034c85d6 | ||
|
|
d3c5feaf1b | ||
|
|
96c62f556b | ||
|
|
ebdad3f7c7 | ||
|
|
2fc2f1ddb3 | ||
|
|
a1af6e3892 | ||
|
|
726acb9c29 | ||
|
|
54fde33a20 | ||
|
|
b8cc75c6b4 | ||
|
|
b13fe7f3e4 | ||
|
|
81372d6a6b | ||
|
|
918f8816c5 | ||
|
|
bf981935cb | ||
|
|
1fa92f78e4 | ||
|
|
07564bbde3 | ||
|
|
4014e93155 | ||
|
|
f81224a2a6 | ||
|
|
8760152159 | ||
|
|
5694f30a94 | ||
|
|
156478b381 | ||
|
|
ad416b9cb2 | ||
|
|
2e39a5e573 | ||
|
|
cab099d77f | ||
|
|
0b5e93fd60 | ||
|
|
6e2ba78204 | ||
|
|
115f5ae6a3 | ||
|
|
bf12016315 | ||
|
|
b544931ee5 | ||
|
|
9cef626b28 | ||
|
|
708d382a3f | ||
|
|
f24ea4a5f8 | ||
|
|
6ddd09ff1f | ||
|
|
ddc560e862 | ||
|
|
6f452c62de | ||
|
|
76bb95098c | ||
|
|
0e241f56fb | ||
|
|
8ac3bb9711 | ||
|
|
ff62f8821a | ||
|
|
90c433443f | ||
|
|
8a37663c89 | ||
|
|
bc4015ac50 | ||
|
|
f13c0d78a8 | ||
|
|
cc3871adf6 | ||
|
|
3e52beef14 | ||
|
|
48403ce940 | ||
|
|
6564df8082 | ||
|
|
73202e1483 | ||
|
|
dc60e97415 | ||
|
|
ad40d7d8a9 | ||
|
|
f88f71d933 | ||
|
|
d688dd02c8 | ||
|
|
456c99d7db | ||
|
|
e4f03fac4b | ||
|
|
2cb72e1f48 | ||
|
|
d800b97f69 | ||
|
|
0674e04ee1 | ||
|
|
d56f321aad | ||
|
|
bedd2bbb23 | ||
|
|
27ef7ce560 | ||
|
|
775ebd3b1e | ||
|
|
49c7d83840 | ||
|
|
a30469f6ec | ||
|
|
045f9ef827 | ||
|
|
abc13575c9 | ||
|
|
958b824dbc | ||
|
|
7b5f5abd22 | ||
|
|
5b7060c6a3 | ||
|
|
bbcba005c0 | ||
|
|
11d8b90f88 | ||
|
|
b531ec9b50 | ||
|
|
79790303a9 | ||
|
|
11c45a67b3 | ||
|
|
413ea07cbd | ||
|
|
890148051f | ||
|
|
3ba5d1f3fd | ||
|
|
ef5a0c3f75 | ||
|
|
0323b2783b | ||
|
|
b1e8bc4a46 | ||
|
|
e3a33d102e | ||
|
|
b9a3ed1d74 | ||
|
|
cb0d4e8bd7 | ||
|
|
5ee7bdc55e | ||
|
|
35ed4e20f0 | ||
|
|
a5faf0699a | ||
|
|
cb249b30af | ||
|
|
bb1e3a2c72 | ||
|
|
540b8d7c13 | ||
|
|
99e524589c | ||
|
|
295343be85 | ||
|
|
6f61d2246d | ||
|
|
e0cd4ed379 | ||
|
|
9f3269bce7 | ||
|
|
464fabc3bb | ||
|
|
c187b948d9 | ||
|
|
eb85ee4d35 | ||
|
|
de6bc02ad8 | ||
|
|
d355b3167f | ||
|
|
6cd846dfd8 | ||
|
|
0c102f5324 | ||
|
|
f50596c4a1 | ||
|
|
5d289ce023 | ||
|
|
2722e8482d | ||
|
|
895dcf5a30 | ||
|
|
ac25c9cd7f | ||
|
|
47d00d1f27 | ||
|
|
6bab805528 | ||
|
|
6efd28d904 | ||
|
|
04329bf171 | ||
|
|
3d56b6864e | ||
|
|
e2e675e469 | ||
|
|
aceb98b4a0 | ||
|
|
b848faa2c0 | ||
|
|
ea04f5391e | ||
|
|
58e61e514a | ||
|
|
b91918b04d | ||
|
|
8032fa0bcc | ||
|
|
1f0c641610 | ||
|
|
37fa9345cf | ||
|
|
2c31032a1c | ||
|
|
aeb85486c4 | ||
|
|
4f5fe6723b | ||
|
|
53a8e6df51 | ||
|
|
f45409e456 | ||
|
|
34df600350 | ||
|
|
255640a385 | ||
|
|
442bcf7e4f | ||
|
|
3a8540a439 | ||
|
|
681038cbd4 | ||
|
|
bb8c450452 | ||
|
|
5e41de8edd | ||
|
|
47f7987210 | ||
|
|
3515aee8e8 | ||
|
|
23223f3925 | ||
|
|
f049973349 | ||
|
|
2cdef91d11 | ||
|
|
297ec33e8e | ||
|
|
dc55959df4 | ||
|
|
311b64acd1 | ||
|
|
89f11ab630 | ||
|
|
9c68a7970d | ||
|
|
18d619efa1 | ||
|
|
6490c67a6c | ||
|
|
8cdf87d72b | ||
|
|
46da6d0ddc | ||
|
|
89b9f0a4f9 | ||
|
|
887f1f7c71 | ||
|
|
c1f7b665d5 | ||
|
|
26fc6b7056 | ||
|
|
3d45db2606 | ||
|
|
91603945ef | ||
|
|
d6df3b980c | ||
|
|
d1185d0f5f | ||
|
|
f35132e182 | ||
|
|
09d22a9f2d | ||
|
|
b0ee05f07d | ||
|
|
bb33c11a6b | ||
|
|
728152a31c | ||
|
|
048f4bdf90 | ||
|
|
8c405b251f | ||
|
|
53ba09a2fe | ||
|
|
0d62c5ecfa | ||
|
|
44bb1e6803 | ||
|
|
6f69f3b8f5 | ||
|
|
d97576678d | ||
|
|
88bf4f9903 | ||
|
|
f07227e560 | ||
|
|
b197c678ef | ||
|
|
d13981b489 | ||
|
|
90d4681ae8 | ||
|
|
ce228630ce | ||
|
|
855fdee332 | ||
|
|
f8745636f2 | ||
|
|
aa07ff1682 | ||
|
|
4c3e310634 | ||
|
|
8b52330304 | ||
|
|
200dc1c91a | ||
|
|
531d4aaefc | ||
|
|
f8d98fb66f | ||
|
|
5b2472853b | ||
|
|
2dc234a94d | ||
|
|
fdb43b2c53 | ||
|
|
2161d1aa2f | ||
|
|
08c8534d39 | ||
|
|
9e7914e0b6 | ||
|
|
daebfec0a2 | ||
|
|
e81411cb9b | ||
|
|
6cdae16752 | ||
|
|
65a0c6cb23 | ||
|
|
36d0550bf9 | ||
|
|
92aee997da | ||
|
|
63e6f3a3fa | ||
|
|
bfafbad9dc | ||
|
|
4435fead5a | ||
|
|
e73715f30e | ||
|
|
f31e9c0d81 | ||
|
|
2d1a4737db | ||
|
|
91f89793da | ||
|
|
d201644a5b | ||
|
|
982dd001ef | ||
|
|
80bd4d134e | ||
|
|
92fda348cd | ||
|
|
ef3fdb7555 | ||
|
|
9eb30ffab3 | ||
|
|
0bf6c25a14 | ||
|
|
5bb7a7d30c | ||
|
|
85c9319dfd | ||
|
|
3fea051691 | ||
|
|
7826fdffeb | ||
|
|
1105605370 | ||
|
|
3de3dd426b | ||
|
|
0c7187d53f | ||
|
|
12db53d1eb | ||
|
|
cebde9d4c0 | ||
|
|
9395165916 | ||
|
|
a8daa2c77e | ||
|
|
49c873c858 | ||
|
|
c6fc5765f3 | ||
|
|
62cbbf57e7 | ||
|
|
b81c5636cc | ||
|
|
d867649a93 | ||
|
|
cd08259012 | ||
|
|
e814af1af5 | ||
|
|
ecbff16a88 | ||
|
|
baffa4a38c | ||
|
|
fad507d2dd | ||
|
|
053ee8284d | ||
|
|
4f05fa9375 | ||
|
|
7ecf1bcf94 | ||
|
|
f030cdcb02 | ||
|
|
7474a1868e | ||
|
|
9a4d90790a | ||
|
|
7e38ee07c7 | ||
|
|
b9574e2d67 | ||
|
|
6431613363 | ||
|
|
a00392166c | ||
|
|
bfcad6c5f2 | ||
|
|
7daf2162ef | ||
|
|
dec8d75083 | ||
|
|
f486f8de1d | ||
|
|
93b1e9c371 | ||
|
|
6440f57467 | ||
|
|
25451eb763 | ||
|
|
dbefb80f63 | ||
|
|
889d68dc7b | ||
|
|
42dbc04ff9 | ||
|
|
82c8ef1e4b | ||
|
|
4deb45df3c | ||
|
|
4b02960fd1 | ||
|
|
15e5564b12 | ||
|
|
e66241ddcb | ||
|
|
d3990a6c55 | ||
|
|
be1d081629 | ||
|
|
fafb524a47 | ||
|
|
da1b9ccac7 | ||
|
|
7b97e1ca26 | ||
|
|
7f11549337 | ||
|
|
987e0ddd4e | ||
|
|
8fd097836f | ||
|
|
5acee68987 | ||
|
|
25a1ca5a9f | ||
|
|
32af107699 | ||
|
|
b929e16f2c | ||
|
|
1c942186aa | ||
|
|
d9f8785372 | ||
|
|
8758d74e32 | ||
|
|
6448a7db9e | ||
|
|
46d1da7cd3 | ||
|
|
77c05a4d4f | ||
|
|
4024334c0c | ||
|
|
3294b27029 | ||
|
|
0d4747e8e9 | ||
|
|
1ebc648158 | ||
|
|
7e5bfd4b10 | ||
|
|
5074f4d7af | ||
|
|
22feec49cb | ||
|
|
4b59876d8b | ||
|
|
3c278bc930 | ||
|
|
0221d29ecb | ||
|
|
130b56f02d | ||
|
|
e86f5f4c3c | ||
|
|
3b0701e772 | ||
|
|
9874dce520 | ||
|
|
7ff492df6c | ||
|
|
51b59ae103 | ||
|
|
8888807780 | ||
|
|
d19f7d6b53 | ||
|
|
07de4e5015 |
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
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -21,7 +21,7 @@ What to do to reproduce the problem.
|
||||
|
||||
**If you're using a build with a tray icon, right-click on it and press "Open logs" to access your logs.**
|
||||
|
||||
When you notice the problem, check the output of `jfa-go`. If the problem is not obvious (e.g a panic (red text) or 'ERROR' log), re-run jfa-go with the `-debug` argument and reproduce the problem. You should then take a screenshot of the output, or paste it here, preferably between \`\`\` tags (e.g \`\`\``Log here`\`\`\`). Remember to censor any personal information.
|
||||
When you notice the problem, check the output of `jfa-go` or get the logs by pressing the "Logs" button in the Settings tab. If the problem is not obvious (e.g a panic (red text) or 'ERROR' log), re-run jfa-go with the `-debug` argument and reproduce the problem. You should then take a screenshot of the output, or paste it here, preferably between \`\`\` tags (e.g \`\`\``Log here`\`\`\`). Remember to censor any personal information.
|
||||
|
||||
|
||||
If nothing catches your eye in the log, access the admin page via your browser, go into the console (Right click > Inspect Element > Console), refresh, reproduce the problem then paste the output here in the same way as above.
|
||||
|
||||
7
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
site/node_modules/
|
||||
site/out/
|
||||
site/tempts/
|
||||
mail/*.html
|
||||
dist/
|
||||
build/
|
||||
@@ -20,3 +21,9 @@ cl.md
|
||||
mautrix/
|
||||
tempts/
|
||||
matacc.txt
|
||||
scripts/langmover/lang
|
||||
scripts/langmover/lang2
|
||||
scripts/langmover/out
|
||||
tinyproxy.conf
|
||||
static/banner.svg
|
||||
start.sh
|
||||
|
||||
150
.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,43 +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
|
||||
- npx esbuild --bundle css/base.css --outfile=./data/web/css/bundle.css --external:remixicon.css --minify
|
||||
- cp node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 data/web/css/
|
||||
- cp -r html data/
|
||||
- cp -r lang data/
|
||||
- cp LICENSE 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/
|
||||
- npx esbuild --bundle ts/admin.ts --outfile=./data/web/js/admin.js --minify
|
||||
- npx esbuild --bundle ts/pwr.ts --outfile=./data/web/js/pwr.js --minify
|
||||
- npx esbuild --bundle ts/form.ts --outfile=./data/web/js/form.js --minify
|
||||
- npx esbuild --bundle ts/setup.ts --outfile=./data/web/js/setup.js --minify
|
||||
- npx esbuild --bundle ts/crash.ts --outfile=./data/crash.js --minify
|
||||
- cp html/crash.html data/
|
||||
- npx uncss data/crash.html --csspath web/css --output data/bundle.css
|
||||
- node scripts/inline.js root data data/crash.html data/crash.html
|
||||
- rm data/bundle.css
|
||||
- mv data/crash.html data/html/
|
||||
- go get -u github.com/swaggo/swag/cmd/swag
|
||||
- swag init -g main.go
|
||||
- npm i
|
||||
- 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.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,{{ .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
|
||||
@@ -54,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 -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:
|
||||
@@ -65,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,{{ .Env.JFA_GO_TAG }}
|
||||
ldflags:
|
||||
- -s -w -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary
|
||||
- -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:
|
||||
@@ -124,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
|
||||
@@ -133,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
|
||||
@@ -142,7 +161,7 @@ nfpms:
|
||||
license: MIT
|
||||
vendor: hrfee.dev
|
||||
version_metadata: git
|
||||
builds:
|
||||
ids:
|
||||
- linux-tray
|
||||
contents:
|
||||
- src: ./LICENSE
|
||||
@@ -158,10 +177,13 @@ nfpms:
|
||||
replaces:
|
||||
- jfa-go
|
||||
dependencies:
|
||||
- libappindicator3-1
|
||||
- libayatana-appindicator
|
||||
- libolm-dev
|
||||
rpm:
|
||||
dependencies:
|
||||
- libappindicator-gtk3
|
||||
- libolm
|
||||
apk:
|
||||
dependencies:
|
||||
- libappindicator
|
||||
- libayatana-appindicator
|
||||
- olm
|
||||
|
||||
51
.woodpecker/git-binary.yaml
Normal file
@@ -0,0 +1,51 @@
|
||||
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: build
|
||||
image: docker.io/hrfee/jfa-go-build-docker:latest
|
||||
environment:
|
||||
JFA_GO_SNAPSHOT: y
|
||||
JFA_GO_BUILT_BY:
|
||||
from_secret: BUILT_BY
|
||||
commands:
|
||||
- curl -sfL https://goreleaser.com/static/run > goreleaser
|
||||
- chmod +x goreleaser
|
||||
- ./scripts/version.sh ./goreleaser --snapshot --skip=publish --clean
|
||||
- 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: 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 --upload ./dist/*.zip ./dist/*.rpm ./dist/*.apk --tag internal-git=true'
|
||||
29
.woodpecker/git-docker.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
image: docker.io/woodpeckerci/plugin-docker-buildx
|
||||
secrets: [ BUILT_BY ]
|
||||
settings:
|
||||
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: $BUILT_BY
|
||||
- name: buildrone
|
||||
image: docker.io/python
|
||||
environment:
|
||||
BUILDRONE_KEY:
|
||||
from_secret: BUILDRONE_KEY
|
||||
commands:
|
||||
- wget https://builds.hrfee.pw/upload.py
|
||||
- pip install requests
|
||||
- python upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-unstable=true
|
||||
|
||||
41
.woodpecker/stable-binary.yaml
Normal file
@@ -0,0 +1,41 @@
|
||||
when:
|
||||
- event: tag
|
||||
branch: main
|
||||
|
||||
clone:
|
||||
git:
|
||||
image: woodpeckerci/plugin-git
|
||||
settings:
|
||||
tags: true
|
||||
partial: false
|
||||
depth: 0
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
image: docker.io/hrfee/jfa-go-build-docker:latest
|
||||
environment:
|
||||
JFA_GO_BUILT_BY:
|
||||
from_secret: BUILT_BY
|
||||
commands:
|
||||
- curl -sfL https://goreleaser.com/static/run > ../goreleaser
|
||||
- chmod +x ../goreleaser
|
||||
- ./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: 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'
|
||||
29
.woodpecker/stable-docker.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
when:
|
||||
- event: tag
|
||||
branch: main
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
image: docker.io/woodpeckerci/plugin-docker-buildx
|
||||
secrets: [ BUILT_BY ]
|
||||
settings:
|
||||
username:
|
||||
from_secret: DOCKER_USERNAME
|
||||
password:
|
||||
from_secret: DOCKER_TOKEN
|
||||
repo: docker.io/hrfee/jfa-go
|
||||
tags: latest
|
||||
registry: docker.io
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
build_args:
|
||||
- BUILT_BY: $BUILT_BY
|
||||
- name: buildrone
|
||||
image: docker.io/python
|
||||
environment:
|
||||
BUILDRONE_KEY:
|
||||
from_secret: BUILDRONE_KEY
|
||||
commands:
|
||||
- wget https://builds.hrfee.pw/upload.py
|
||||
- pip install requests
|
||||
- python upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-stable=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/).
|
||||
|
||||
32
Dockerfile
@@ -1,30 +1,26 @@
|
||||
FROM --platform=$BUILDPLATFORM golang:latest AS support
|
||||
# Use this instead if hrfee/jfa-go-build-docker doesn't support your architecture
|
||||
# FROM --platform=$BUILDPLATFORM golang:latest AS support
|
||||
FROM --platform=$BUILDPLATFORM docker.io/hrfee/jfa-go-build-docker:latest AS support
|
||||
# FROM --platform=$BUILDPLATFORM jfa-go-bd 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_14.x | bash -) \
|
||||
&& apt-get install nodejs \
|
||||
&& (cd /opt/build; make configuration npm email typescript bundle-css inline 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 curl -sfL https://goreleaser.com/static/run > /goreleaser && chmod +x /goreleaser
|
||||
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 golang:bookworm AS final
|
||||
ARG TARGETARCH
|
||||
ENV GOARCH=$TARGETARCH
|
||||
|
||||
COPY --from=support /opt/build /opt/build
|
||||
COPY --from=support /opt/build/dist/*_linux_${TARGETARCH}* /opt/jfa-go
|
||||
COPY --from=support /opt/build/build/data /opt/jfa-go/data
|
||||
|
||||
RUN (cd /opt/build; make compile INTERNAL=off UPDATER=docker)
|
||||
|
||||
FROM golang:latest
|
||||
|
||||
COPY --from=build /opt/build/build /opt/jfa-go
|
||||
RUN apt-get update -y && apt-get install libolm-dev -y
|
||||
|
||||
EXPOSE 8056
|
||||
EXPOSE 8057
|
||||
|
||||
CMD [ "/opt/jfa-go/jfa-go", "-data", "/data" ]
|
||||
|
||||
|
||||
|
||||
2
LICENSE
@@ -2,7 +2,7 @@
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Harvey Tindall
|
||||
Copyright (c) 2023 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
|
||||
|
||||
209
Makefile
@@ -1,3 +1,6 @@
|
||||
.PHONY: configuration email typescript swagger copy compile compress inline-css variants-html install clean npm config-description config-default precompile
|
||||
.DEFAULT_GOAL := all
|
||||
|
||||
GOESBUILD ?= off
|
||||
ifeq ($(GOESBUILD), on)
|
||||
ESBUILD := esbuild
|
||||
@@ -6,12 +9,16 @@ else
|
||||
endif
|
||||
GOBINARY ?= go
|
||||
|
||||
CSSVERSION ?= v3
|
||||
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)
|
||||
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)
|
||||
@@ -22,14 +29,16 @@ endif
|
||||
|
||||
INTERNAL ?= on
|
||||
TRAY ?= off
|
||||
E2EE ?= off
|
||||
E2EE ?= on
|
||||
TAGS := -tags "
|
||||
|
||||
ifeq ($(INTERNAL), on)
|
||||
DATA := data
|
||||
COMPDEPS := $(BUILDDEPS)
|
||||
else
|
||||
DATA := build/data
|
||||
TAGS := $(TAGS) external
|
||||
COMPDEPS :=
|
||||
endif
|
||||
|
||||
ifeq ($(TRAY), on)
|
||||
@@ -50,17 +59,19 @@ endif
|
||||
DEBUG ?= off
|
||||
ifeq ($(DEBUG), on)
|
||||
SOURCEMAP := --sourcemap
|
||||
TYPECHECK := tsc -noEmit --project ts/tsconfig.json
|
||||
MINIFY :=
|
||||
TYPECHECK := npx tsc -noEmit --project ts/tsconfig.json
|
||||
# jank
|
||||
COPYTS := rm -r $(DATA)/web/js/ts; cp -r tempts $(DATA)/web/js
|
||||
UNCSS := cp $(DATA)/web/css/bundle.css $(DATA)/bundle.css
|
||||
TAILWIND := --content ""
|
||||
COPYTS := rm -r $(DATA)/web/js/ts; cp -r tempts $(DATA)/web/js/ts
|
||||
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
|
||||
@@ -72,82 +83,123 @@ else
|
||||
RACEDETECTOR :=
|
||||
endif
|
||||
|
||||
npm:
|
||||
$(info installing npm dependencies)
|
||||
npm install
|
||||
@if [ "$(GOESBUILD)" = "off" ]; then\
|
||||
npm install esbuild;\
|
||||
else\
|
||||
go get -u github.com/evanw/esbuild/cmd/esbuild;\
|
||||
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
|
||||
ifeq ($(GOESBUILD), on)
|
||||
NPMIGNOREOPTIONAL := --no-optional
|
||||
NPMOPTS := $(NPMIGNOREOPTIONAL); $(ESBUILDINSTALL)
|
||||
else
|
||||
NPMOPTS :=
|
||||
endif
|
||||
|
||||
ifeq (, $(shell which swag))
|
||||
SWAGINSTALL := $(GOBINARY) install github.com/swaggo/swag/cmd/swag@latest
|
||||
else
|
||||
SWAGINSTALL :=
|
||||
endif
|
||||
|
||||
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
|
||||
mkdir -p $(DATA)/web/css
|
||||
|
||||
$(CONFIG_DEFAULT): $(CONFIG_BASE)
|
||||
$(info Generating config-default.ini)
|
||||
python3 scripts/generate_ini.py -i config/config-base.json -o $(DATA)/config-default.ini
|
||||
go run scripts/ini/main.go -in $(CONFIG_BASE) -out $(DATA)/config-default.ini
|
||||
|
||||
email:
|
||||
configuration: $(CONFIG_DEFAULT)
|
||||
|
||||
EMAIL_SRC = $(wildcard mail/*)
|
||||
EMAIL_TARGET = $(DATA)/confirmation.html
|
||||
$(EMAIL_TARGET): $(EMAIL_SRC)
|
||||
$(info Generating email html)
|
||||
python3 scripts/compile_mjml.py -o $(DATA)/
|
||||
npx mjml mail/*.mjml -o $(DATA)/
|
||||
$(info Copying plaintext mail)
|
||||
cp mail/*.txt $(DATA)/
|
||||
|
||||
typescript:
|
||||
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)
|
||||
$(adding dark variants to typescript)
|
||||
-rm -r tempts
|
||||
rm -rf tempts
|
||||
cp -r ts tempts
|
||||
scripts/dark-variant.sh ts tempts
|
||||
scripts/dark-variant.sh ts tempts/modules
|
||||
$(adding dark variants to typescript)
|
||||
scripts/dark-variant.sh tempts
|
||||
scripts/dark-variant.sh tempts/modules
|
||||
$(info compiling typescript)
|
||||
-mkdir -p $(DATA)/web/js
|
||||
-$(ESBUILD) --bundle tempts/admin.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/admin.js --minify
|
||||
-$(ESBUILD) --bundle tempts/pwr.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/pwr.js --minify
|
||||
-$(ESBUILD) --bundle tempts/form.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/form.js --minify
|
||||
-$(ESBUILD) --bundle tempts/setup.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/setup.js --minify
|
||||
-$(ESBUILD) --bundle tempts/crash.ts --outfile=./$(DATA)/crash.js --minify
|
||||
$(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
|
||||
SWAGGER_SRC = $(wildcard api*.go) $(wildcard *auth.go) views.go
|
||||
SWAGGER_TARGET = docs/docs.go
|
||||
$(SWAGGER_TARGET): $(SWAGGER_SRC)
|
||||
$(SWAGINSTALL)
|
||||
swag init -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 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:
|
||||
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)
|
||||
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/%)
|
||||
CSS_SRC = $(wildcard css/*.css)
|
||||
CSS_TARGET = $(DATA)/web/css/part-bundle.css
|
||||
CSS_FULLTARGET = $(CSS_BUNDLE)
|
||||
ALL_CSS_SRC = $(ICON_SRC) $(CSS_SRC)
|
||||
ALL_CSS_TARGET = $(ICON_TARGET)
|
||||
|
||||
$(CSS_FULLTARGET): $(TYPESCRIPT_TARGET) $(VARIANTS_TARGET) $(ALL_CSS_SRC) $(wildcard html/*.html)
|
||||
$(info copying fonts)
|
||||
cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 $(DATA)/web/css/
|
||||
$(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))
|
||||
cp $(CONFIG_BASE) $(DATA)/
|
||||
$(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)/
|
||||
@@ -155,14 +207,27 @@ copy:
|
||||
cp -r lang $(DATA)/
|
||||
cp LICENSE $(DATA)/
|
||||
|
||||
# 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 =
|
||||
ifeq ($(INTERNAL), on)
|
||||
COMPDEPS = $(BUILDDEPS)
|
||||
endif
|
||||
|
||||
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)
|
||||
|
||||
all: $(BUILDDEPS) $(GO_TARGET)
|
||||
|
||||
compress:
|
||||
upx --lzma $(GO_TARGET)
|
||||
|
||||
install:
|
||||
cp -r build $(DESTDIR)/jfa-go
|
||||
@@ -174,4 +239,6 @@ clean:
|
||||
-rm docs/docs.go docs/swagger.json docs/swagger.yaml
|
||||
go clean
|
||||
|
||||
all: configuration npm email typescript variants-html bundle-css inline swagger copy compile
|
||||
npm:
|
||||
$(info installing npm dependencies)
|
||||
npm install $(NPMOPTS)
|
||||
|
||||
76
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)
|
||||
@@ -7,29 +7,41 @@
|
||||
|
||||
##### Downloads:
|
||||
##### [docker](#docker) | [debian/ubuntu](#debian) | [arch (aur)](#aur) | [other platforms](#other-platforms)
|
||||
---
|
||||
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
|
||||
😂).
|
||||
---
|
||||
## 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).
|
||||
|
||||
#### Does/Will it still work?
|
||||
jfa-go currently works on Jellyfin 10.9.8, the latest version as of 31/07/2024. I should be able to maintain compatability in the future, unless any big changes occur.
|
||||
|
||||
#### Alternatives
|
||||
If you want a bit more of a guarantee of support, I've seen these projects mentioned although haven't tried them myself.
|
||||
|
||||
* [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.
|
||||
* [jfa-go now integrates with Jellyseerr, much like Ombi, but better.](https://github.com/hrfee/jfa-go/pull/351)
|
||||
* [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 [Emby](https://emby.media/) as 2nd class) that provides invite-based account creation as well as other features that make one's 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
|
||||
* Send invites via a link and/or email, discord, telegram or matrix
|
||||
* 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 and contact method verificatoin 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 the user via email/telegram.
|
||||
* Notifications: Get notified when someone creates an account, or an invite expires.
|
||||
* 🔗 Ombi/Jellyseerr Integration: Automatically creates and synchronizes details for new accounts. Supports setting permissions with the Profiles feature. **Ombi integration use is risky, see [wiki](https://wiki.jfa-go.com/docs/ombi/)**.
|
||||
* Account management: Bulk or individually; apply settings, delete, disable/enable, send messages and much more.
|
||||
* 📣 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
|
||||
* Telegram/Discord/Matrix Integration: Verify users via a chat bot, and send Password Resets, Announcements, etc. through it.
|
||||
* "My Account" Page: Allows users to reset their password, manage contact details, view their account expiry date, and send referrals. Can be customized with markdown.
|
||||
* Referrals: Users can be given special invites to send to their friends and families, similar to some invite-only services like Bluesky.
|
||||
* 🔑 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.
|
||||
* Can also be done through the "My Account" page if enabled.
|
||||
* Admin Notifications: Get notified when someone creates an account, or an invite expires.
|
||||
* 🌓 Customizations
|
||||
* Customize emails with variables and markdown
|
||||
* Specify contact and help messages to appear in emails and pages
|
||||
@@ -37,19 +49,18 @@ a rewrite of [jellyfin-accounts](https://github.com/hrfee/jellyfin-accounts) (or
|
||||
|
||||
#### 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.
|
||||
|
||||
`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
|
||||
docker create \
|
||||
@@ -57,7 +68,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
|
||||
```
|
||||
@@ -65,7 +76,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
|
||||
@@ -79,7 +90,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
|
||||
# ------
|
||||
```
|
||||
@@ -93,7 +104,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`.
|
||||
@@ -132,6 +143,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
|
||||
```
|
||||
@@ -139,18 +152,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 [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
|
||||
}
|
||||
190
api-activities.go
Normal file
@@ -0,0 +1,190 @@
|
||||
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"
|
||||
}
|
||||
|
||||
// @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
|
||||
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)
|
||||
}
|
||||
|
||||
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, query, q)
|
||||
}
|
||||
query = nq
|
||||
}
|
||||
|
||||
if query == nil {
|
||||
query = &badgerhold.Query{}
|
||||
}
|
||||
|
||||
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),
|
||||
SourceUsername: act.MustGetSourceUsername(app.jf),
|
||||
}
|
||||
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
|
||||
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)
|
||||
}
|
||||
118
api-backups.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"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) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
449
api-invites.go
Normal file
@@ -0,0 +1,449 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
"github.com/itchyny/timefmt-go"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
)
|
||||
|
||||
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()
|
||||
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.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()
|
||||
inv, match := app.storage.GetInvitesKey(code)
|
||||
if !match {
|
||||
return false
|
||||
}
|
||||
expiry := inv.ValidTill
|
||||
if currentTime.After(expiry) {
|
||||
app.deleteExpiredInvite(inv)
|
||||
match = false
|
||||
} else if used {
|
||||
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--
|
||||
}
|
||||
newInv.UsedBy = append(newInv.UsedBy, []string{username, strconv.FormatInt(currentTime.Unix(), 10)})
|
||||
if !del {
|
||||
app.storage.SetInvitesKey(code, newInv)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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.Code, data, app, 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 Create a new invite.
|
||||
// @Produce json
|
||||
// @Param generateInviteDTO body generateInviteDTO true "New invite request object"
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Router /invites [post]
|
||||
// @Security Bearer
|
||||
// @tags Invites
|
||||
func (app *appContext) GenerateInvite(gc *gin.Context) {
|
||||
var req generateInviteDTO
|
||||
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))
|
||||
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 {
|
||||
invite.NoLimit = true
|
||||
} else {
|
||||
invite.RemainingUses = req.RemainingUses
|
||||
}
|
||||
} else {
|
||||
invite.RemainingUses = 1
|
||||
}
|
||||
invite.UserExpiry = req.UserExpiry
|
||||
if invite.UserExpiry {
|
||||
invite.UserMonths = req.UserMonths
|
||||
invite.UserDays = req.UserDays
|
||||
invite.UserHours = req.UserHours
|
||||
invite.UserMinutes = req.UserMinutes
|
||||
}
|
||||
invite.ValidTill = validTill
|
||||
if req.SendTo != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
|
||||
addressValid := false
|
||||
discord := ""
|
||||
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
|
||||
users := app.discord.GetUsers(req.SendTo)
|
||||
if len(users) == 0 {
|
||||
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipNoUser, req.SendTo)
|
||||
} else if len(users) > 1 {
|
||||
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipMultiUser, 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(invite.Code, invite, app, false)
|
||||
if err != nil {
|
||||
// Slight misuse of the template
|
||||
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, req.SendTo, err)
|
||||
|
||||
app.err.Printf(lm.FailedConstructInviteMessage, invite.Code, 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(lm.FailedSendInviteMessage, invite.Code, req.SendTo, err)
|
||||
app.err.Println(invite.SendTo)
|
||||
} else {
|
||||
app.info.Printf(lm.SentInviteMessage, invite.Code, req.SendTo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.Profile != "" {
|
||||
if _, ok := app.storage.GetProfileKey(req.Profile); ok {
|
||||
invite.Profile = req.Profile
|
||||
} else {
|
||||
invite.Profile = "Default"
|
||||
}
|
||||
}
|
||||
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 invites.
|
||||
// @Produce json
|
||||
// @Success 200 {object} getInvitesDTO
|
||||
// @Router /invites [get]
|
||||
// @Security Bearer
|
||||
// @tags Invites
|
||||
func (app *appContext) GetInvites(gc *gin.Context) {
|
||||
currentTime := time.Now()
|
||||
app.checkInvites()
|
||||
var invites []inviteDTO
|
||||
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: inv.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,
|
||||
UserLabel: inv.UserLabel,
|
||||
}
|
||||
if len(inv.UsedBy) != 0 {
|
||||
invite.UsedBy = map[string]int64{}
|
||||
for _, pair := range inv.UsedBy {
|
||||
// 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)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedParseTime, err)
|
||||
}
|
||||
unix = date.Unix()
|
||||
}
|
||||
invite.UsedBy[pair[0]] = unix
|
||||
}
|
||||
}
|
||||
invite.RemainingUses = 1
|
||||
if inv.RemainingUses != 0 {
|
||||
invite.RemainingUses = inv.RemainingUses
|
||||
}
|
||||
if inv.SendTo != "" {
|
||||
invite.SendTo = inv.SendTo
|
||||
}
|
||||
if len(inv.Notify) != 0 {
|
||||
var addressOrID string
|
||||
if app.config.Section("ui").Key("jellyfin_login").MustBool(false) {
|
||||
addressOrID = gc.GetString("jfId")
|
||||
} else {
|
||||
addressOrID = app.config.Section("ui").Key("email").String()
|
||||
}
|
||||
if _, ok := inv.Notify[addressOrID]; ok {
|
||||
if _, ok = inv.Notify[addressOrID]["notify-expiry"]; ok {
|
||||
invite.NotifyExpiry = inv.Notify[addressOrID]["notify-expiry"]
|
||||
}
|
||||
if _, ok = inv.Notify[addressOrID]["notify-creation"]; ok {
|
||||
invite.NotifyCreation = inv.Notify[addressOrID]["notify-creation"]
|
||||
}
|
||||
}
|
||||
}
|
||||
invites = append(invites, invite)
|
||||
}
|
||||
resp := getInvitesDTO{
|
||||
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 Invites
|
||||
func (app *appContext) SetProfile(gc *gin.Context) {
|
||||
var req inviteProfileDTO
|
||||
gc.BindJSON(&req)
|
||||
// "" means "Don't apply profile"
|
||||
if _, ok := app.storage.GetProfileKey(req.Profile); !ok && req.Profile != "" {
|
||||
app.err.Printf(lm.FailedGetProfile, req.Profile)
|
||||
respond(500, "Profile not found", gc)
|
||||
return
|
||||
}
|
||||
inv, _ := app.storage.GetInvitesKey(req.Invite)
|
||||
inv.Profile = req.Profile
|
||||
app.storage.SetInvitesKey(req.Invite, inv)
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @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 {
|
||||
invite, ok := app.storage.GetInvitesKey(code)
|
||||
if !ok {
|
||||
msg := fmt.Sprintf(lm.InvalidInviteCode, code)
|
||||
app.err.Println(msg)
|
||||
respond(400, msg, 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(lm.FailedGetContactMethod, gc.GetString("jfId"))
|
||||
respond(500, fmt.Sprintf(lm.FailedGetContactMethod, "admin"), 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 {
|
||||
*/
|
||||
for _, notifyType := range []string{"notify-expiry", "notify-creation"} {
|
||||
if _, ok := settings[notifyType]; ok && invite.Notify[address][notifyType] != settings[notifyType] {
|
||||
invite.Notify[address][notifyType] = settings[notifyType]
|
||||
app.debug.Printf(lm.SetAdminNotify, notifyType, settings[notifyType], address)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
app.storage.SetInvitesKey(code, invite)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Delete an invite.
|
||||
// @Produce json
|
||||
// @Param deleteInviteDTO body deleteInviteDTO true "Delete invite object"
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 400 {object} stringResponse
|
||||
// @Router /invites [delete]
|
||||
// @Security Bearer
|
||||
// @tags Invites
|
||||
func (app *appContext) DeleteInvite(gc *gin.Context) {
|
||||
var req deleteInviteDTO
|
||||
gc.BindJSON(&req)
|
||||
inv, ok := app.storage.GetInvitesKey(req.Code)
|
||||
if ok {
|
||||
app.storage.DeleteInvitesKey(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(lm.FailedDeleteInvite, req.Code, "invalid code")
|
||||
respond(400, "Code doesn't exist", gc)
|
||||
}
|
||||
165
api-jellyseerr.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"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.FailedGetUsers, 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
|
||||
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) AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (err error) {
|
||||
_, err = js.MustGetUser(jellyfinID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
contactMethods := map[jellyseerr.NotificationsField]any{}
|
||||
if emailEnabled {
|
||||
err = js.ModifyMainUserSettings(jellyfinID, jellyseerr.MainUserSettings{Email: req.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
|
||||
} else {
|
||||
contactMethods[jellyseerr.FieldEmailEnabled] = req.EmailContact
|
||||
}
|
||||
}
|
||||
if discordEnabled && discord != nil {
|
||||
contactMethods[jellyseerr.FieldDiscord] = discord.ID
|
||||
contactMethods[jellyseerr.FieldDiscordEnabled] = req.DiscordContact
|
||||
}
|
||||
if telegramEnabled && discord != nil {
|
||||
contactMethods[jellyseerr.FieldTelegram] = telegram.ChatID
|
||||
contactMethods[jellyseerr.FieldTelegramEnabled] = req.TelegramContact
|
||||
}
|
||||
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)
|
||||
}
|
||||
803
api-messages.go
Normal file
@@ -0,0 +1,803 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hrfee/jfa-go/jellyseerr"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// @Summary Get a list of email names and IDs.
|
||||
// @Produce json
|
||||
// @Param lang query string false "Language for email titles."
|
||||
// @Success 200 {object} emailListDTO
|
||||
// @Router /config/emails [get]
|
||||
// @Security Bearer
|
||||
// @tags Configuration
|
||||
func (app *appContext) GetCustomContent(gc *gin.Context) {
|
||||
lang := gc.Query("lang")
|
||||
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.MustGetCustomContentKey("UserCreated").Enabled},
|
||||
"InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.MustGetCustomContentKey("InviteExpiry").Enabled},
|
||||
"PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.MustGetCustomContentKey("PasswordReset").Enabled},
|
||||
"UserDeleted": {Name: app.storage.lang.Email[lang].UserDeleted["name"], Enabled: app.storage.MustGetCustomContentKey("UserDeleted").Enabled},
|
||||
"UserDisabled": {Name: app.storage.lang.Email[lang].UserDisabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserDisabled").Enabled},
|
||||
"UserEnabled": {Name: app.storage.lang.Email[lang].UserEnabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserEnabled").Enabled},
|
||||
"UserExpiryAdjusted": {Name: app.storage.lang.Email[lang].UserExpiryAdjusted["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpiryAdjusted").Enabled},
|
||||
"InviteEmail": {Name: app.storage.lang.Email[lang].InviteEmail["name"], Enabled: app.storage.MustGetCustomContentKey("InviteEmail").Enabled},
|
||||
"WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.MustGetCustomContentKey("WelcomeEmail").Enabled},
|
||||
"EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.MustGetCustomContentKey("EmailConfirmation").Enabled},
|
||||
"UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpired").Enabled},
|
||||
"UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.MustGetCustomContentKey("UserLogin").Enabled},
|
||||
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("UserPage").Enabled},
|
||||
"PostSignupCard": {Name: app.storage.lang.Admin[adminLang].Strings["postSignupCard"], Enabled: app.storage.MustGetCustomContentKey("PostSignupCard").Enabled, Description: app.storage.lang.Admin[adminLang].Strings["postSignupCardDescription"]},
|
||||
}
|
||||
|
||||
filter := gc.Query("filter")
|
||||
if filter == "user" {
|
||||
list = emailListDTO{"UserLogin": list["UserLogin"], "UserPage": list["UserPage"]}
|
||||
} else {
|
||||
delete(list, "UserLogin")
|
||||
delete(list, "UserPage")
|
||||
}
|
||||
|
||||
gc.JSON(200, list)
|
||||
}
|
||||
|
||||
// @Summary Sets the corresponding custom content.
|
||||
// @Produce json
|
||||
// @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 content"
|
||||
// @Router /config/emails/{id} [post]
|
||||
// @Security Bearer
|
||||
// @tags Configuration
|
||||
func (app *appContext) SetCustomMessage(gc *gin.Context) {
|
||||
var req CustomContent
|
||||
gc.BindJSON(&req)
|
||||
id := gc.Param("id")
|
||||
if req.Content == "" {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
message, ok := app.storage.GetCustomContentKey(id)
|
||||
if !ok {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
message.Content = req.Content
|
||||
message.Enabled = true
|
||||
app.storage.SetCustomContentKey(id, message)
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Enable/Disable custom content.
|
||||
// @Produce json
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Param enable/disable path string true "enable/disable"
|
||||
// @Param id path string true "ID of email"
|
||||
// @Router /config/emails/{id}/state/{enable/disable} [post]
|
||||
// @Security Bearer
|
||||
// @tags Configuration
|
||||
func (app *appContext) SetCustomMessageState(gc *gin.Context) {
|
||||
id := gc.Param("id")
|
||||
s := gc.Param("state")
|
||||
enabled := false
|
||||
if s == "enable" {
|
||||
enabled = true
|
||||
} else if s != "disable" {
|
||||
respondBool(400, false, gc)
|
||||
}
|
||||
message, ok := app.storage.GetCustomContentKey(id)
|
||||
if !ok {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
message.Enabled = enabled
|
||||
app.storage.SetCustomContentKey(id, message)
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @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
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Param id path string true "ID of email"
|
||||
// @Router /config/emails/{id} [get]
|
||||
// @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, ok := app.storage.GetCustomContentKey(id)
|
||||
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
|
||||
} else if id == "PostSignupCard" {
|
||||
variables = []string{"{username}", "{myAccountURL}"}
|
||||
customMessage.Variables = variables
|
||||
}
|
||||
|
||||
content = customMessage.Content
|
||||
noContent := content == ""
|
||||
if !noContent {
|
||||
variables = customMessage.Variables
|
||||
}
|
||||
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 "UserExpiryAdjusted":
|
||||
if noContent {
|
||||
msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", app, true)
|
||||
}
|
||||
values = app.email.expiryAdjustedValues(username, time.Now(), app.storage.lang.Email[lang].Strings.get("reason"), app, false, true)
|
||||
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", "PostSignupCard":
|
||||
values = map[string]interface{}{}
|
||||
}
|
||||
if err != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" && id != "PostSignupCard" {
|
||||
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++
|
||||
}
|
||||
}
|
||||
customMessage.Variables = variables
|
||||
}
|
||||
if variables == nil {
|
||||
variables = []string{}
|
||||
}
|
||||
app.storage.SetCustomContentKey(id, customMessage)
|
||||
var mail *Message
|
||||
if id != "UserLogin" && id != "UserPage" && id != "PostSignupCard" {
|
||||
mail, err = app.email.constructTemplate("", "<div class=\"preview-content\"></div>", app)
|
||||
if err != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
} else if id == "PostSignupCard" {
|
||||
// Jankiness follows.
|
||||
// Source content from "Success Message" setting.
|
||||
if noContent {
|
||||
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 += "\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 {
|
||||
mail = &Message{
|
||||
HTML: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
|
||||
}
|
||||
mail.Markdown = mail.HTML
|
||||
}
|
||||
gc.JSON(200, customEmailDTO{Content: content, Variables: variables, Conditionals: conditionals, Values: values, HTML: mail.HTML, Plaintext: mail.Text})
|
||||
}
|
||||
|
||||
// @Summary Returns a new Telegram verification PIN, and the bot username.
|
||||
// @Produce json
|
||||
// @Success 200 {object} telegramPinDTO
|
||||
// @Router /telegram/pin [get]
|
||||
// @Security Bearer
|
||||
// @tags Other
|
||||
func (app *appContext) TelegramGetPin(gc *gin.Context) {
|
||||
gc.JSON(200, telegramPinDTO{
|
||||
Token: app.telegram.NewAuthToken(),
|
||||
Username: app.telegram.username,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Link a Jellyfin & Telegram user together via a verification PIN.
|
||||
// @Produce json
|
||||
// @Param telegramSetDTO body telegramSetDTO true "Token and user's Jellyfin ID."
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @Router /users/telegram [post]
|
||||
// @Security Bearer
|
||||
// @tags Other
|
||||
func (app *appContext) TelegramAddUser(gc *gin.Context) {
|
||||
var req telegramSetDTO
|
||||
gc.BindJSON(&req)
|
||||
if req.Token == "" || req.ID == "" {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
tgToken, ok := app.telegram.TokenVerified(req.Token)
|
||||
app.telegram.DeleteVerifiedToken(req.Token)
|
||||
if !ok {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
tgUser := TelegramUser{
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
linkExistingOmbiDiscordTelegram(app)
|
||||
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."
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Success 400 {object} boolResponse
|
||||
// @Success 500 {object} boolResponse
|
||||
// @Router /users/contact [post]
|
||||
// @Security Bearer
|
||||
// @tags Other
|
||||
func (app *appContext) SetContactMethods(gc *gin.Context) {
|
||||
var req SetContactMethodsDTO
|
||||
gc.BindJSON(&req)
|
||||
if req.ID == "" {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
app.setContactMethods(req, gc)
|
||||
}
|
||||
|
||||
func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Context) {
|
||||
jsPrefs := map[jellyseerr.NotificationsField]any{}
|
||||
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 {
|
||||
app.debug.Printf(lm.SetContactPrefForService, lm.Telegram, tgUser.Username, req.Telegram)
|
||||
jsPrefs[jellyseerr.FieldTelegramEnabled] = 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 change {
|
||||
app.debug.Printf(lm.SetContactPrefForService, lm.Discord, dcUser.Username, req.Discord)
|
||||
jsPrefs[jellyseerr.FieldDiscordEnabled] = 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 change {
|
||||
app.debug.Printf(lm.SetContactPrefForService, lm.Matrix, mxUser.UserID, 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 change {
|
||||
app.debug.Printf(lm.SetContactPrefForService, lm.Email, email.Addr, req.Email)
|
||||
jsPrefs[jellyseerr.FieldEmailEnabled] = req.Email
|
||||
}
|
||||
}
|
||||
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
|
||||
err := app.js.ModifyNotifications(req.ID, jsPrefs)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
|
||||
}
|
||||
}
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Returns true/false on whether or not a telegram PIN was verified. Requires bearer auth.
|
||||
// @Produce json
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Param pin path string true "PIN code to check"
|
||||
// @Router /telegram/verified/{pin} [get]
|
||||
// @Security Bearer
|
||||
// @tags Other
|
||||
func (app *appContext) TelegramVerified(gc *gin.Context) {
|
||||
pin := gc.Param("pin")
|
||||
_, ok := app.telegram.TokenVerified(pin)
|
||||
respondBool(200, ok, gc)
|
||||
}
|
||||
|
||||
// @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
|
||||
// @Param pin path string true "PIN code to check"
|
||||
// @Param invCode path string true "invite Code"
|
||||
// @Router /invite/{invCode}/telegram/verified/{pin} [get]
|
||||
// @tags Other
|
||||
func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) {
|
||||
code := gc.Param("invCode")
|
||||
if _, ok := app.storage.GetInvitesKey(code); !ok {
|
||||
respondBool(401, false, gc)
|
||||
return
|
||||
}
|
||||
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.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. NOTE: "/invite" might have been changed in Settings > URL Paths.
|
||||
// @Produce json
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 401 {object} boolResponse
|
||||
// @Param pin path string true "PIN code to check"
|
||||
// @Param invCode path string true "invite Code"
|
||||
// @Router /invite/{invCode}/discord/verified/{pin} [get]
|
||||
// @tags Other
|
||||
func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
|
||||
code := gc.Param("invCode")
|
||||
if _, ok := app.storage.GetInvitesKey(code); !ok {
|
||||
respondBool(401, false, gc)
|
||||
return
|
||||
}
|
||||
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.MethodID().(string)) {
|
||||
delete(app.discord.verifiedTokens, pin)
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
respondBool(200, ok, gc)
|
||||
}
|
||||
|
||||
// @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
|
||||
// @Failure 401 {object} boolResponse
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Param invCode path string true "invite Code"
|
||||
// @Router /invite/{invCode}/discord/invite [get]
|
||||
// @tags Other
|
||||
func (app *appContext) DiscordServerInvite(gc *gin.Context) {
|
||||
if app.discord.InviteChannel.Name == "" {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
code := gc.Param("invCode")
|
||||
if _, ok := app.storage.GetInvitesKey(code); !ok {
|
||||
respondBool(401, false, gc)
|
||||
return
|
||||
}
|
||||
invURL, iconURL := app.discord.NewTempInvite(10*60, 1)
|
||||
if invURL == "" {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
gc.JSON(200, DiscordInviteDTO{invURL, iconURL})
|
||||
}
|
||||
|
||||
// @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
|
||||
// @Failure 401 {object} boolResponse
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Param invCode path string true "invite Code"
|
||||
// @Param MatrixSendPINDTO body MatrixSendPINDTO true "User's Matrix ID."
|
||||
// @Router /invite/{invCode}/matrix/user [post]
|
||||
// @tags Other
|
||||
func (app *appContext) MatrixSendPIN(gc *gin.Context) {
|
||||
code := gc.Param("invCode")
|
||||
if _, ok := app.storage.GetInvitesKey(code); !ok {
|
||||
respondBool(401, false, gc)
|
||||
return
|
||||
}
|
||||
var req MatrixSendPINDTO
|
||||
gc.BindJSON(&req)
|
||||
if req.UserID == "" {
|
||||
respond(400, "errorNoUserID", gc)
|
||||
return
|
||||
}
|
||||
if app.config.Section("matrix").Key("require_unique").MustBool(false) {
|
||||
for _, u := range app.storage.GetMatrix() {
|
||||
if req.UserID == u.UserID {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ok := app.matrix.SendStart(req.UserID)
|
||||
if !ok {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @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
|
||||
// @Param pin path string true "PIN code to check"
|
||||
// @Param invCode path string true "invite Code"
|
||||
// @Param userID path string true "Matrix User ID"
|
||||
// @Router /invite/{invCode}/matrix/verified/{userID}/{pin} [get]
|
||||
// @tags Other
|
||||
func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
|
||||
code := gc.Param("invCode")
|
||||
if _, ok := app.storage.GetInvitesKey(code); !ok {
|
||||
app.debug.Printf(lm.InvalidInviteCode, code)
|
||||
respondBool(401, false, gc)
|
||||
return
|
||||
}
|
||||
userID := gc.Param("userID")
|
||||
pin := gc.Param("pin")
|
||||
user, ok := app.matrix.tokens[pin]
|
||||
if !ok {
|
||||
app.debug.Printf(lm.InvalidPIN, pin)
|
||||
respondBool(200, false, gc)
|
||||
return
|
||||
}
|
||||
if user.User.UserID != userID {
|
||||
app.debug.Printf(lm.UnauthorizedPIN, pin)
|
||||
respondBool(200, false, gc)
|
||||
return
|
||||
}
|
||||
user.Verified = true
|
||||
app.matrix.tokens[pin] = user
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Generates a Matrix access token from a username and password.
|
||||
// @Produce json
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 400 {object} stringResponse
|
||||
// @Failure 401 {object} boolResponse
|
||||
// @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
|
||||
gc.BindJSON(&req)
|
||||
if req.Username == "" || req.Password == "" {
|
||||
respond(400, "errorLoginBlank", gc)
|
||||
return
|
||||
}
|
||||
token, err := app.matrix.generateAccessToken(req.Homeserver, req.Username, req.Password)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGenerateToken, err)
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
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(lm.FailedWriting, app.configPath, err)
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Links a Matrix user to a Jellyfin account via user IDs. Notifications are turned on by default.
|
||||
// @Produce json
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @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.deprecatedMatrix = matrixStore{}
|
||||
}
|
||||
roomID, err := app.matrix.CreateRoom(req.UserID)
|
||||
if err != nil {
|
||||
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,
|
||||
})
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Returns a list of matching users from a Discord guild, given a username (discriminator optional).
|
||||
// @Produce json
|
||||
// @Success 200 {object} DiscordUsersDTO
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @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 == "" {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
users := app.discord.GetUsers(name)
|
||||
resp := DiscordUsersDTO{Users: make([]DiscordUserDTO, len(users))}
|
||||
for i, u := range users {
|
||||
resp.Users[i] = DiscordUserDTO{
|
||||
Name: RenderDiscordUsername(u.User),
|
||||
ID: u.User.ID,
|
||||
AvatarURL: u.User.AvatarURL("32"),
|
||||
}
|
||||
}
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
// @Summary Links a Discord account to a Jellyfin account via user IDs. Notifications are turned on by default.
|
||||
// @Produce json
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @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
|
||||
gc.BindJSON(&req)
|
||||
if req.JellyfinID == "" || req.DiscordID == "" {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
user, ok := app.discord.NewUser(req.DiscordID)
|
||||
if !ok {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
|
||||
app.storage.SetDiscordKey(req.JellyfinID, user)
|
||||
|
||||
if err := app.js.ModifyNotifications(req.JellyfinID, map[jellyseerr.NotificationsField]any{
|
||||
jellyseerr.FieldDiscord: req.DiscordID,
|
||||
jellyseerr.FieldDiscordEnabled: true,
|
||||
}); err != nil {
|
||||
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, 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)
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary unlink a Discord account from a Jellyfin user. Always succeeds.
|
||||
// @Produce json
|
||||
// @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
|
||||
gc.BindJSON(&req)
|
||||
/* user, status, err := app.jf.UserByID(req.ID, false)
|
||||
if req.ID == "" || status != 200 || err != nil {
|
||||
respond(400, "User not found", gc)
|
||||
return
|
||||
} */
|
||||
app.storage.DeleteDiscordKey(req.ID)
|
||||
|
||||
// May not actually remove Discord ID, but should disable interaction.
|
||||
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: req.ID,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "discord",
|
||||
Time: time.Now(),
|
||||
}, gc, false)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary unlink a Telegram account from a Jellyfin user. Always succeeds.
|
||||
// @Produce json
|
||||
// @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
|
||||
gc.BindJSON(&req)
|
||||
/* user, status, err := app.jf.UserByID(req.ID, false)
|
||||
if req.ID == "" || status != 200 || err != nil {
|
||||
respond(400, "User not found", gc)
|
||||
return
|
||||
} */
|
||||
app.storage.DeleteTelegramKey(req.ID)
|
||||
|
||||
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: req.ID,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "telegram",
|
||||
Time: time.Now(),
|
||||
}, gc, false)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary unlink a Matrix account from a Jellyfin user. Always succeeds.
|
||||
// @Produce json
|
||||
// @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
|
||||
gc.BindJSON(&req)
|
||||
/* user, status, err := app.jf.UserByID(req.ID, false)
|
||||
if req.ID == "" || status != 200 || err != nil {
|
||||
respond(400, "User not found", gc)
|
||||
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)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
223
api-ombi.go
Normal file
@@ -0,0 +1,223 @@
|
||||
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"
|
||||
"github.com/hrfee/jfa-go/ombi"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
)
|
||||
|
||||
func (app *appContext) getOmbiUser(jfID 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
|
||||
}
|
||||
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 := ""
|
||||
if a, ok := ombiUser["emailAddress"]; ok && a != nil {
|
||||
ombiAddr = a.(string)
|
||||
}
|
||||
if ombiUser["userName"].(string) == username || (ombiAddr == email && email != "") {
|
||||
return ombiUser, err
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
// @Produce json
|
||||
// @Success 200 {object} ombiUsersDTO
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @Router /ombi/users [get]
|
||||
// @Security Bearer
|
||||
// @tags Ombi
|
||||
func (app *appContext) OmbiUsers(gc *gin.Context) {
|
||||
users, err := app.ombi.GetUsers()
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUsers, lm.Ombi, err)
|
||||
respond(500, "Couldn't get users", gc)
|
||||
return
|
||||
}
|
||||
userlist := make([]ombiUser, len(users))
|
||||
for i, data := range users {
|
||||
userlist[i] = ombiUser{
|
||||
Name: data["userName"].(string),
|
||||
ID: data["id"].(string),
|
||||
}
|
||||
}
|
||||
gc.JSON(200, ombiUsersDTO{Users: userlist})
|
||||
}
|
||||
|
||||
// @Summary Store Ombi user template in an existing profile.
|
||||
// @Produce json
|
||||
// @Param ombiUser body ombiUser true "User to source settings 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/ombi/{profile} [post]
|
||||
// @Security Bearer
|
||||
// @tags Ombi
|
||||
func (app *appContext) SetOmbiProfile(gc *gin.Context) {
|
||||
var req ombiUser
|
||||
gc.BindJSON(&req)
|
||||
escapedProfileName := gc.Param("profile")
|
||||
profileName, _ := url.QueryUnescape(escapedProfileName)
|
||||
profile, ok := app.storage.GetProfileKey(profileName)
|
||||
if !ok {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
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.SetProfileKey(profileName, profile)
|
||||
respondBool(204, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Remove ombi 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/ombi/{profile} [delete]
|
||||
// @Security Bearer
|
||||
// @tags Ombi
|
||||
func (app *appContext) DeleteOmbiProfile(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.Ombi = nil
|
||||
app.storage.SetProfileKey(profileName, profile)
|
||||
respondBool(204, true, gc)
|
||||
}
|
||||
|
||||
type OmbiWrapper struct {
|
||||
*ombi.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) AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (err error) {
|
||||
var ombiUser map[string]interface{}
|
||||
ombiUser, err = ombi.getUser(req.Username, req.Email)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if discordEnabled || telegramEnabled {
|
||||
dID := ""
|
||||
tUser := ""
|
||||
if discord != nil {
|
||||
dID = discord.ID
|
||||
}
|
||||
if telegram != nil {
|
||||
tUser = telegram.Username
|
||||
}
|
||||
var resp string
|
||||
resp, err = ombi.SetNotificationPrefs(ombiUser, dID, tUser)
|
||||
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)
|
||||
}
|
||||
231
api-profiles.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
// @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) {
|
||||
out := getProfilesDTO{
|
||||
DefaultProfile: app.storage.GetDefaultProfile().Name,
|
||||
Profiles: map[string]profileDTO{},
|
||||
}
|
||||
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 Set the default profile to use.
|
||||
// @Produce json
|
||||
// @Param profileChangeDTO body profileChangeDTO true "Default profile object"
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @Router /profiles/default [post]
|
||||
// @Security Bearer
|
||||
// @tags Profiles & Settings
|
||||
func (app *appContext) SetDefaultProfile(gc *gin.Context) {
|
||||
req := profileChangeDTO{}
|
||||
gc.BindJSON(&req)
|
||||
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
|
||||
}
|
||||
app.storage.db.ForEach(&badgerhold.Query{}, func(profile *Profile) error {
|
||||
if profile.Name == req.Name {
|
||||
profile.Default = true
|
||||
} else {
|
||||
profile.Default = false
|
||||
}
|
||||
app.storage.SetProfileKey(profile.Name, *profile)
|
||||
return nil
|
||||
})
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Create a profile based on a Jellyfin user's settings.
|
||||
// @Produce json
|
||||
// @Param newProfileDTO body newProfileDTO true "New profile object"
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @Router /profiles [post]
|
||||
// @Security Bearer
|
||||
// @tags Profiles & Settings
|
||||
func (app *appContext) CreateProfile(gc *gin.Context) {
|
||||
var req newProfileDTO
|
||||
gc.BindJSON(&req)
|
||||
app.jf.CacheExpiry = time.Now()
|
||||
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,
|
||||
Homescreen: req.Homescreen,
|
||||
}
|
||||
app.debug.Printf(lm.CreateProfileFromUser, user.Name)
|
||||
if req.Homescreen {
|
||||
profile.Configuration = user.Configuration
|
||||
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.SetProfileKey(req.Name, profile)
|
||||
// Refresh discord bots, profile list
|
||||
if discordEnabled {
|
||||
app.discord.UpdateCommands()
|
||||
}
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Delete an existing profile
|
||||
// @Produce json
|
||||
// @Param profileChangeDTO body profileChangeDTO true "Delete profile object"
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Router /profiles [delete]
|
||||
// @Security Bearer
|
||||
// @tags Profiles & Settings
|
||||
func (app *appContext) DeleteProfile(gc *gin.Context) {
|
||||
req := profileChangeDTO{}
|
||||
gc.BindJSON(&req)
|
||||
name := req.Name
|
||||
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) {
|
||||
profileName := gc.Param("profile")
|
||||
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) {
|
||||
profileName := gc.Param("profile")
|
||||
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)
|
||||
}
|
||||
806
api-userpage.go
Normal file
@@ -0,0 +1,806 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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.
|
||||
// @Produce json
|
||||
// @Success 200 {object} MyDetailsDTO
|
||||
// @Router /my/details [get]
|
||||
// @tags User Page
|
||||
func (app *appContext) MyDetails(gc *gin.Context) {
|
||||
resp := MyDetailsDTO{
|
||||
Id: gc.GetString("jfId"),
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
resp.Username = user.Name
|
||||
resp.Admin = user.Policy.IsAdministrator
|
||||
resp.AccountsAdmin = false
|
||||
if !app.config.Section("ui").Key("allow_all").MustBool(false) {
|
||||
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
|
||||
if emailStore, ok := app.storage.GetEmailsKey(resp.Id); ok {
|
||||
resp.AccountsAdmin = emailStore.Admin
|
||||
}
|
||||
resp.AccountsAdmin = resp.AccountsAdmin || (adminOnly && resp.Admin)
|
||||
}
|
||||
resp.Disabled = user.Policy.IsDisabled
|
||||
|
||||
if exp, ok := app.storage.GetUserExpiryKey(user.ID); ok {
|
||||
resp.Expiry = exp.Expiry.Unix()
|
||||
}
|
||||
|
||||
if emailEnabled {
|
||||
resp.Email = &MyDetailsContactMethodsDTO{}
|
||||
if email, ok := app.storage.GetEmailsKey(user.ID); ok && email.Addr != "" {
|
||||
resp.Email.Value = email.Addr
|
||||
resp.Email.Enabled = email.Contact
|
||||
}
|
||||
}
|
||||
|
||||
if discordEnabled {
|
||||
resp.Discord = &MyDetailsContactMethodsDTO{}
|
||||
if discord, ok := app.storage.GetDiscordKey(user.ID); ok {
|
||||
resp.Discord.Value = RenderDiscordUsername(discord)
|
||||
resp.Discord.Enabled = discord.Contact
|
||||
}
|
||||
}
|
||||
|
||||
if telegramEnabled {
|
||||
resp.Telegram = &MyDetailsContactMethodsDTO{}
|
||||
if telegram, ok := app.storage.GetTelegramKey(user.ID); ok {
|
||||
resp.Telegram.Value = telegram.Username
|
||||
resp.Telegram.Enabled = telegram.Contact
|
||||
}
|
||||
}
|
||||
|
||||
if matrixEnabled {
|
||||
resp.Matrix = &MyDetailsContactMethodsDTO{}
|
||||
if matrix, ok := app.storage.GetMatrixKey(user.ID); ok {
|
||||
resp.Matrix.Value = matrix.UserID
|
||||
resp.Matrix.Enabled = matrix.Contact
|
||||
}
|
||||
}
|
||||
|
||||
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."
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Success 400 {object} boolResponse
|
||||
// @Success 500 {object} boolResponse
|
||||
// @Router /my/contact [post]
|
||||
// @Security Bearer
|
||||
// @tags User Page
|
||||
func (app *appContext) SetMyContactMethods(gc *gin.Context) {
|
||||
var req SetContactMethodsDTO
|
||||
gc.BindJSON(&req)
|
||||
req.ID = gc.GetString("jfId")
|
||||
if req.ID == "" {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
app.setContactMethods(req, gc)
|
||||
}
|
||||
|
||||
// @Summary Logout by deleting refresh token from cookies.
|
||||
// @Produce json
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @Router /my/logout [post]
|
||||
// @Security Bearer
|
||||
// @tags User Page
|
||||
func (app *appContext) LogoutUser(gc *gin.Context) {
|
||||
cookie, err := gc.Cookie("user-refresh")
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf(lm.FailedGetCookies, "user-refresh", err)
|
||||
app.debug.Println(msg)
|
||||
respond(500, msg, gc)
|
||||
return
|
||||
}
|
||||
app.invalidTokens = append(app.invalidTokens, cookie)
|
||||
gc.SetCookie("refresh", "invalid", -1, "/my", gc.Request.URL.Hostname(), true, true)
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary confirm an action (e.g. changing an email address.)
|
||||
// @Produce json
|
||||
// @Param jwt path string true "jwt confirmation code"
|
||||
// @Router /my/confirm/{jwt} [post]
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 400 {object} stringResponse
|
||||
// @Failure 404
|
||||
// @Success 303
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @tags User Page
|
||||
func (app *appContext) ConfirmMyAction(gc *gin.Context) {
|
||||
app.confirmMyAction(gc, "")
|
||||
}
|
||||
|
||||
func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
|
||||
var claims jwt.MapClaims
|
||||
var target ConfirmationTarget
|
||||
var id string
|
||||
fail := func() {
|
||||
app.gcHTML(gc, 404, "404.html", OtherPage, gin.H{
|
||||
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate key
|
||||
if key == "" {
|
||||
key = gc.Param("jwt")
|
||||
}
|
||||
token, err := jwt.Parse(key, checkToken)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedParseJWT, err)
|
||||
fail()
|
||||
// respond(500, "unknownError", gc)
|
||||
return
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
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.Println(lm.InvalidJWT)
|
||||
fail()
|
||||
// respond(400, "invalidKey", gc)
|
||||
return
|
||||
}
|
||||
target = ConfirmationTarget(int(claims["target"].(float64)))
|
||||
id = claims["id"].(string)
|
||||
|
||||
// Perform an Action
|
||||
if target == NoOp {
|
||||
gc.Redirect(http.StatusSeeOther, PAGES.MyAccount)
|
||||
return
|
||||
} else if target == UserEmailChange {
|
||||
app.modifyEmail(id, claims["email"].(string))
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactLinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "email",
|
||||
Time: time.Now(),
|
||||
}, gc, true)
|
||||
|
||||
app.info.Printf(lm.UserEmailAdjusted, gc.GetString("jfId"))
|
||||
gc.Redirect(http.StatusSeeOther, PAGES.MyAccount)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Modify your email address.
|
||||
// @Produce json
|
||||
// @Param ModifyMyEmailDTO body ModifyMyEmailDTO true "New email address."
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 400 {object} stringResponse
|
||||
// @Failure 401 {object} stringResponse
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @Router /my/email [post]
|
||||
// @Security Bearer
|
||||
// @tags User Page
|
||||
func (app *appContext) ModifyMyEmail(gc *gin.Context) {
|
||||
var req ModifyMyEmailDTO
|
||||
gc.BindJSON(&req)
|
||||
if !strings.ContainsRune(req.Email, '@') {
|
||||
respond(400, "Invalid Email Address", gc)
|
||||
return
|
||||
}
|
||||
id := gc.GetString("jfId")
|
||||
|
||||
// We'll use the ConfirmMyAction route to do the work, even if we don't need to confirm the address.
|
||||
claims := jwt.MapClaims{
|
||||
"valid": true,
|
||||
"id": id,
|
||||
"email": req.Email,
|
||||
"type": "confirmation",
|
||||
"target": UserEmailChange,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
}
|
||||
tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET")))
|
||||
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedSignJWT, err)
|
||||
respond(500, "errorUnknown", gc)
|
||||
return
|
||||
}
|
||||
|
||||
if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) {
|
||||
user, err := app.jf.UserByID(id, false)
|
||||
name := ""
|
||||
if err == nil {
|
||||
name = user.Name
|
||||
}
|
||||
app.debug.Printf(lm.EmailConfirmationRequired, id)
|
||||
respond(401, "confirmEmail", gc)
|
||||
msg, err := app.email.constructConfirmation("", name, key, app, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructConfirmationEmail, id, err)
|
||||
} else if err := app.email.send(msg, req.Email); err != nil {
|
||||
app.err.Printf(lm.FailedSendConfirmationEmail, id, req.Email, err)
|
||||
} else {
|
||||
app.err.Printf(lm.SentConfirmationEmail, id, req.Email)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
app.confirmMyAction(gc, key)
|
||||
return
|
||||
}
|
||||
|
||||
// @Summary Returns a 10-minute, one-use Discord server invite
|
||||
// @Produce json
|
||||
// @Success 200 {object} DiscordInviteDTO
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @Failure 401 {object} boolResponse
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Param invCode path string true "invite Code"
|
||||
// @Router /my/discord/invite [get]
|
||||
// @Security Bearer
|
||||
// @tags User Page
|
||||
func (app *appContext) MyDiscordServerInvite(gc *gin.Context) {
|
||||
if app.discord.InviteChannel.Name == "" {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
invURL, iconURL := app.discord.NewTempInvite(10*60, 1)
|
||||
if invURL == "" {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
gc.JSON(200, DiscordInviteDTO{invURL, iconURL})
|
||||
}
|
||||
|
||||
// @Summary Returns a linking PIN for discord/telegram
|
||||
// @Produce json
|
||||
// @Success 200 {object} GetMyPINDTO
|
||||
// @Failure 400 {object} stringResponse
|
||||
// Param service path string true "discord/telegram"
|
||||
// @Router /my/pin/{service} [get]
|
||||
// @Security Bearer
|
||||
// @tags User Page
|
||||
func (app *appContext) GetMyPIN(gc *gin.Context) {
|
||||
service := gc.Param("service")
|
||||
resp := GetMyPINDTO{}
|
||||
switch service {
|
||||
case "discord":
|
||||
resp.PIN = app.discord.NewAssignedAuthToken(gc.GetString("jfId"))
|
||||
break
|
||||
case "telegram":
|
||||
resp.PIN = app.telegram.NewAssignedAuthToken(gc.GetString("jfId"))
|
||||
break
|
||||
default:
|
||||
respond(400, "invalid service", gc)
|
||||
return
|
||||
}
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
// @Summary Returns true/false on whether or not your discord PIN was verified, and assigns the discord user to you.
|
||||
// @Produce json
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 401 {object} boolResponse
|
||||
// @Param pin path string true "PIN code to check"
|
||||
// @Router /my/discord/verified/{pin} [get]
|
||||
// @Security Bearer
|
||||
// @tags User Page
|
||||
func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
|
||||
pin := gc.Param("pin")
|
||||
dcUser, ok := app.discord.AssignedUserVerified(pin, gc.GetString("jfId"))
|
||||
app.discord.DeleteVerifiedToken(pin)
|
||||
if !ok {
|
||||
respondBool(200, false, gc)
|
||||
return
|
||||
}
|
||||
if app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(dcUser.ID) {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
existingUser, ok := app.storage.GetDiscordKey(gc.GetString("jfId"))
|
||||
if ok {
|
||||
dcUser.Lang = existingUser.Lang
|
||||
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)
|
||||
}
|
||||
|
||||
// @Summary Returns true/false on whether or not your telegram PIN was verified, and assigns the telegram user to you.
|
||||
// @Produce json
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 401 {object} boolResponse
|
||||
// @Param pin path string true "PIN code to check"
|
||||
// @Router /my/telegram/verified/{pin} [get]
|
||||
// @Security Bearer
|
||||
// @tags User Page
|
||||
func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
|
||||
pin := gc.Param("pin")
|
||||
token, ok := app.telegram.AssignedTokenVerified(pin, gc.GetString("jfId"))
|
||||
app.telegram.DeleteVerifiedToken(pin)
|
||||
if !ok {
|
||||
respondBool(200, false, gc)
|
||||
return
|
||||
}
|
||||
if app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(token.Username) {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
tgUser := TelegramUser{
|
||||
ChatID: token.ChatID,
|
||||
Username: token.Username,
|
||||
Contact: true,
|
||||
}
|
||||
if lang, ok := app.telegram.languages[tgUser.ChatID]; ok {
|
||||
tgUser.Lang = lang
|
||||
}
|
||||
|
||||
existingUser, ok := app.storage.GetTelegramKey(gc.GetString("jfId"))
|
||||
if ok {
|
||||
tgUser.Lang = existingUser.Lang
|
||||
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)
|
||||
}
|
||||
|
||||
// @Summary Generate and send a new PIN to your given matrix user.
|
||||
// @Produce json
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 400 {object} stringResponse
|
||||
// @Failure 401 {object} boolResponse
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Param MatrixSendPINDTO body MatrixSendPINDTO true "User's Matrix ID."
|
||||
// @Router /my/matrix/user [post]
|
||||
// @Security Bearer
|
||||
// @tags User Page
|
||||
func (app *appContext) MatrixSendMyPIN(gc *gin.Context) {
|
||||
var req MatrixSendPINDTO
|
||||
gc.BindJSON(&req)
|
||||
if req.UserID == "" {
|
||||
respond(400, "errorNoUserID", gc)
|
||||
return
|
||||
}
|
||||
if app.config.Section("matrix").Key("require_unique").MustBool(false) {
|
||||
for _, u := range app.storage.GetMatrix() {
|
||||
if req.UserID == u.UserID {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ok := app.matrix.SendStart(req.UserID)
|
||||
if !ok {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Check whether your matrix PIN is valid, and link the account to yours if so.
|
||||
// @Produce json
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 401 {object} boolResponse
|
||||
// @Param pin path string true "PIN code to check"
|
||||
// @Param invCode path string true "invite Code"
|
||||
// @Param userID path string true "Matrix User ID"
|
||||
// @Router /my/matrix/verified/{userID}/{pin} [get]
|
||||
// @Security Bearer
|
||||
// @tags User Page
|
||||
func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
|
||||
userID := gc.Param("userID")
|
||||
pin := gc.Param("pin")
|
||||
user, ok := app.matrix.tokens[pin]
|
||||
if !ok {
|
||||
app.debug.Printf(lm.InvalidPIN, pin)
|
||||
respondBool(200, false, gc)
|
||||
return
|
||||
}
|
||||
if user.User.UserID != userID {
|
||||
app.debug.Printf(lm.UnauthorizedPIN, pin)
|
||||
respondBool(200, false, gc)
|
||||
return
|
||||
}
|
||||
|
||||
mxUser := *user.User
|
||||
mxUser.Contact = true
|
||||
existingUser, ok := app.storage.GetMatrixKey(gc.GetString("jfId"))
|
||||
if ok {
|
||||
mxUser.Lang = existingUser.Lang
|
||||
mxUser.Contact = existingUser.Contact
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// @Summary unlink the Discord account from your Jellyfin user. Always succeeds.
|
||||
// @Produce json
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Router /my/discord [delete]
|
||||
// @Security Bearer
|
||||
// @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)
|
||||
}
|
||||
|
||||
// @Summary unlink the Telegram account from your Jellyfin user. Always succeeds.
|
||||
// @Produce json
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Router /my/telegram [delete]
|
||||
// @Security Bearer
|
||||
// @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)
|
||||
}
|
||||
|
||||
// @Summary unlink the Matrix account from your Jellyfin user. Always succeeds.
|
||||
// @Produce json
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Router /my/matrix [delete]
|
||||
// @Security Bearer
|
||||
// @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)
|
||||
}
|
||||
|
||||
// @Summary Generate & send a password reset link if the given username/email/contact method exists. Doesn't give you any info about it's success.
|
||||
// @Produce json
|
||||
// @Param address path string true "address/contact method associated w/ your account."
|
||||
// @Success 204 {object} boolResponse
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Router /my/password/reset/{address} [post]
|
||||
// @Tags User Page
|
||||
func (app *appContext) ResetMyPassword(gc *gin.Context) {
|
||||
// All requests should take 1 second, to make it harder to tell if a success occured or not.
|
||||
timerWait := make(chan bool)
|
||||
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 == "" {
|
||||
cancel.Stop()
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
var pwr InternalPWR
|
||||
var err error
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
pwr, err = app.GenInternalReset(jfUser.ID)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
|
||||
for range timerWait {
|
||||
respondBool(204, true, gc)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
if app.internalPWRs == nil {
|
||||
app.internalPWRs = map[string]InternalPWR{}
|
||||
}
|
||||
app.internalPWRs[pwr.PIN] = pwr
|
||||
// FIXME: Send to all contact methods
|
||||
msg, err := app.email.constructReset(
|
||||
PasswordReset{
|
||||
Pin: pwr.PIN,
|
||||
Username: pwr.Username,
|
||||
Expiry: pwr.Expiry,
|
||||
Internal: true,
|
||||
}, app, false,
|
||||
)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
|
||||
for range timerWait {
|
||||
respondBool(204, true, gc)
|
||||
return
|
||||
}
|
||||
return
|
||||
} else if err := app.sendByID(msg, jfUser.ID); err != nil {
|
||||
app.err.Printf(lm.FailedSendPWRMessage, pwr.Username, "?", err)
|
||||
} else {
|
||||
app.info.Printf(lm.SentPWRMessage, pwr.Username, "?")
|
||||
}
|
||||
for range timerWait {
|
||||
respondBool(204, true, gc)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Change your password, given the old one and the new one.
|
||||
// @Produce json
|
||||
// @Param ChangeMyPasswordDTO body ChangeMyPasswordDTO true "User's old & new passwords."
|
||||
// @Success 204 {object} boolResponse
|
||||
// @Failure 400 {object} PasswordValidation
|
||||
// @Failure 401 {object} boolResponse
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Router /my/password [post]
|
||||
// @Security Bearer
|
||||
// @Tags User Page
|
||||
func (app *appContext) ChangeMyPassword(gc *gin.Context) {
|
||||
var req ChangeMyPasswordDTO
|
||||
gc.BindJSON(&req)
|
||||
if req.Old == "" || req.New == "" {
|
||||
respondBool(400, false, gc)
|
||||
}
|
||||
validation := app.validator.validate(req.New)
|
||||
for _, val := range validation {
|
||||
if !val {
|
||||
gc.JSON(400, validation)
|
||||
return
|
||||
}
|
||||
}
|
||||
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, err = app.authJf.Authenticate(user.Name, req.Old)
|
||||
if err != nil {
|
||||
respondBool(401, false, gc)
|
||||
return
|
||||
}
|
||||
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, err := app.getOmbiUser(gc.GetString("jfId"))
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUser, user.Name, lm.Ombi, err)
|
||||
return
|
||||
}
|
||||
ombiUser["password"] = req.New
|
||||
err = app.ombi.ModifyUser(ombiUser)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedChangePassword, lm.Ombi, ombiUser["userName"], err)
|
||||
return
|
||||
}
|
||||
app.debug.Printf(lm.ChangePassword, lm.Ombi, ombiUser["userName"])
|
||||
}()
|
||||
}
|
||||
cookie, err := gc.Cookie("user-refresh")
|
||||
if err == nil {
|
||||
app.invalidTokens = append(app.invalidTokens, cookie)
|
||||
gc.SetCookie("refresh", "invalid", -1, "/my", gc.Request.URL.Hostname(), true, true)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
gc.JSON(200, GetMyReferralRespDTO{
|
||||
Code: inv.Code,
|
||||
RemainingUses: inv.RemainingUses,
|
||||
NoLimit: inv.NoLimit,
|
||||
Expiry: inv.ValidTill.Unix(),
|
||||
UseExpiry: inv.UseReferralExpiry,
|
||||
})
|
||||
}
|
||||
1294
api-users.go
Normal file
17
args.go
@@ -23,6 +23,7 @@ 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")
|
||||
@@ -41,6 +42,9 @@ func (app *appContext) loadArgs(firstCall bool) {
|
||||
if *PPROF {
|
||||
os.Setenv("PPROF", "1")
|
||||
}
|
||||
if *_LOADBAK != "" {
|
||||
LOADBAK = *_LOADBAK
|
||||
}
|
||||
}
|
||||
|
||||
if os.Getenv("SWAGGER") == "1" {
|
||||
@@ -75,18 +79,23 @@ func (app *appContext) loadArgs(firstCall bool) {
|
||||
os.Setenv("JFA_DATAPATH", app.dataPath)
|
||||
}
|
||||
|
||||
/* Adds start/stop/systemd to help message, and
|
||||
/*
|
||||
Adds start/stop/systemd to help message, and
|
||||
|
||||
also gets rid of usage for shorthand flags, and merge them with the full-length one.
|
||||
implementation is 🤢, will clean this up eventually.
|
||||
|
||||
-h SHORTHAND
|
||||
-help
|
||||
prints this message.
|
||||
|
||||
becomes:
|
||||
|
||||
-help, -h
|
||||
prints this message.
|
||||
*/
|
||||
func helpFunc() {
|
||||
fmt.Fprint(os.Stderr, `Usage of jfa-go:
|
||||
fmt.Fprint(stderr, `Usage of jfa-go:
|
||||
start
|
||||
start jfa-go as a daemon and run in the background.
|
||||
stop
|
||||
@@ -99,7 +108,7 @@ func helpFunc() {
|
||||
// Write defaults into buffer then remove any shorthands
|
||||
flag.CommandLine.SetOutput(&b)
|
||||
flag.PrintDefaults()
|
||||
flag.CommandLine.SetOutput(os.Stderr)
|
||||
flag.CommandLine.SetOutput(stderr)
|
||||
scanner := bufio.NewScanner(&b)
|
||||
out := ""
|
||||
line := scanner.Text()
|
||||
@@ -150,5 +159,5 @@ func helpFunc() {
|
||||
lastLine = true
|
||||
}
|
||||
}
|
||||
fmt.Fprint(os.Stderr, out)
|
||||
fmt.Fprint(stderr, out)
|
||||
}
|
||||
|
||||
278
auth.go
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -9,21 +10,50 @@ 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"
|
||||
)
|
||||
|
||||
const (
|
||||
TOKEN_VALIDITY_SEC = 20 * 60
|
||||
REFRESH_TOKEN_VALIDITY_SEC = 3600 * 24
|
||||
)
|
||||
|
||||
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 {
|
||||
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) (string, string, error) {
|
||||
func CreateToken(userId, jfId string, admin bool) (string, string, error) {
|
||||
var token, refresh string
|
||||
claims := jwt.MapClaims{
|
||||
"valid": true,
|
||||
"id": userId,
|
||||
"exp": time.Now().Add(time.Minute * 20).Unix(),
|
||||
"exp": time.Now().Add(time.Second * TOKEN_VALIDITY_SEC).Unix(),
|
||||
"jfid": jfId,
|
||||
"admin": admin,
|
||||
"type": "bearer",
|
||||
}
|
||||
tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
@@ -31,7 +61,7 @@ func CreateToken(userId, jfId string) (string, string, error) {
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
claims["exp"] = time.Now().Add(time.Hour * 24).Unix()
|
||||
claims["exp"] = time.Now().Add(time.Second * REFRESH_TOKEN_VALIDITY_SEC).Unix()
|
||||
claims["type"] = "refresh"
|
||||
tk = jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
refresh, err = tk.SignedString([]byte(os.Getenv("JFA_SECRET")))
|
||||
@@ -41,50 +71,70 @@ func CreateToken(userId, jfId string) (string, string, error) {
|
||||
return token, refresh, nil
|
||||
}
|
||||
|
||||
// Check header for token
|
||||
func (app *appContext) authenticate(gc *gin.Context) {
|
||||
// Caller should return if this returns false.
|
||||
func (app *appContext) decodeValidateAuthHeader(gc *gin.Context) (claims jwt.MapClaims, ok bool) {
|
||||
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.authLog(lm.FailedCastJWT)
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
expiryUnix := int64(claims["exp"].(float64))
|
||||
if err != nil {
|
||||
app.debug.Printf("Auth denied: %s", err)
|
||||
respond(401, "Unauthorized", gc)
|
||||
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
|
||||
return
|
||||
}
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
// Check header for token
|
||||
func (app *appContext) authenticate(gc *gin.Context) {
|
||||
claims, ok := app.decodeValidateAuthHeader(gc)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
isAdminToken := claims["admin"].(bool)
|
||||
if !isAdminToken {
|
||||
app.authLog(lm.NonAdminToken)
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
|
||||
userID := claims["id"].(string)
|
||||
jfID := claims["jfid"].(string)
|
||||
match := false
|
||||
for _, user := range app.users {
|
||||
for _, user := range app.adminUsers {
|
||||
if user.UserID == userID {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
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)
|
||||
app.debug.Println("Auth succeeded")
|
||||
gc.Set("userMode", false)
|
||||
gc.Next()
|
||||
}
|
||||
|
||||
@@ -99,6 +149,68 @@ type getTokenDTO struct {
|
||||
Token string `json:"token" example:"kjsdklsfdkljfsjsdfklsdfkldsfjdfskjsdfjklsdf"` // API token for use with everything else.
|
||||
}
|
||||
|
||||
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)
|
||||
username = creds[0]
|
||||
password = creds[1]
|
||||
ok = false
|
||||
if username == "" || password == "" {
|
||||
app.logIpDebug(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.EmptyUserOrPass))
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
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, 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
|
||||
} else if errors.As(err, &mediabrowser.ErrForbidden{}) {
|
||||
app.logIpInfo(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.UserDisabled))
|
||||
respond(403, "yourAccountWasDisabled", gc)
|
||||
return
|
||||
}
|
||||
app.authLog(fmt.Sprintf(lm.FailedAuthJellyfin, app.jf.Server, 0, err))
|
||||
respond(500, "Jellyfin error", gc)
|
||||
return
|
||||
}
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
// @Summary Grabs an API token using username & password.
|
||||
// @description If viewing docs locally, click the lock icon next to this, login with your normal jfa-go credentials. Click 'try it out', then 'execute' and an API Key will be returned, copy it (not including quotes). On any of the other routes, click the lock icon and set the API key as "Bearer `your api key`".
|
||||
// @Produce json
|
||||
@@ -108,68 +220,94 @@ type getTokenDTO struct {
|
||||
// @tags Auth
|
||||
// @Security getTokenAuth
|
||||
func (app *appContext) getTokenLogin(gc *gin.Context) {
|
||||
app.info.Println("Token requested (login attempt)")
|
||||
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
|
||||
auth, _ := base64.StdEncoding.DecodeString(header[1])
|
||||
creds := strings.SplitN(string(auth), ":", 2)
|
||||
var userID, jfID string
|
||||
if creds[0] == "" || creds[1] == "" {
|
||||
app.debug.Println("Auth denied: blank username/password")
|
||||
respond(401, "Unauthorized", gc)
|
||||
app.logIpInfo(gc, false, fmt.Sprintf(lm.RequestingToken, lm.TokenLoginAttempt))
|
||||
username, password, ok := app.decodeValidateLoginHeader(gc, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var userID, jfID string
|
||||
match := false
|
||||
for _, user := range app.users {
|
||||
if user.Username == creds[0] && user.Password == creds[1] {
|
||||
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, status, err := app.authJf.Authenticate(creds[0], creds[1])
|
||||
if status != 200 || err != nil {
|
||||
if status == 401 || status == 400 {
|
||||
app.info.Println("Auth denied: Invalid username/password (Jellyfin)")
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
app.err.Printf("Auth failed: Couldn't authenticate with Jellyfin (%d/%s)", status, err)
|
||||
respond(500, "Jellyfin error", gc)
|
||||
user, ok := app.validateJellyfinCredentials(username, password, gc, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
jfID = user.ID
|
||||
if app.config.Section("ui").Key("admin_only").MustBool(true) {
|
||||
if !user.Policy.IsAdministrator {
|
||||
app.debug.Printf("Auth denied: Users \"%s\" isn't admin", creds[0])
|
||||
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\"", creds[0])
|
||||
app.users = append(app.users, newUser)
|
||||
app.debug.Printf(lm.GenerateToken, username)
|
||||
app.adminUsers = append(app.adminUsers, newUser)
|
||||
}
|
||||
token, refresh, err := CreateToken(userID, jfID)
|
||||
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 := gc.Request.URL.Hostname()
|
||||
host := app.ExternalDomain
|
||||
|
||||
// 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})
|
||||
}
|
||||
|
||||
func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName string) (claims jwt.MapClaims, ok bool) {
|
||||
ok = false
|
||||
cookie, err := gc.Cookie(cookieName)
|
||||
if err != nil || cookie == "" {
|
||||
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.authLog(lm.LocallyInvalidatedJWT)
|
||||
respond(401, lm.InvalidJWT, gc)
|
||||
return
|
||||
}
|
||||
}
|
||||
token, err := jwt.Parse(cookie, checkToken)
|
||||
if err != nil {
|
||||
app.authLog(fmt.Sprintf(lm.FailedParseJWT, err))
|
||||
respond(400, lm.InvalidJWT, gc)
|
||||
return
|
||||
}
|
||||
claims, ok = token.Claims.(jwt.MapClaims)
|
||||
expiryUnix := int64(claims["exp"].(float64))
|
||||
expiry := time.Unix(expiryUnix, 0)
|
||||
if !(ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now())) {
|
||||
app.authLog(lm.InvalidJWT)
|
||||
respond(401, lm.InvalidJWT, gc)
|
||||
ok = false
|
||||
return
|
||||
}
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
// @Summary Grabs an API token using a refresh token from cookies.
|
||||
// @Produce json
|
||||
// @Success 200 {object} getTokenDTO
|
||||
@@ -177,47 +315,21 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
|
||||
// @Router /token/refresh [get]
|
||||
// @tags Auth
|
||||
func (app *appContext) getTokenRefresh(gc *gin.Context) {
|
||||
app.debug.Println("Token requested (refresh token)")
|
||||
cookie, err := gc.Cookie("refresh")
|
||||
if err != nil || cookie == "" {
|
||||
app.debug.Printf("getTokenRefresh denied: Couldn't get token: %s", 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)
|
||||
return
|
||||
}
|
||||
}
|
||||
token, err := jwt.Parse(cookie, checkToken)
|
||||
if err != nil {
|
||||
app.debug.Println("getTokenRefresh: Invalid token")
|
||||
respond(400, "Invalid token", 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)
|
||||
return
|
||||
}
|
||||
expiry := time.Unix(expiryUnix, 0)
|
||||
if !(ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now())) {
|
||||
app.debug.Printf("getTokenRefresh: Invalid token: %s", err)
|
||||
respond(401, "Invalid token", gc)
|
||||
app.logIpInfo(gc, false, fmt.Sprintf(lm.RequestingToken, lm.TokenRefresh))
|
||||
claims, ok := app.decodeValidateRefreshCookie(gc, "refresh")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID := claims["id"].(string)
|
||||
jfID := claims["jfid"].(string)
|
||||
jwt, refresh, err := CreateToken(userID, jfID)
|
||||
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, (3600 * 24), "/", gc.Request.URL.Hostname(), true, true)
|
||||
// host := gc.Request.URL.Hostname()
|
||||
host := app.ExternalDomain
|
||||
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
|
||||
gc.JSON(200, getTokenDTO{jwt})
|
||||
}
|
||||
|
||||
302
backups.go
Normal file
@@ -0,0 +1,302 @@
|
||||
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_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}{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("remaining:%+v\n", backupsByCommit)
|
||||
}
|
||||
// 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)
|
||||
|
||||
app.ConnectDB()
|
||||
defer app.storage.db.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 + "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 + "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)
|
||||
}
|
||||
136
common/common.go
@@ -1,8 +1,18 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
)
|
||||
|
||||
// TimeoutHandler recovers from an http timeout or panic.
|
||||
@@ -12,7 +22,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 +31,127 @@ 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
|
||||
}
|
||||
|
||||
62
common/config.go
Normal file
@@ -0,0 +1,62 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type Section struct {
|
||||
Section string `json:"section" yaml:"section" example:"my_section"`
|
||||
Meta SectionMeta `json:"meta" yaml:"meta"`
|
||||
Settings []Setting `json:"settings" yaml:"settings"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Sections []Section `json:"sections" yaml:"sections"`
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
258
config.go
@@ -3,11 +3,16 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hrfee/jfa-go/common"
|
||||
"github.com/hrfee/jfa-go/easyproxy"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
@@ -17,6 +22,9 @@ var telegramEnabled = false
|
||||
var discordEnabled = false
|
||||
var matrixEnabled = false
|
||||
|
||||
// URL subpaths. Ignore the "Current" field.
|
||||
var PAGES = PagePaths{}
|
||||
|
||||
func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
|
||||
val := app.config.Section(sect).Key(key).MustString("")
|
||||
if strings.HasPrefix(val, "jfa-go:") {
|
||||
@@ -30,29 +38,88 @@ func (app *appContext) MustSetValue(section, key, val string) {
|
||||
app.config.Section(section).Key(key).SetValue(app.config.Section(section).Key(key).MustString(val))
|
||||
}
|
||||
|
||||
func (app *appContext) MustSetURLPath(section, key, val string) {
|
||||
if !strings.HasPrefix(val, "/") && val != "" {
|
||||
val = "/" + val
|
||||
}
|
||||
app.MustSetValue(section, key, val)
|
||||
}
|
||||
|
||||
func FixFullURL(v string) string {
|
||||
if !strings.HasPrefix(v, "http://") && !strings.HasPrefix(v, "https://") {
|
||||
v = "http://" + v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func FormatSubpath(path string) string {
|
||||
if path == "/" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSuffix(path, "/")
|
||||
}
|
||||
|
||||
func (app *appContext) MustCorrectURL(section, key, value string) {
|
||||
v := app.config.Section(section).Key(key).String()
|
||||
if v == "" {
|
||||
v = value
|
||||
}
|
||||
v = FixFullURL(v)
|
||||
app.config.Section(section).Key(key).SetValue(v)
|
||||
}
|
||||
|
||||
func (app *appContext) loadConfig() error {
|
||||
var err error
|
||||
app.config, err = ini.Load(app.configPath)
|
||||
app.config, err = ini.ShadowLoad(app.configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app.MustSetValue("jellyfin", "public_server", app.config.Section("jellyfin").Key("server").String())
|
||||
// URLs
|
||||
app.MustSetURLPath("ui", "url_base", "")
|
||||
app.MustSetURLPath("url_paths", "admin", "")
|
||||
app.MustSetURLPath("url_paths", "user_page", "/my/account")
|
||||
app.MustSetURLPath("url_paths", "form", "/invite")
|
||||
PAGES.Base = FormatSubpath(app.config.Section("ui").Key("url_base").String())
|
||||
PAGES.Admin = FormatSubpath(app.config.Section("url_paths").Key("admin").String())
|
||||
PAGES.MyAccount = FormatSubpath(app.config.Section("url_paths").Key("user_page").String())
|
||||
PAGES.Form = FormatSubpath(app.config.Section("url_paths").Key("form").String())
|
||||
if !(app.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" {
|
||||
app.err.Printf(lm.BadURLBase, PAGES.Base)
|
||||
}
|
||||
app.info.Printf(lm.SubpathBlockMessage, PAGES.Base, PAGES.Admin, PAGES.MyAccount, PAGES.Form)
|
||||
|
||||
app.MustSetValue("ui", "redirect_url", app.config.Section("jellyfin").Key("public_server").String())
|
||||
app.MustCorrectURL("jellyfin", "server", "")
|
||||
app.MustCorrectURL("jellyfin", "public_server", app.config.Section("jellyfin").Key("server").String())
|
||||
app.MustCorrectURL("ui", "redirect_url", app.config.Section("jellyfin").Key("public_server").String())
|
||||
|
||||
for _, key := range app.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"))))
|
||||
}
|
||||
}
|
||||
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"} {
|
||||
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"))))
|
||||
}
|
||||
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"))))
|
||||
}
|
||||
app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/")
|
||||
|
||||
app.ExternalURI = strings.TrimSuffix(strings.TrimSuffix(app.config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/")
|
||||
if !strings.HasSuffix(app.ExternalURI, PAGES.Base) {
|
||||
app.err.Println(lm.NoURLSuffix)
|
||||
}
|
||||
if app.ExternalURI == "" {
|
||||
app.err.Println(lm.NoExternalHost + lm.LoginWontSave)
|
||||
}
|
||||
u, err := url.Parse(app.ExternalURI)
|
||||
if err == nil {
|
||||
app.ExternalDomain = u.Hostname()
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -75,11 +142,14 @@ func (app *appContext) loadConfig() error {
|
||||
|
||||
app.MustSetValue("smtp", "hello_hostname", "localhost")
|
||||
app.MustSetValue("smtp", "cert_validation", "true")
|
||||
app.MustSetValue("smtp", "auth_type", "4")
|
||||
app.MustSetValue("smtp", "port", "465")
|
||||
|
||||
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)
|
||||
}
|
||||
app.MustSetValue("activity_log", "keep_n_records", "1000")
|
||||
app.MustSetValue("activity_log", "delete_after_days", "90")
|
||||
|
||||
sc := app.config.Section("discord").Key("start_command").MustString("start")
|
||||
app.config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
|
||||
|
||||
// Deletion template is good enough for these as well.
|
||||
app.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html")
|
||||
@@ -97,6 +167,11 @@ func (app *appContext) loadConfig() error {
|
||||
app.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html")
|
||||
app.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt")
|
||||
|
||||
app.MustSetValue("user_expiry", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html")
|
||||
app.MustSetValue("user_expiry", "adjustment_email_text", "jfa-go:"+"expiry-adjusted.txt")
|
||||
|
||||
app.MustSetValue("email", "collect", "true")
|
||||
|
||||
app.MustSetValue("matrix", "topic", "Jellyfin notifications")
|
||||
app.MustSetValue("matrix", "show_on_reg", "true")
|
||||
|
||||
@@ -104,15 +179,43 @@ func (app *appContext) loadConfig() error {
|
||||
|
||||
app.MustSetValue("telegram", "show_on_reg", "true")
|
||||
|
||||
app.MustSetValue("backups", "every_n_minutes", "1440")
|
||||
app.MustSetValue("backups", "path", filepath.Join(app.dataPath, "backups"))
|
||||
app.MustSetValue("backups", "keep_n_backups", "20")
|
||||
app.MustSetValue("backups", "keep_previous_version_backup", "true")
|
||||
|
||||
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))
|
||||
|
||||
// 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)
|
||||
app.MustSetValue("jellyfin", "cache_timeout", "30")
|
||||
app.MustSetValue("jellyfin", "web_cache_async_timeout", "1")
|
||||
app.MustSetValue("jellyfin", "web_cache_sync_timeout", "10")
|
||||
|
||||
LOGIP = app.config.Section("advanced").Key("log_ips").MustBool(false)
|
||||
LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false)
|
||||
|
||||
app.MustSetValue("advanced", "auth_retry_count", "6")
|
||||
app.MustSetValue("advanced", "auth_retry_gap", "10")
|
||||
|
||||
app.MustSetValue("ui", "port", "8056")
|
||||
app.MustSetValue("advanced", "tls_port", "8057")
|
||||
|
||||
app.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 app.config.Section("user_page").Key(v).MustBool(true) {
|
||||
allDisabled = false
|
||||
}
|
||||
}
|
||||
if allDisabled {
|
||||
app.info.Println(lm.EnableAllPWRMethods)
|
||||
for _, v := range pwrMethods {
|
||||
app.config.Section("user_page").Key(v).SetValue("true")
|
||||
}
|
||||
}
|
||||
|
||||
messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false)
|
||||
telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false)
|
||||
@@ -132,6 +235,28 @@ func (app *appContext) loadConfig() error {
|
||||
messagesEnabled = false
|
||||
}
|
||||
|
||||
if app.proxyEnabled = app.config.Section("advanced").Key("proxy").MustBool(false); app.proxyEnabled {
|
||||
app.proxyConfig = easyproxy.ProxyConfig{}
|
||||
app.proxyConfig.Protocol = easyproxy.HTTP
|
||||
if strings.Contains(app.config.Section("advanced").Key("proxy_protocol").MustString("http"), "socks") {
|
||||
app.proxyConfig.Protocol = easyproxy.SOCKS5
|
||||
}
|
||||
app.proxyConfig.Addr = app.config.Section("advanced").Key("proxy_address").MustString("")
|
||||
app.proxyConfig.User = app.config.Section("advanced").Key("proxy_user").MustString("")
|
||||
app.proxyConfig.Password = app.config.Section("advanced").Key("proxy_password").MustString("")
|
||||
app.proxyTransport, err = easyproxy.NewTransport(app.proxyConfig)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedInitProxy, app.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)
|
||||
app.proxyEnabled = false
|
||||
} else {
|
||||
app.proxyEnabled = true
|
||||
app.info.Printf(lm.InitProxy, app.proxyConfig.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
app.MustSetValue("updates", "enabled", "true")
|
||||
releaseChannel := app.config.Section("updates").Key("channel").String()
|
||||
if app.config.Section("updates").Key("enabled").MustBool(false) {
|
||||
@@ -144,6 +269,9 @@ func (app *appContext) loadConfig() error {
|
||||
v = "git"
|
||||
}
|
||||
app.updater = newUpdater(baseURL, namespace, repo, v, commit, updater)
|
||||
if app.proxyEnabled {
|
||||
app.updater.SetTransport(app.proxyTransport)
|
||||
}
|
||||
}
|
||||
if releaseChannel == "" {
|
||||
if version == "git" {
|
||||
@@ -154,9 +282,6 @@ func (app *appContext) loadConfig() error {
|
||||
app.MustSetValue("updates", "channel", releaseChannel)
|
||||
}
|
||||
|
||||
app.storage.customEmails_path = app.config.Section("files").Key("custom_emails").String()
|
||||
app.storage.loadCustomEmails()
|
||||
|
||||
substituteStrings = app.config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("")
|
||||
|
||||
if substituteStrings != "" {
|
||||
@@ -166,11 +291,11 @@ func (app *appContext) loadConfig() error {
|
||||
|
||||
oldFormLang := app.config.Section("ui").Key("language").MustString("")
|
||||
if oldFormLang != "" {
|
||||
app.storage.lang.chosenFormLang = oldFormLang
|
||||
app.storage.lang.chosenUserLang = oldFormLang
|
||||
}
|
||||
newFormLang := app.config.Section("ui").Key("language-form").MustString("")
|
||||
if newFormLang != "" {
|
||||
app.storage.lang.chosenFormLang = 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")
|
||||
@@ -181,3 +306,98 @@ func (app *appContext) loadConfig() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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.
|
||||
1652
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)
|
||||
|
||||
268
css/base.css
@@ -1,18 +1,25 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import "remixicon.css";
|
||||
@import "./modal.css";
|
||||
@import "./dark.css";
|
||||
@import "./tooltip.css";
|
||||
@import "./loader.css";
|
||||
@import "./fonts.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--border-width-default: 2px;
|
||||
--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";
|
||||
|
||||
--bg-light: #fff;
|
||||
--bg-dark: #101010;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.light {
|
||||
@@ -21,23 +28,33 @@
|
||||
|
||||
.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: var(--bg-light);
|
||||
}
|
||||
|
||||
.dark select, .dark option, .dark input {
|
||||
background: #202020;
|
||||
}
|
||||
|
||||
html:not(.dark) .card.\@low:not(.\~neutral):not(.\~positive):not(.\~urge):not(.\~warning):not(.\~info):not(.\~critical) {
|
||||
--color-fill: va(--color-fill);
|
||||
--color-content: var(--color-content);
|
||||
--color-accent: var(--color-accent);
|
||||
--color-muted: var(--color-muted);
|
||||
html:not(.dark) .card.\@low:not(.\~neutral):not(.\~positive):not(.\~urge):not(.\~warning):not(.\~info):not(.\~critical),
|
||||
html:not(.dark) .card.\@low:not(.\~neutral):not(.\~positive):not(.\~urge):not(.\~warning):not(.\~info):not(.\~critical) > * {
|
||||
/* Colors from ~neutral */
|
||||
--color-fill-high: #64748b;
|
||||
--color-fill-low: #e2e8f0;
|
||||
--color-content-high: #f8fafc;
|
||||
--color-content-low: #1e293b;
|
||||
--color-accent-high: #475569;
|
||||
--color-accent-low: #cbd5e1;
|
||||
--color-muted-high: #475569;
|
||||
--color-muted-low: #f1f5f9;
|
||||
background-color: #fff;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.light-only {
|
||||
@@ -48,18 +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) {
|
||||
@media screen and (max-width: 1024px) {
|
||||
:root {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@@ -88,66 +94,10 @@ html:not(.dark) .card.\@low:not(.\~neutral):not(.\~positive):not(.\~urge):not(.\
|
||||
padding: var(--spacing-4,1rem);
|
||||
}
|
||||
|
||||
.modal-content .banner {
|
||||
margin-left: calc(-1 * var(--spacing-4,1rem) - 0.5%); /* Not sure why this is necessary */
|
||||
margin-right: calc(-1 * var(--spacing-4,1rem) - 0.5%);
|
||||
}
|
||||
|
||||
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 {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-expand {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.flex-row-group {
|
||||
display: block;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -172,11 +122,7 @@ span.sm:not(.heading) {
|
||||
margin: .25rem;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Who knows for half of these to be honest */
|
||||
@media screen and (max-width: 400px) {
|
||||
.row {
|
||||
flex-direction: column;
|
||||
@@ -207,69 +153,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 */
|
||||
}
|
||||
@@ -288,10 +171,6 @@ sup.\~critical, .text-critical {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex-auto {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.center {
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -300,14 +179,6 @@ sup.\~critical, .text-critical {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.no-lp {
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.focused {
|
||||
display: block;
|
||||
}
|
||||
@@ -404,7 +275,16 @@ 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;
|
||||
}
|
||||
|
||||
p.top {
|
||||
margin-top: 0px;
|
||||
@@ -427,6 +307,10 @@ p.top {
|
||||
margin-bottom: -0.5rem;
|
||||
}
|
||||
|
||||
.dropdown.over-top {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.dropdown-display.lg {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -474,15 +358,24 @@ a:hover:not(.lang-link):not(.\~urge), a:active:not(.lang-link):not(.\~urge) {
|
||||
color: var(--color-urge-200);
|
||||
}
|
||||
|
||||
a.button,
|
||||
a.button:link,
|
||||
a.button:visited,
|
||||
a.button:focus,
|
||||
a.buton:hover {
|
||||
color: var(--color-content) !important;
|
||||
}
|
||||
|
||||
|
||||
.link-center {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search {
|
||||
/* .search {
|
||||
max-width: 15rem;
|
||||
min-width: 10rem;
|
||||
}
|
||||
} */
|
||||
|
||||
td.img-circle {
|
||||
width: 32px;
|
||||
@@ -537,6 +430,59 @@ div.card:contains(section.banner.footer) {
|
||||
padding-bottom: 0px !important
|
||||
}
|
||||
|
||||
.mx-0i {
|
||||
margin-left: 0px !important;
|
||||
margin-right: 0px !important
|
||||
}
|
||||
|
||||
.text-center-i {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:before, .modal-close {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.g-recaptcha {
|
||||
overflow: hidden;
|
||||
width: 296px;
|
||||
height: 72px;
|
||||
transform: scale(1.1);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.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-1 items-center;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
/* seems to be the sweet spot */
|
||||
--inside-input-base: -2.6rem;
|
||||
|
||||
/* 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-left: calc(var(--inside-input-base) - 1.0*var(--tailwind-spacing));
|
||||
}
|
||||
|
||||
.gap-2 > .button.inside-input {
|
||||
margin-left: calc(var(--inside-input-base) - 2.0*var(--tailwind-spacing));
|
||||
}
|
||||
|
||||
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,54 +10,22 @@
|
||||
background-color: rgba(0,0,0,40%);
|
||||
}
|
||||
|
||||
.modal-shown {
|
||||
display: block;
|
||||
.wall {
|
||||
position: fixed;
|
||||
z-index: 11;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@keyframes modal-hide {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
html.dark .wall {
|
||||
background-color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.modal-hiding {
|
||||
animation: modal-hide 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
@keyframes modal-content-show {
|
||||
from {
|
||||
opacity: 0;
|
||||
top: -6rem;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
margin: 10% auto;
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.modal-content.wide {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.modal-shown .modal-content {
|
||||
animation: modal-content-show 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1000px) {
|
||||
.modal-content.wide {
|
||||
width: 75%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
.modal-content, .modal-content.wide {
|
||||
width: 90%;
|
||||
}
|
||||
html:not(.dark) .wall {
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
.tooltip .content {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
max-width: 10rem;
|
||||
min-width: 6rem;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
@@ -13,12 +14,29 @@
|
||||
border-radius: 6px;
|
||||
overflow-wrap: break-word;
|
||||
text-align: center;
|
||||
transition: opacity 100ms;
|
||||
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: -1rem;
|
||||
}
|
||||
|
||||
.tooltip.below .content {
|
||||
top: 2.5rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.tooltip.above .content {
|
||||
bottom: 2.5rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.tooltip.darker .content {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.tooltip.right .content {
|
||||
left: 120%;
|
||||
}
|
||||
@@ -31,6 +49,10 @@
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tooltip:hover .content {
|
||||
.tooltip:hover .content,
|
||||
.tooltip:focus .content,
|
||||
.tooltip:focus-within .content
|
||||
{
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
696
discord.go
@@ -2,23 +2,31 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dg "github.com/bwmarrin/discordgo"
|
||||
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 []string
|
||||
verifiedTokens map[string]DiscordUser // Map of tokens 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.
|
||||
app *appContext
|
||||
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
|
||||
}
|
||||
|
||||
func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
|
||||
@@ -34,22 +42,42 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
|
||||
Stopped: false,
|
||||
ShutdownChannel: make(chan string),
|
||||
bot: bot,
|
||||
tokens: []string{},
|
||||
tokens: map[string]VerifToken{},
|
||||
verifiedTokens: map[string]DiscordUser{},
|
||||
users: map[string]DiscordUser{},
|
||||
app: app,
|
||||
roleID: app.config.Section("discord").Key("apply_role").String(),
|
||||
commandHandlers: map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string){},
|
||||
commandIDs: []string{},
|
||||
}
|
||||
for _, user := range app.storage.discord {
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
d.tokens = append(d.tokens, pin)
|
||||
d.tokens[pin] = VerifToken{Expiry: time.Now().Add(VERIF_TOKEN_EXPIRY_SEC * time.Second), JellyfinID: ""}
|
||||
return pin
|
||||
}
|
||||
|
||||
// NewAssignedAuthToken generates an 8-character pin in the form "A1-2B-CD",
|
||||
// and assigns it for access only with the given Jellyfin ID.
|
||||
func (d *DiscordDaemon) NewAssignedAuthToken(id string) string {
|
||||
pin := genAuthToken()
|
||||
d.tokens[pin] = VerifToken{Expiry: time.Now().Add(VERIF_TOKEN_EXPIRY_SEC * time.Second), JellyfinID: id}
|
||||
return pin
|
||||
}
|
||||
|
||||
@@ -71,10 +99,11 @@ func (d *DiscordDaemon) MustGetUser(channelID, userID, discrim, username string)
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) run() {
|
||||
d.bot.AddHandler(d.messageHandler)
|
||||
d.bot.AddHandler(d.commandHandler)
|
||||
|
||||
d.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers | dg.IntentsGuildInvites
|
||||
if err := d.bot.Open(); err != nil {
|
||||
d.app.err.Printf("Discord: Failed to start daemon: %v", err)
|
||||
d.app.err.Printf(lm.FailedStartDaemon, lm.Discord, err)
|
||||
return
|
||||
}
|
||||
// Wait for everything to populate, it's slow sometimes.
|
||||
@@ -92,63 +121,114 @@ 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
|
||||
}
|
||||
}
|
||||
err = d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start"))
|
||||
defer d.deregisterCommands()
|
||||
defer d.bot.Close()
|
||||
|
||||
go d.registerCommands()
|
||||
|
||||
<-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.
|
||||
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(lm.FailedGetDiscordRoles, err)
|
||||
return
|
||||
}
|
||||
for _, role := range r {
|
||||
if role.Name != d.username && role.Name != "@everyone" {
|
||||
roles = append(roles, [2]string{role.ID, role.Name})
|
||||
}
|
||||
}
|
||||
// roles = make([][2]string, len(r))
|
||||
// for i, role := range r {
|
||||
// roles[i] = [2]string{role.ID, role.Name}
|
||||
// }
|
||||
return
|
||||
}
|
||||
|
||||
// ApplyRole applies the member role to the given user if set.
|
||||
func (d *DiscordDaemon) ApplyRole(userID string) error {
|
||||
if d.roleID == "" {
|
||||
return nil
|
||||
}
|
||||
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,
|
||||
@@ -157,19 +237,38 @@ 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
|
||||
}
|
||||
iconURL = guild.IconURL()
|
||||
iconURL = guild.IconURL("256")
|
||||
return
|
||||
}
|
||||
|
||||
// RenderDiscordUsername returns String of discord username, with support for new discriminator-less versions.
|
||||
func RenderDiscordUsername[DcUser *dg.User | DiscordUser](user DcUser) string {
|
||||
u, ok := interface{}(user).(*dg.User)
|
||||
var discriminator, username string
|
||||
if ok {
|
||||
discriminator = u.Discriminator
|
||||
username = u.Username
|
||||
} else {
|
||||
u2 := interface{}(user).(DiscordUser)
|
||||
discriminator = u2.Discriminator
|
||||
username = u2.Username
|
||||
}
|
||||
|
||||
if discriminator == "0" {
|
||||
return "@" + username
|
||||
}
|
||||
return username + "#" + discriminator
|
||||
}
|
||||
|
||||
// Returns the user(s) roughly corresponding to the username (if they are in the guild).
|
||||
// if no discriminator (#xxxx) is given in the username and there are multiple corresponding users, a list of all matching users is returned.
|
||||
func (d *DiscordDaemon) GetUsers(username string) []*dg.Member {
|
||||
@@ -179,10 +278,14 @@ 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, "#")
|
||||
hasAt := strings.HasPrefix(username, "@")
|
||||
if hasAt {
|
||||
username = username[1:]
|
||||
}
|
||||
var users []*dg.Member
|
||||
for _, member := range members {
|
||||
if hasDiscriminator {
|
||||
@@ -190,6 +293,11 @@ func (d *DiscordDaemon) GetUsers(username string) []*dg.Member {
|
||||
return []*dg.Member{member}
|
||||
}
|
||||
}
|
||||
if hasAt {
|
||||
if member.User.Username == username && member.User.Discriminator == "0" {
|
||||
return []*dg.Member{member}
|
||||
}
|
||||
}
|
||||
if strings.Contains(member.User.Username, username) {
|
||||
users = append(users, member)
|
||||
}
|
||||
@@ -200,7 +308,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
|
||||
@@ -209,7 +317,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
|
||||
@@ -224,137 +332,387 @@ func (d *DiscordDaemon) Shutdown() {
|
||||
close(d.ShutdownChannel)
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) messageHandler(s *dg.Session, m *dg.MessageCreate) {
|
||||
if m.GuildID != "" && d.channelName != "" {
|
||||
if d.channelID == "" {
|
||||
channel, err := s.Channel(m.ChannelID)
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Couldn't get channel, will monitor all: %v", err)
|
||||
d.channelName = ""
|
||||
func (d *DiscordDaemon) registerCommands() {
|
||||
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.",
|
||||
},
|
||||
{
|
||||
Name: "lang",
|
||||
Description: "Set the language for the bot.",
|
||||
Options: []*dg.ApplicationCommandOption{
|
||||
{
|
||||
Type: dg.ApplicationCommandOptionString,
|
||||
Name: "language",
|
||||
Description: "Language Name",
|
||||
Required: true,
|
||||
Choices: []*dg.ApplicationCommandOptionChoice{},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "pin",
|
||||
Description: "Send PIN for Discord verification.",
|
||||
Options: []*dg.ApplicationCommandOption{
|
||||
{
|
||||
Type: dg.ApplicationCommandOptionString,
|
||||
Name: "pin",
|
||||
Description: "Verification PIN (e.g AB-CD-EF)",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
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 {
|
||||
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(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 d.commandDescriptions {
|
||||
command, err := d.bot.ApplicationCommandCreate(d.bot.State.User.ID, d.guildID, cmd)
|
||||
if err != nil {
|
||||
d.app.err.Printf(lm.FailedRegisterDiscordCommand, cmd.Name, err)
|
||||
} else {
|
||||
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(lm.FailedGetDiscordCommands, err)
|
||||
return
|
||||
}
|
||||
for _, cmd := range existingCommands {
|
||||
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.Channel.Name != "" {
|
||||
if d.Channel.ID == "" {
|
||||
channel, err := s.Channel(i.ChannelID)
|
||||
if err != nil {
|
||||
d.app.err.Printf(lm.FailedGetDiscordChannel, i.ChannelID, err)
|
||||
d.app.err.Println(lm.MonitorAllDiscordChannels)
|
||||
d.Channel.Name = ""
|
||||
}
|
||||
if channel.Name == d.Channel.Name {
|
||||
d.Channel.ID = channel.ID
|
||||
}
|
||||
}
|
||||
if channel.Name == d.channelName {
|
||||
d.channelID = channel.ID
|
||||
if d.Channel.ID != i.ChannelID {
|
||||
d.app.debug.Printf(lm.IgnoreOutOfChannelMessage, lm.Discord)
|
||||
return
|
||||
}
|
||||
}
|
||||
if d.channelID != m.ChannelID {
|
||||
d.app.debug.Printf("Discord: Ignoring message as not in specified channel")
|
||||
if i.Interaction.Member.User.ID == s.State.User.ID {
|
||||
return
|
||||
}
|
||||
}
|
||||
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
|
||||
lang := d.app.storage.lang.chosenTelegramLang
|
||||
if user, ok := d.users[i.Interaction.Member.User.ID]; ok {
|
||||
if _, ok := d.app.storage.lang.Telegram[user.Lang]; ok {
|
||||
lang = user.Lang
|
||||
}
|
||||
}
|
||||
}
|
||||
switch msg := sects[0]; msg {
|
||||
case d.app.config.Section("discord").Key("start_command").MustString("!start"):
|
||||
d.commandStart(s, m, lang)
|
||||
case "!lang":
|
||||
d.commandLang(s, m, sects, lang)
|
||||
default:
|
||||
d.commandPIN(s, m, sects, lang)
|
||||
h(s, i, lang)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) commandStart(s *dg.Session, m *dg.MessageCreate, lang string) {
|
||||
channel, err := s.UserChannelCreate(m.Author.ID)
|
||||
// cmd* methods handle slash-commands, msg* methods handle ! commands.
|
||||
|
||||
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", m.Author.Username, err)
|
||||
d.app.err.Printf(lm.FailedCreateDiscordDMChannel, i.Interaction.Member.User.ID, err)
|
||||
return
|
||||
}
|
||||
user := d.MustGetUser(channel.ID, m.Author.ID, m.Author.Discriminator, m.Author.Username)
|
||||
d.users[m.Author.ID] = user
|
||||
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)
|
||||
user := 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] = user
|
||||
|
||||
content := d.app.storage.lang.Telegram[lang].Strings.get("discordStartMessage") + "\n"
|
||||
content += d.app.storage.lang.Telegram[lang].Strings.template("languageMessageDiscord", tmpl{"command": "/lang"})
|
||||
err = s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
|
||||
// Type: dg.InteractionResponseChannelMessageWithSource,
|
||||
Type: dg.InteractionResponseChannelMessageWithSource,
|
||||
Data: &dg.InteractionResponseData{
|
||||
Content: content,
|
||||
Flags: 64, // Ephemeral
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
|
||||
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) commandLang(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(),
|
||||
)
|
||||
func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang string) {
|
||||
pin := i.ApplicationCommandData().Options[0].StringValue()
|
||||
user, ok := d.tokens[pin]
|
||||
if !ok || time.Now().After(user.Expiry) {
|
||||
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
|
||||
// Type: dg.InteractionResponseChannelMessageWithSource,
|
||||
Type: dg.InteractionResponseChannelMessageWithSource,
|
||||
Data: &dg.InteractionResponseData{
|
||||
Content: d.app.storage.lang.Telegram[lang].Strings.get("invalidPIN"),
|
||||
Flags: 64, // Ephemeral
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
|
||||
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
|
||||
}
|
||||
delete(d.tokens, pin)
|
||||
return
|
||||
}
|
||||
if _, ok := d.app.storage.lang.Telegram[sects[1]]; ok {
|
||||
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
|
||||
// Type: dg.InteractionResponseChannelMessageWithSource,
|
||||
Type: dg.InteractionResponseChannelMessageWithSource,
|
||||
Data: &dg.InteractionResponseData{
|
||||
Content: d.app.storage.lang.Telegram[lang].Strings.get("pinSuccess"),
|
||||
Flags: 64, // Ephemeral
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
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
|
||||
d.verifiedTokens[pin] = dcUser
|
||||
delete(d.tokens, pin)
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) cmdLang(s *dg.Session, i *dg.InteractionCreate, lang string) {
|
||||
code := i.ApplicationCommandData().Options[0].StringValue()
|
||||
if _, ok := d.app.storage.lang.Telegram[code]; ok {
|
||||
var user DiscordUser
|
||||
for jfID, user := range d.app.storage.discord {
|
||||
if user.ID == m.Author.ID {
|
||||
user.Lang = sects[1]
|
||||
d.app.storage.discord[jfID] = user
|
||||
if err := d.app.storage.storeDiscordUsers(); err != nil {
|
||||
d.app.err.Printf("Failed to store Discord users: %v", err)
|
||||
}
|
||||
for _, u := range d.app.storage.GetDiscord() {
|
||||
if u.ID == i.Interaction.Member.User.ID {
|
||||
u.Lang = code
|
||||
lang = code
|
||||
d.app.storage.SetDiscordKey(u.JellyfinID, u)
|
||||
user = u
|
||||
break
|
||||
}
|
||||
}
|
||||
d.users[m.Author.ID] = user
|
||||
d.users[i.Interaction.Member.User.ID] = user
|
||||
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
|
||||
// Type: dg.InteractionResponseChannelMessageWithSource,
|
||||
Type: dg.InteractionResponseChannelMessageWithSource,
|
||||
Data: &dg.InteractionResponseData{
|
||||
Content: d.app.storage.lang.Telegram[lang].Strings.template("languageSet", tmpl{"language": d.app.storage.lang.Telegram[lang].Meta.Name}),
|
||||
Flags: 64, // Ephemeral
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) commandPIN(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
|
||||
}
|
||||
tokenIndex := -1
|
||||
for i, token := range d.tokens {
|
||||
if sects[0] == token {
|
||||
tokenIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if tokenIndex == -1 {
|
||||
_, 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)
|
||||
}
|
||||
return
|
||||
}
|
||||
_, err := s.ChannelMessageSend(
|
||||
m.ChannelID,
|
||||
d.app.storage.lang.Telegram[lang].Strings.get("pinSuccess"),
|
||||
)
|
||||
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("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
|
||||
d.app.err.Printf(lm.FailedCreateDiscordDMChannel, i.Interaction.Member.User.ID, err)
|
||||
return
|
||||
}
|
||||
d.verifiedTokens[sects[0]] = d.users[m.Author.ID]
|
||||
d.tokens[len(d.tokens)-1], d.tokens[tokenIndex] = d.tokens[tokenIndex], d.tokens[len(d.tokens)-1]
|
||||
d.tokens = d.tokens[:len(d.tokens)-1]
|
||||
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)
|
||||
// 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))
|
||||
s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
|
||||
Type: dg.InteractionResponseChannelMessageWithSource,
|
||||
Data: &dg.InteractionResponseData{
|
||||
Content: d.app.storage.lang.Telegram[lang].Strings.get("noPermission"),
|
||||
Flags: 64, // Ephemeral
|
||||
},
|
||||
})
|
||||
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 && d.app.config.Section("invite_emails").Key("enabled").MustBool(false) {
|
||||
invname, err := d.bot.GuildMember(d.guildID, recipient.ID)
|
||||
invite.SendTo = invname.User.Username
|
||||
msg, err := d.app.email.constructInvite(invite.Code, invite, d.app, false)
|
||||
if err != nil {
|
||||
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, invite.Code, err)
|
||||
d.app.err.Println(invite.SendTo)
|
||||
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
|
||||
Type: dg.InteractionResponseChannelMessageWithSource,
|
||||
Data: &dg.InteractionResponseData{
|
||||
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInviteFailure"),
|
||||
Flags: 64, // Ephemeral
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
err = d.app.discord.SendDM(msg, recipient.ID)
|
||||
if err != nil {
|
||||
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, RenderDiscordUsername(recipient), err)
|
||||
d.app.err.Println(invite.SendTo)
|
||||
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
|
||||
Type: dg.InteractionResponseChannelMessageWithSource,
|
||||
Data: &dg.InteractionResponseData{
|
||||
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInviteFailure"),
|
||||
Flags: 64, // Ephemeral
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
|
||||
}
|
||||
} else {
|
||||
d.app.info.Printf(lm.SentInviteMessage, invite.Code, RenderDiscordUsername(recipient))
|
||||
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
|
||||
Type: dg.InteractionResponseChannelMessageWithSource,
|
||||
Data: &dg.InteractionResponseData{
|
||||
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInvite"),
|
||||
Flags: 64, // Ephemeral
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//if profile != "" {
|
||||
d.app.storage.SetInvitesKey(invite.Code, invite)
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) SendDM(message *Message, userID ...string) error {
|
||||
@@ -408,3 +766,69 @@ func (d *DiscordDaemon) Send(message *Message, channelID ...string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserVerified returns whether or not a token with the given PIN has been verified, and the user itself.
|
||||
func (d *DiscordDaemon) UserVerified(pin string) (ContactMethodUser, bool) {
|
||||
u, ok := d.verifiedTokens[pin]
|
||||
// delete(d.verifiedTokens, pin)
|
||||
return &u, ok
|
||||
}
|
||||
|
||||
// AssignedUserVerified returns whether or not a user with the given PIN has been verified, and the token itself.
|
||||
// Returns false if the given Jellyfin ID does not match the one in the user.
|
||||
func (d *DiscordDaemon) AssignedUserVerified(pin string, jfID string) (user DiscordUser, ok bool) {
|
||||
user, ok = d.verifiedTokens[pin]
|
||||
if ok && user.JellyfinID != jfID {
|
||||
ok = false
|
||||
}
|
||||
// delete(d.verifiedUsers, pin)
|
||||
return
|
||||
}
|
||||
|
||||
// UserExists returns whether or not a user with the given ID exists.
|
||||
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
|
||||
}
|
||||
|
||||
// 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.18
|
||||
|
||||
require golang.org/x/net v0.36.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.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
|
||||
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
|
||||
353
email.go
@@ -10,6 +10,8 @@ import (
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -18,12 +20,16 @@ import (
|
||||
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/html"
|
||||
"github.com/hrfee/jfa-go/easyproxy"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/itchyny/timefmt-go"
|
||||
"github.com/mailgun/mailgun-go/v4"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
sMail "github.com/xhit/go-simple-mail/v2"
|
||||
)
|
||||
|
||||
var renderer = html.NewRenderer(html.RendererOptions{Flags: html.Smartypants})
|
||||
var markdownRenderer = html.NewRenderer(html.RendererOptions{Flags: html.Smartypants})
|
||||
|
||||
// EmailClient implements email sending, right now via smtp, mailgun or a dummy client.
|
||||
type EmailClient interface {
|
||||
@@ -75,21 +81,31 @@ func NewEmailer(app *appContext) *Emailer {
|
||||
}
|
||||
method := app.config.Section("email").Key("method").String()
|
||||
if method == "smtp" {
|
||||
sslTLS := false
|
||||
if app.config.Section("smtp").Key("encryption").String() == "ssl_tls" {
|
||||
sslTLS = true
|
||||
enc := sMail.EncryptionSTARTTLS
|
||||
switch app.config.Section("smtp").Key("encryption").String() {
|
||||
case "ssl_tls":
|
||||
enc = sMail.EncryptionSSLTLS
|
||||
case "starttls":
|
||||
enc = sMail.EncryptionSTARTTLS
|
||||
case "none":
|
||||
enc = sMail.EncryptionNone
|
||||
}
|
||||
username := app.config.Section("smtp").Key("username").MustString("")
|
||||
password := app.config.Section("smtp").Key("password").String()
|
||||
if username == "" && password != "" {
|
||||
username = emailer.fromAddr
|
||||
}
|
||||
err := emailer.NewSMTP(app.config.Section("smtp").Key("server").String(), app.config.Section("smtp").Key("port").MustInt(465), username, password, sslTLS, app.config.Section("smtp").Key("ssl_cert").MustString(""), app.config.Section("smtp").Key("hello_hostname").String(), app.config.Section("smtp").Key("cert_validation").MustBool(true))
|
||||
var proxyConf *easyproxy.ProxyConfig = nil
|
||||
if app.proxyEnabled {
|
||||
proxyConf = &app.proxyConfig
|
||||
}
|
||||
authType := sMail.AuthType(app.config.Section("smtp").Key("auth_type").MustInt(4))
|
||||
err := emailer.NewSMTP(app.config.Section("smtp").Key("server").String(), app.config.Section("smtp").Key("port").MustInt(465), username, password, enc, app.config.Section("smtp").Key("ssl_cert").MustString(""), app.config.Section("smtp").Key("hello_hostname").String(), app.config.Section("smtp").Key("cert_validation").MustBool(true), authType, proxyConf)
|
||||
if err != nil {
|
||||
app.err.Printf("Error while initiating SMTP mailer: %v", err)
|
||||
app.err.Printf(lm.FailedInitSMTP, err)
|
||||
}
|
||||
} else if method == "mailgun" {
|
||||
emailer.NewMailgun(app.config.Section("mailgun").Key("api_url").String(), app.config.Section("mailgun").Key("api_key").String())
|
||||
emailer.NewMailgun(app.config.Section("mailgun").Key("api_url").String(), app.config.Section("mailgun").Key("api_key").String(), app.proxyTransport)
|
||||
} else if method == "dummy" {
|
||||
emailer.sender = &DummyClient{}
|
||||
}
|
||||
@@ -110,16 +126,12 @@ type SMTP struct {
|
||||
}
|
||||
|
||||
// NewSMTP returns an SMTP emailClient.
|
||||
func (emailer *Emailer) NewSMTP(server string, port int, username, password string, sslTLS bool, certPath string, helloHostname string, validateCertificate bool) (err error) {
|
||||
func (emailer *Emailer) NewSMTP(server string, port int, username, password string, encryption sMail.Encryption, certPath string, helloHostname string, validateCertificate bool, authType sMail.AuthType, proxy *easyproxy.ProxyConfig) (err error) {
|
||||
sender := &SMTP{}
|
||||
sender.Client = sMail.NewSMTPClient()
|
||||
if sslTLS {
|
||||
sender.Client.Encryption = sMail.EncryptionSSLTLS
|
||||
} else {
|
||||
sender.Client.Encryption = sMail.EncryptionSTARTTLS
|
||||
}
|
||||
sender.Client.Encryption = encryption
|
||||
if username != "" || password != "" {
|
||||
sender.Client.Authentication = sMail.AuthLogin
|
||||
sender.Client.Authentication = authType
|
||||
sender.Client.Username = username
|
||||
sender.Client.Password = password
|
||||
}
|
||||
@@ -128,12 +140,16 @@ func (emailer *Emailer) NewSMTP(server string, port int, username, password stri
|
||||
sender.Client.Host = server
|
||||
sender.Client.Port = port
|
||||
sender.Client.KeepAlive = false
|
||||
|
||||
// x509.SystemCertPool is unavailable on windows
|
||||
if PLATFORM == "windows" {
|
||||
sender.Client.TLSConfig = &tls.Config{
|
||||
InsecureSkipVerify: !validateCertificate,
|
||||
ServerName: server,
|
||||
}
|
||||
if proxy != nil {
|
||||
sender.Client.CustomConn, err = easyproxy.NewConn(*proxy, fmt.Sprintf("%s:%d", server, port), sender.Client.TLSConfig)
|
||||
}
|
||||
emailer.sender = sender
|
||||
return
|
||||
}
|
||||
@@ -153,6 +169,9 @@ func (emailer *Emailer) NewSMTP(server string, port int, username, password stri
|
||||
ServerName: server,
|
||||
RootCAs: rootCAs,
|
||||
}
|
||||
if proxy != nil {
|
||||
sender.Client.CustomConn, err = easyproxy.NewConn(*proxy, fmt.Sprintf("%s:%d", server, port), sender.Client.TLSConfig)
|
||||
}
|
||||
emailer.sender = sender
|
||||
return
|
||||
}
|
||||
@@ -184,13 +203,19 @@ type Mailgun struct {
|
||||
}
|
||||
|
||||
// NewMailgun returns a Mailgun emailClient.
|
||||
func (emailer *Emailer) NewMailgun(url, key string) {
|
||||
func (emailer *Emailer) NewMailgun(url, key string, transport *http.Transport) {
|
||||
sender := &Mailgun{
|
||||
client: mailgun.NewMailgun(strings.Split(emailer.fromAddr, "@")[1], key),
|
||||
}
|
||||
if transport != nil {
|
||||
cli := sender.client.Client()
|
||||
cli.Transport = transport
|
||||
}
|
||||
// Mailgun client takes the base url, so we need to trim off the end (e.g 'v3/messages')
|
||||
if strings.Contains(url, "messages") {
|
||||
url = url[0:strings.LastIndex(url, "/")]
|
||||
}
|
||||
if strings.Contains(url, "v3") {
|
||||
url = url[0:strings.LastIndex(url, "/")]
|
||||
}
|
||||
sender.client.SetAPIBase(url)
|
||||
@@ -301,11 +326,12 @@ func (emailer *Emailer) confirmationValues(code, username, key string, app *appC
|
||||
}
|
||||
} else {
|
||||
message := app.config.Section("messages").Key("message").String()
|
||||
inviteLink := app.config.Section("invite_emails").Key("url_base").String()
|
||||
if !strings.HasSuffix(inviteLink, "/invite") {
|
||||
inviteLink += "/invite"
|
||||
inviteLink := app.ExternalURI
|
||||
if code == "" { // Personal email change
|
||||
inviteLink = fmt.Sprintf("%s/my/confirm/%s", inviteLink, url.PathEscape(key))
|
||||
} else { // Invite email confirmation
|
||||
inviteLink = fmt.Sprintf("%s%s/%s?key=%s", inviteLink, PAGES.Form, code, url.PathEscape(key))
|
||||
}
|
||||
inviteLink = fmt.Sprintf("%s/%s?key=%s", inviteLink, code, key)
|
||||
template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username})
|
||||
template["confirmationURL"] = inviteLink
|
||||
template["message"] = message
|
||||
@@ -319,10 +345,11 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, app *a
|
||||
}
|
||||
var err error
|
||||
template := emailer.confirmationValues(code, username, key, app, noSub)
|
||||
if app.storage.customEmails.EmailConfirmation.Enabled {
|
||||
message := app.storage.MustGetCustomContentKey("EmailConfirmation")
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
app.storage.customEmails.EmailConfirmation.Content,
|
||||
app.storage.customEmails.EmailConfirmation.Variables,
|
||||
message.Content,
|
||||
message.Variables,
|
||||
nil,
|
||||
template,
|
||||
)
|
||||
@@ -343,7 +370,7 @@ func (emailer *Emailer) constructTemplate(subject, md string, app *appContext, u
|
||||
subject = templateEmail(subject, []string{"{username}"}, nil, map[string]interface{}{"username": username[0]})
|
||||
}
|
||||
email := &Message{Subject: subject}
|
||||
html := markdown.ToHTML([]byte(md), nil, renderer)
|
||||
html := markdown.ToHTML([]byte(md), nil, markdownRenderer)
|
||||
text := stripMarkdown(md)
|
||||
message := app.config.Section("messages").Key("message").String()
|
||||
var err error
|
||||
@@ -367,11 +394,7 @@ func (emailer *Emailer) inviteValues(code string, invite Invite, app *appContext
|
||||
expiry := invite.ValidTill
|
||||
d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern)
|
||||
message := app.config.Section("messages").Key("message").String()
|
||||
inviteLink := app.config.Section("invite_emails").Key("url_base").String()
|
||||
if !strings.HasSuffix(inviteLink, "/invite") {
|
||||
inviteLink += "/invite"
|
||||
}
|
||||
inviteLink = fmt.Sprintf("%s/%s", inviteLink, code)
|
||||
inviteLink := fmt.Sprintf("%s%s/%s", app.ExternalURI, PAGES.Form, code)
|
||||
template := map[string]interface{}{
|
||||
"hello": emailer.lang.InviteEmail.get("hello"),
|
||||
"youHaveBeenInvited": emailer.lang.InviteEmail.get("youHaveBeenInvited"),
|
||||
@@ -402,10 +425,11 @@ func (emailer *Emailer) constructInvite(code string, invite Invite, app *appCont
|
||||
}
|
||||
template := emailer.inviteValues(code, invite, app, noSub)
|
||||
var err error
|
||||
if app.storage.customEmails.InviteEmail.Enabled {
|
||||
message := app.storage.MustGetCustomContentKey("InviteEmail")
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
app.storage.customEmails.InviteEmail.Content,
|
||||
app.storage.customEmails.InviteEmail.Variables,
|
||||
message.Content,
|
||||
message.Variables,
|
||||
nil,
|
||||
template,
|
||||
)
|
||||
@@ -441,10 +465,11 @@ func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appCont
|
||||
}
|
||||
var err error
|
||||
template := emailer.expiryValues(code, invite, app, noSub)
|
||||
if app.storage.customEmails.InviteExpiry.Enabled {
|
||||
message := app.storage.MustGetCustomContentKey("InviteExpiry")
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
app.storage.customEmails.InviteExpiry.Content,
|
||||
app.storage.customEmails.InviteExpiry.Variables,
|
||||
message.Content,
|
||||
message.Variables,
|
||||
nil,
|
||||
template,
|
||||
)
|
||||
@@ -495,10 +520,11 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite
|
||||
}
|
||||
template := emailer.createdValues(code, username, address, invite, app, noSub)
|
||||
var err error
|
||||
if app.storage.customEmails.UserCreated.Enabled {
|
||||
message := app.storage.MustGetCustomContentKey("UserCreated")
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
app.storage.customEmails.UserCreated.Content,
|
||||
app.storage.customEmails.UserCreated.Variables,
|
||||
message.Content,
|
||||
message.Variables,
|
||||
nil,
|
||||
template,
|
||||
)
|
||||
@@ -512,18 +538,6 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite
|
||||
return email, nil
|
||||
}
|
||||
|
||||
// GenResetLink generates and returns a password reset link.
|
||||
func (app *appContext) GenResetLink(pin string) (string, error) {
|
||||
url := app.config.Section("password_resets").Key("url_base").String()
|
||||
var pinLink string
|
||||
if url == "" {
|
||||
return pinLink, fmt.Errorf("disabled as no URL Base provided. Set in Settings > Password Resets.")
|
||||
}
|
||||
// Strip /invite from end of this URL, ik it's ugly.
|
||||
pinLink = fmt.Sprintf("%s/reset?pin=%s", url, pin)
|
||||
return pinLink, nil
|
||||
}
|
||||
|
||||
func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bool) map[string]interface{} {
|
||||
d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern)
|
||||
message := app.config.Section("messages").Key("message").String()
|
||||
@@ -563,7 +577,7 @@ func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bo
|
||||
// Only used in html email.
|
||||
template["pin_code"] = pwr.Pin
|
||||
} else {
|
||||
app.info.Println("Couldn't generate PWR link: %v", err)
|
||||
app.info.Printf(lm.FailedGeneratePWRLink, err)
|
||||
template["pin"] = pwr.Pin
|
||||
}
|
||||
} else {
|
||||
@@ -580,10 +594,11 @@ func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub
|
||||
}
|
||||
template := emailer.resetValues(pwr, app, noSub)
|
||||
var err error
|
||||
if app.storage.customEmails.PasswordReset.Enabled {
|
||||
message := app.storage.MustGetCustomContentKey("PasswordReset")
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
app.storage.customEmails.PasswordReset.Content,
|
||||
app.storage.customEmails.PasswordReset.Variables,
|
||||
message.Content,
|
||||
message.Variables,
|
||||
nil,
|
||||
template,
|
||||
)
|
||||
@@ -612,7 +627,6 @@ func (emailer *Emailer) deletedValues(reason string, app *appContext, noSub bool
|
||||
template["reason"] = reason
|
||||
template["message"] = app.config.Section("messages").Key("message").String()
|
||||
}
|
||||
fmt.Println("TTTT", template)
|
||||
return template
|
||||
}
|
||||
|
||||
@@ -622,10 +636,11 @@ func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub b
|
||||
}
|
||||
var err error
|
||||
template := emailer.deletedValues(reason, app, noSub)
|
||||
if app.storage.customEmails.UserDeleted.Enabled {
|
||||
message := app.storage.MustGetCustomContentKey("UserDeleted")
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
app.storage.customEmails.UserDeleted.Content,
|
||||
app.storage.customEmails.UserDeleted.Variables,
|
||||
message.Content,
|
||||
message.Variables,
|
||||
nil,
|
||||
template,
|
||||
)
|
||||
@@ -663,10 +678,11 @@ func (emailer *Emailer) constructDisabled(reason string, app *appContext, noSub
|
||||
}
|
||||
var err error
|
||||
template := emailer.disabledValues(reason, app, noSub)
|
||||
if app.storage.customEmails.UserDisabled.Enabled {
|
||||
message := app.storage.MustGetCustomContentKey("UserDisabled")
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
app.storage.customEmails.UserDisabled.Content,
|
||||
app.storage.customEmails.UserDisabled.Variables,
|
||||
message.Content,
|
||||
message.Variables,
|
||||
nil,
|
||||
template,
|
||||
)
|
||||
@@ -704,10 +720,11 @@ func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub b
|
||||
}
|
||||
var err error
|
||||
template := emailer.enabledValues(reason, app, noSub)
|
||||
if app.storage.customEmails.UserEnabled.Enabled {
|
||||
message := app.storage.MustGetCustomContentKey("UserEnabled")
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
app.storage.customEmails.UserEnabled.Content,
|
||||
app.storage.customEmails.UserEnabled.Variables,
|
||||
message.Content,
|
||||
message.Variables,
|
||||
nil,
|
||||
template,
|
||||
)
|
||||
@@ -721,6 +738,72 @@ func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub b
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func (emailer *Emailer) expiryAdjustedValues(username string, expiry time.Time, reason string, app *appContext, noSub bool, custom bool) map[string]interface{} {
|
||||
template := map[string]interface{}{
|
||||
"yourExpiryWasAdjusted": emailer.lang.UserExpiryAdjusted.get("yourExpiryWasAdjusted"),
|
||||
"ifPreviouslyDisabled": emailer.lang.UserExpiryAdjusted.get("ifPreviouslyDisabled"),
|
||||
"reasonString": emailer.lang.Strings.get("reason"),
|
||||
"newExpiry": "",
|
||||
"message": "",
|
||||
}
|
||||
if noSub {
|
||||
template["helloUser"] = emailer.lang.Strings.get("helloUser")
|
||||
empty := []string{"reason", "newExpiry"}
|
||||
for _, v := range empty {
|
||||
template[v] = "{" + v + "}"
|
||||
}
|
||||
} else {
|
||||
template["reason"] = reason
|
||||
template["message"] = app.config.Section("messages").Key("message").String()
|
||||
template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username})
|
||||
exp := app.formatDatetime(expiry)
|
||||
if !expiry.IsZero() {
|
||||
if custom {
|
||||
template["newExpiry"] = exp
|
||||
} else if !expiry.IsZero() {
|
||||
template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
|
||||
"date": exp,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return template
|
||||
}
|
||||
|
||||
func (emailer *Emailer) constructExpiryAdjusted(username string, expiry time.Time, reason string, app *appContext, noSub bool) (*Message, error) {
|
||||
email := &Message{
|
||||
Subject: app.config.Section("user_expiry").Key("adjustment_subject").MustString(emailer.lang.UserExpiryAdjusted.get("title")),
|
||||
}
|
||||
var err error
|
||||
var template map[string]interface{}
|
||||
message := app.storage.MustGetCustomContentKey("UserExpiryAdjusted")
|
||||
if message.Enabled {
|
||||
template = emailer.expiryAdjustedValues(username, expiry, reason, app, noSub, true)
|
||||
} else {
|
||||
template = emailer.expiryAdjustedValues(username, expiry, reason, app, noSub, false)
|
||||
}
|
||||
if noSub {
|
||||
template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
|
||||
"date": "{newExpiry}",
|
||||
})
|
||||
}
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
message.Content,
|
||||
message.Variables,
|
||||
nil,
|
||||
template,
|
||||
)
|
||||
email, err = emailer.constructTemplate(email.Subject, content, app)
|
||||
} else {
|
||||
email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "user_expiry", "adjustment_email_", template)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func (emailer *Emailer) welcomeValues(username string, expiry time.Time, app *appContext, noSub bool, custom bool) map[string]interface{} {
|
||||
template := map[string]interface{}{
|
||||
"welcome": emailer.lang.WelcomeEmail.get("welcome"),
|
||||
@@ -759,7 +842,8 @@ func (emailer *Emailer) constructWelcome(username string, expiry time.Time, app
|
||||
}
|
||||
var err error
|
||||
var template map[string]interface{}
|
||||
if app.storage.customEmails.WelcomeEmail.Enabled {
|
||||
message := app.storage.MustGetCustomContentKey("WelcomeEmail")
|
||||
if message.Enabled {
|
||||
template = emailer.welcomeValues(username, expiry, app, noSub, true)
|
||||
} else {
|
||||
template = emailer.welcomeValues(username, expiry, app, noSub, false)
|
||||
@@ -769,11 +853,11 @@ func (emailer *Emailer) constructWelcome(username string, expiry time.Time, app
|
||||
"date": "{yourAccountWillExpire}",
|
||||
})
|
||||
}
|
||||
if app.storage.customEmails.WelcomeEmail.Enabled {
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
app.storage.customEmails.WelcomeEmail.Content,
|
||||
app.storage.customEmails.WelcomeEmail.Variables,
|
||||
app.storage.customEmails.WelcomeEmail.Conditionals,
|
||||
message.Content,
|
||||
message.Variables,
|
||||
message.Conditionals,
|
||||
template,
|
||||
)
|
||||
email, err = emailer.constructTemplate(email.Subject, content, app)
|
||||
@@ -804,10 +888,11 @@ func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Mess
|
||||
}
|
||||
var err error
|
||||
template := emailer.userExpiredValues(app, noSub)
|
||||
if app.storage.customEmails.UserExpired.Enabled {
|
||||
message := app.storage.MustGetCustomContentKey("UserExpired")
|
||||
if message.Enabled {
|
||||
content := templateEmail(
|
||||
app.storage.customEmails.UserExpired.Content,
|
||||
app.storage.customEmails.UserExpired.Variables,
|
||||
message.Content,
|
||||
message.Variables,
|
||||
nil,
|
||||
template,
|
||||
)
|
||||
@@ -826,49 +911,123 @@ func (emailer *Emailer) send(email *Message, address ...string) error {
|
||||
return emailer.sender.Send(emailer.fromName, emailer.fromAddr, email, address...)
|
||||
}
|
||||
|
||||
func (app *appContext) sendByID(email *Message, ID ...string) error {
|
||||
func (app *appContext) sendByID(email *Message, ID ...string) (err error) {
|
||||
for _, id := range ID {
|
||||
var err error
|
||||
if tgChat, ok := app.storage.telegram[id]; ok && tgChat.Contact && telegramEnabled {
|
||||
if tgChat, ok := app.storage.GetTelegramKey(id); ok && tgChat.Contact && telegramEnabled {
|
||||
err = app.telegram.Send(email, tgChat.ChatID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
}
|
||||
if dcChat, ok := app.storage.discord[id]; ok && dcChat.Contact && discordEnabled {
|
||||
if dcChat, ok := app.storage.GetDiscordKey(id); ok && dcChat.Contact && discordEnabled {
|
||||
err = app.discord.Send(email, dcChat.ChannelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
}
|
||||
if mxChat, ok := app.storage.matrix[id]; ok && mxChat.Contact && matrixEnabled {
|
||||
if mxChat, ok := app.storage.GetMatrixKey(id); ok && mxChat.Contact && matrixEnabled {
|
||||
err = app.matrix.Send(email, mxChat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
}
|
||||
if address, ok := app.storage.emails[id]; ok && address.Contact && emailEnabled {
|
||||
if address, ok := app.storage.GetEmailsKey(id); ok && address.Contact && emailEnabled {
|
||||
err = app.email.send(email, address.Addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
}
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
}
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
func (app *appContext) getAddressOrName(jfID string) string {
|
||||
if dcChat, ok := app.storage.discord[jfID]; ok && dcChat.Contact && discordEnabled {
|
||||
return dcChat.Username + "#" + dcChat.Discriminator
|
||||
if dcChat, ok := app.storage.GetDiscordKey(jfID); ok && dcChat.Contact && discordEnabled {
|
||||
return RenderDiscordUsername(dcChat)
|
||||
}
|
||||
if tgChat, ok := app.storage.telegram[jfID]; ok && tgChat.Contact && telegramEnabled {
|
||||
if tgChat, ok := app.storage.GetTelegramKey(jfID); ok && tgChat.Contact && telegramEnabled {
|
||||
return "@" + tgChat.Username
|
||||
}
|
||||
if addr, ok := app.storage.emails[jfID]; ok {
|
||||
if addr, ok := app.storage.GetEmailsKey(jfID); ok {
|
||||
return addr.Addr
|
||||
}
|
||||
if mxChat, ok := app.storage.GetMatrixKey(jfID); ok && mxChat.Contact && matrixEnabled {
|
||||
return mxChat.UserID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ReverseUserSearch returns the jellyfin ID of the user with the given username, email, or contact method username.
|
||||
// returns "" if none found. returns only the first match, might be an issue if there are users with the same contact method usernames.
|
||||
func (app *appContext) ReverseUserSearch(address string, matchUsername, matchEmail, matchContactMethod bool) (user mediabrowser.User, ok bool) {
|
||||
ok = false
|
||||
var err error = nil
|
||||
if matchUsername {
|
||||
user, err = app.jf.UserByName(address, false)
|
||||
if err == nil {
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if matchEmail {
|
||||
emailAddresses := []EmailAddress{}
|
||||
err = app.storage.db.Find(&emailAddresses, badgerhold.Where("Addr").Eq(address))
|
||||
if err == nil && len(emailAddresses) > 0 {
|
||||
for _, emailUser := range emailAddresses {
|
||||
user, err = app.jf.UserByID(emailUser.JellyfinID, false)
|
||||
if err == nil {
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dont know how we'd use badgerhold when we need to render each username,
|
||||
// Apart from storing the rendered name in the db.
|
||||
if matchContactMethod {
|
||||
for _, dcUser := range app.storage.GetDiscord() {
|
||||
if RenderDiscordUsername(dcUser) == strings.ToLower(address) {
|
||||
user, err = app.jf.UserByID(dcUser.JellyfinID, false)
|
||||
if err == nil {
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
tgUsername := strings.TrimPrefix(address, "@")
|
||||
telegramUsers := []TelegramUser{}
|
||||
err = app.storage.db.Find(&telegramUsers, badgerhold.Where("Username").Eq(tgUsername))
|
||||
if err == nil && len(telegramUsers) > 0 {
|
||||
for _, telegramUser := range telegramUsers {
|
||||
user, err = app.jf.UserByID(telegramUser.JellyfinID, false)
|
||||
if err == nil {
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
matrixUsers := []MatrixUser{}
|
||||
err = app.storage.db.Find(&matrixUsers, badgerhold.Where("UserID").Eq(address))
|
||||
if err == nil && len(matrixUsers) > 0 {
|
||||
for _, matrixUser := range matrixUsers {
|
||||
user, err = app.jf.UserByID(matrixUser.JellyfinID, false)
|
||||
if err == nil {
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// EmailAddressExists returns whether or not a user with the given email address exists.
|
||||
func (app *appContext) EmailAddressExists(address string) bool {
|
||||
c, err := app.storage.db.Count(&EmailAddress{}, badgerhold.Where("Addr").Eq(address))
|
||||
return err != nil || c > 0
|
||||
}
|
||||
|
||||
32
exit.go
@@ -5,13 +5,14 @@ import (
|
||||
"html/template"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/browser"
|
||||
"github.com/robert-nix/ansihtml"
|
||||
)
|
||||
|
||||
// https://gist.github.com/swdunlop/9629168
|
||||
@@ -43,6 +44,21 @@ func identifyPanic() string {
|
||||
return fmt.Sprintf("pc:%x", pc)
|
||||
}
|
||||
|
||||
// OpenFile attempts to open a given file in the appropriate GUI application.
|
||||
func OpenFile(fpath string) (err error) {
|
||||
switch PLATFORM {
|
||||
case "linux":
|
||||
err = exec.Command("xdg-open", fpath).Start()
|
||||
case "windows":
|
||||
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", fpath).Start()
|
||||
case "darwin":
|
||||
err = exec.Command("open", fpath).Start()
|
||||
default:
|
||||
err = fmt.Errorf("unknown os")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Exit dumps the last 100 lines of output to a crash file in /tmp (or equivalent), and generates a prettier HTML file containing it that is opened in the browser if possible.
|
||||
func Exit(err interface{}) {
|
||||
tmpl, err2 := template.ParseFS(localFS, "html/crash.html", "html/header.html")
|
||||
@@ -63,12 +79,19 @@ func Exit(err interface{}) {
|
||||
if err != nil {
|
||||
data["Err"] = fmt.Sprintf("%s %v", identifyPanic(), err)
|
||||
}
|
||||
fpath := filepath.Join(temp, "jfa-go-crash-"+time.Now().Local().Format("2006-01-02T15:04:05"))
|
||||
// Use dashes for time rather than colons for Windows
|
||||
fpath := filepath.Join(temp, "jfa-go-crash-"+time.Now().Local().Format("2006-01-02T15-04-05"))
|
||||
err2 = os.WriteFile(fpath+".txt", []byte(logCache), 0666)
|
||||
if err2 != nil {
|
||||
log.Fatalf("Failed to write crash dump file: %v", err2)
|
||||
}
|
||||
log.Printf("\n------\nA crash report has been saved to \"%s\".\n------", fpath+".txt")
|
||||
|
||||
// Render ANSI colors to HTML
|
||||
data["Log"] = template.HTML(string(ansihtml.ConvertToHTML([]byte(data["Log"].(string)))))
|
||||
data["SanitizedLog"] = template.HTML(string(ansihtml.ConvertToHTML([]byte(data["SanitizedLog"].(string)))))
|
||||
data["Err"] = template.HTML(string(ansihtml.ConvertToHTML([]byte(data["Err"].(string)))))
|
||||
|
||||
f, err2 := os.OpenFile(fpath+".html", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
|
||||
if err2 != nil {
|
||||
log.Fatalf("Failed to open crash dump file: %v", err2)
|
||||
@@ -78,7 +101,10 @@ func Exit(err interface{}) {
|
||||
if err2 != nil {
|
||||
log.Fatalf("Failed to execute template: %v", err2)
|
||||
}
|
||||
browser.OpenFile(fpath + ".html")
|
||||
if err := OpenFile(fpath + ".html"); err != nil {
|
||||
log.Printf("Failed to open browser, trying text file...")
|
||||
OpenFile(fpath + ".txt")
|
||||
}
|
||||
if TRAY {
|
||||
QuitTray()
|
||||
} else {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//go:build external
|
||||
// +build external
|
||||
|
||||
package main
|
||||
@@ -12,6 +13,8 @@ import (
|
||||
|
||||
const binaryType = "external"
|
||||
|
||||
func BuildTagsExternal() { buildTags = append(buildTags, "external") }
|
||||
|
||||
var localFS dirFS
|
||||
var langFS dirFS
|
||||
|
||||
|
||||
69
generic-d.go
Normal file
@@ -0,0 +1,69 @@
|
||||
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
|
||||
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),
|
||||
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 <-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) Shutdown() {
|
||||
d.Stopped = true
|
||||
d.ShutdownChannel <- "Down"
|
||||
<-d.ShutdownChannel
|
||||
close(d.ShutdownChannel)
|
||||
}
|
||||
169
go.mod
@@ -1,6 +1,8 @@
|
||||
module github.com/hrfee/jfa-go
|
||||
|
||||
go 1.16
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.0
|
||||
|
||||
replace github.com/hrfee/jfa-go/docs => ./docs
|
||||
|
||||
@@ -10,56 +12,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
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/discordgo v0.23.2
|
||||
github.com/bwmarrin/discordgo v0.28.1
|
||||
github.com/dgraph-io/badger/v4 v4.3.1
|
||||
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a
|
||||
github.com/fatih/color v1.13.0
|
||||
github.com/fsnotify/fsnotify v1.5.1
|
||||
github.com/getlantern/golog v0.0.0-20210606115803-bce9f9fe5a5f // indirect
|
||||
github.com/getlantern/hidden v0.0.0-20201229170000-e66e7f878730 // indirect
|
||||
github.com/getlantern/ops v0.0.0-20200403153110-8476b16edcd6 // indirect
|
||||
github.com/getlantern/systray v1.1.0
|
||||
github.com/gin-contrib/pprof v1.3.0
|
||||
github.com/gin-contrib/static v0.0.1
|
||||
github.com/gin-gonic/gin v1.7.7
|
||||
github.com/go-openapi/spec v0.20.4 // indirect
|
||||
github.com/go-playground/validator/v10 v10.9.0 // indirect
|
||||
github.com/go-stack/stack v1.8.1 // indirect
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/fsnotify/fsnotify v1.8.0
|
||||
github.com/getlantern/systray v1.2.2
|
||||
github.com/gin-contrib/pprof v1.5.0
|
||||
github.com/gin-contrib/static v1.1.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/gomarkdown/markdown v0.0.0-20211212230626-5af6ad2f47df
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/hrfee/jfa-go/common v0.0.0-20211222231100-d47afe05f49c
|
||||
github.com/hrfee/jfa-go/docs v0.0.0-20211222231100-d47afe05f49c
|
||||
github.com/hrfee/jfa-go/linecache v0.0.0-20211222231100-d47afe05f49c
|
||||
github.com/hrfee/jfa-go/logger v0.0.0-20211222231100-d47afe05f49c
|
||||
github.com/hrfee/jfa-go/ombi v0.0.0-20211222231100-d47afe05f49c
|
||||
github.com/hrfee/mediabrowser v0.3.8
|
||||
github.com/itchyny/timefmt-go v0.1.3
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81
|
||||
github.com/hrfee/jfa-go/common v0.0.0-20241105225412-da4470bc4fbc
|
||||
github.com/hrfee/jfa-go/docs v0.0.0-20241105225412-da4470bc4fbc
|
||||
github.com/hrfee/jfa-go/easyproxy v0.0.0-20241105225412-da4470bc4fbc
|
||||
github.com/hrfee/jfa-go/jellyseerr v0.0.0-20241105225412-da4470bc4fbc
|
||||
github.com/hrfee/jfa-go/linecache v0.0.0-20241105225412-da4470bc4fbc
|
||||
github.com/hrfee/jfa-go/logger v0.0.0-20241105225412-da4470bc4fbc
|
||||
github.com/hrfee/jfa-go/logmessages v0.0.0-20241105225412-da4470bc4fbc
|
||||
github.com/hrfee/jfa-go/ombi v0.0.0-20241105225412-da4470bc4fbc
|
||||
github.com/itchyny/timefmt-go v0.1.6
|
||||
github.com/lithammer/shortuuid/v3 v3.0.7
|
||||
github.com/mailgun/mailgun-go/v4 v4.6.0
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
||||
github.com/swaggo/files v0.0.0-20210815190702-a29dd2bc99b2
|
||||
github.com/swaggo/gin-swagger v1.3.3
|
||||
github.com/swaggo/swag v1.7.6 // indirect
|
||||
github.com/technoweenie/multipartstreamer v1.0.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.4 // indirect
|
||||
github.com/ugorji/go v1.2.6 // indirect
|
||||
github.com/mailgun/mailgun-go/v4 v4.18.1
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
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/timshannon/badgerhold/v4 v4.0.3
|
||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible
|
||||
github.com/xhit/go-simple-mail/v2 v2.10.0
|
||||
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect
|
||||
golang.org/x/net v0.0.0-20211216030914-fe4d6282115f // indirect
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
|
||||
golang.org/x/tools v0.1.8 // indirect
|
||||
google.golang.org/protobuf v1.27.1 // indirect
|
||||
gopkg.in/ini.v1 v1.66.2
|
||||
maunium.net/go/mautrix v0.10.7
|
||||
github.com/xhit/go-simple-mail/v2 v2.16.0
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
maunium.net/go/mautrix v0.21.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/bytedance/sonic v1.12.4 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dgraph-io/ristretto v1.0.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.6 // indirect
|
||||
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect
|
||||
github.com/getlantern/errors v1.0.4 // 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-20231025133620-f368ab734534 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.1.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
github.com/go-openapi/spec v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // 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.22.1 // indirect
|
||||
github.com/go-stack/stack v1.8.1 // indirect
|
||||
github.com/go-test/deep v1.1.0 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hrfee/mediabrowser v0.3.27 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 // 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/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // 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.2.3 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rs/zerolog v1.33.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/swaggo/swag v1.16.4 // indirect
|
||||
github.com/technoweenie/multipartstreamer v1.0.1 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // 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-20240103092955-90b7d1423f92 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.mau.fi/util v0.8.1 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.opentelemetry.io/otel v1.31.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.31.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.31.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
golang.org/x/arch v0.11.0 // indirect
|
||||
golang.org/x/crypto v0.35.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
|
||||
golang.org/x/image v0.21.0 // indirect
|
||||
golang.org/x/net v0.36.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
golang.org/x/tools v0.26.0 // indirect
|
||||
google.golang.org/protobuf v1.35.1 // indirect
|
||||
)
|
||||
|
||||
607
go.sum
@@ -1,311 +1,430 @@
|
||||
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.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 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
||||
github.com/agiledragon/gomonkey/v2 v2.3.1 h1:k+UnUY0EMNYUFUAQVETGY9uUTxjMdnUkP0ARyJS1zzs=
|
||||
github.com/agiledragon/gomonkey/v2 v2.3.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
||||
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
|
||||
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
|
||||
github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts=
|
||||
github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts=
|
||||
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
|
||||
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
|
||||
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
||||
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
||||
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
||||
github.com/bwmarrin/discordgo v0.23.2 h1:BzrtTktixGHIu9Tt7dEE6diysEF9HWnXeHuoJEt2fH4=
|
||||
github.com/bwmarrin/discordgo v0.23.2/go.mod h1:c1WtWUGN6nREDmzIpyTp/iD3VYt4Fpx+bVyfBG7JE+M=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4=
|
||||
github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||
github.com/bytedance/sonic v1.12.4 h1:9Csb3c9ZJhfUWeMtpCDCq6BUoH5ogfDFLUgQ/jG+R0k=
|
||||
github.com/bytedance/sonic v1.12.4/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=
|
||||
github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
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.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
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 v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/dgraph-io/badger/v4 v4.1.0/go.mod h1:P50u28d39ibBRmIJuQC/NSdBOg46HnHw7al2SW5QRHg=
|
||||
github.com/dgraph-io/badger/v4 v4.3.1 h1:7r5wKqmoRpGgSxqa0S/nGdpOpvvzuREGPLSua73C8tw=
|
||||
github.com/dgraph-io/badger/v4 v4.3.1/go.mod h1:oObz97DImXpd6O/Dt8BqdKLLTDmEmarAimo72VV5whQ=
|
||||
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
|
||||
github.com/dgraph-io/ristretto v1.0.0 h1:SYG07bONKMlFDUYu5pEu3DGAh8c2OFNzKm6G9J4Si84=
|
||||
github.com/dgraph-io/ristretto v1.0.0/go.mod h1:jTi2FiYEhQ1NsMmA7DeBykizjOuY88NhKBkepyu1jPc=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
|
||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/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-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/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.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
|
||||
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||
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.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI=
|
||||
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
|
||||
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
|
||||
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 h1:XukU2whlh7OdpxnkXhNH9VTLVz0EVPGKDV5K0oWhvzw=
|
||||
github.com/getlantern/errors v1.0.1/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
|
||||
github.com/getlantern/errors v1.0.4 h1:i2iR1M9GKj4WuingpNqJ+XQEw6i6dnAgKAmLj6ZB3X0=
|
||||
github.com/getlantern/errors v1.0.4/go.mod h1:/Foq8jtSDGP8GOXzAjeslsC4Ar/3kB+UiQH+WyV4pzY=
|
||||
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
|
||||
github.com/getlantern/golog v0.0.0-20210606115803-bce9f9fe5a5f h1:wsVt3P/boVKkPFEZkWxgNgRq/+mD7sWHc17+Vw2PgH8=
|
||||
github.com/getlantern/golog v0.0.0-20210606115803-bce9f9fe5a5f/go.mod h1:ZyIjgH/1wTCl+B+7yH1DqrWp6MPJqESmwmEQ89ZfhvA=
|
||||
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
|
||||
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-20201229170000-e66e7f878730 h1:oKJVQbWZ2CAJ71jYnm6A3+e6h5bkPJ0okIMwkaYB5HI=
|
||||
github.com/getlantern/hidden v0.0.0-20201229170000-e66e7f878730/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-20200403153110-8476b16edcd6 h1:QthAQCekS1YOeYWSvoHI6ZatlG4B+GBDLxV/2ZkBsTA=
|
||||
github.com/getlantern/ops v0.0.0-20200403153110-8476b16edcd6/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
|
||||
github.com/getlantern/systray v1.1.0 h1:U0wCEqseLi2ok1fE6b88gJklzriavPJixZysZPkZd/Y=
|
||||
github.com/getlantern/systray v1.1.0/go.mod h1:AecygODWIsBquJCJFop8MEQcJbWFfw/1yWbVabNgpCM=
|
||||
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||
github.com/getlantern/ops v0.0.0-20220713155959-1315d978fff7/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
|
||||
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 h1:3BwvWj0JZzFEvNNiMhCu4bf60nqcIuQpTYb00Ezm1ag=
|
||||
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534/go.mod h1:ZsLfOY6gKQOTyEcPYNA9ws5/XHZQFroxqCOhHjGcs9Y=
|
||||
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
|
||||
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
|
||||
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.3 h1:etUaeesHhEORpZMp18zoOhepboiWnFtXrBZxszWUn4k=
|
||||
github.com/gin-contrib/gzip v0.0.3/go.mod h1:YxxswVZIqOvcHEQpsSn+QF5guQtO1dCfy0shBPy4jFc=
|
||||
github.com/gin-contrib/pprof v1.3.0 h1:G9eK6HnbkSqDZBYbzG4wrjCsA4e+cvYAHUZw6W+W9K0=
|
||||
github.com/gin-contrib/pprof v1.3.0/go.mod h1:waMjT1H9b179t3CxuG1cV3DHpga6ybizwfBaM5OXaB0=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/pprof v1.5.0 h1:E/Oy7g+kNw94KfdCy3bZxQFtyDnAX2V7axRS7sNYVrU=
|
||||
github.com/gin-contrib/pprof v1.5.0/go.mod h1:GqFL6LerKoCQ/RSWnkYczkTJ+tOAUVN/8sbnEtaqOKs=
|
||||
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/static v1.1.2 h1:c3kT4bFkUJn2aoRU3s6XnMjJT8J6nNWJkR0NglqmlZ4=
|
||||
github.com/gin-contrib/static v1.1.2/go.mod h1:Fw90ozjHCmZBWbgrsqrDvO28YbhKEKzKp8GixhR4yLw=
|
||||
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.2/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
|
||||
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
|
||||
github.com/gin-gonic/gin v1.7.4/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY=
|
||||
github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs=
|
||||
github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
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.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/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 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
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.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
|
||||
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
|
||||
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
||||
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
|
||||
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.3/go.mod h1:gG4F8wdEDN+YPBMVnzE85Rbhf+Th2DTvA9nFPQ5AYEg=
|
||||
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
|
||||
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
||||
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
|
||||
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
|
||||
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.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
|
||||
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
|
||||
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
|
||||
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
|
||||
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
|
||||
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
|
||||
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
|
||||
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
|
||||
github.com/go-playground/validator/v10 v10.9.0 h1:NgTtmN58D0m8+UuxtYmGztBJB7VnPgjj221I1QHci2A=
|
||||
github.com/go-playground/validator/v10 v10.9.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
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.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.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
|
||||
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
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-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/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
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.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
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 h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
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/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/gomarkdown/markdown v0.0.0-20211212230626-5af6ad2f47df h1:M7mdNDTRraBcrHZg2aOYiFP9yTDajb6fquRZRpXnbVA=
|
||||
github.com/gomarkdown/markdown v0.0.0-20211212230626-5af6ad2f47df/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
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-20241105142532-d03b89096d81 h1:5lyLWsV+qCkoYqsKUDuycESh9DEIPVKN6iCFeL7ag50=
|
||||
github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81/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 v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
|
||||
github.com/google/flatbuffers v24.3.25+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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
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/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/hrfee/mediabrowser v0.3.7 h1:F57Cmwst4fOfhPuOlanKiOuek9zCVcXm78/zP/1WB2s=
|
||||
github.com/hrfee/mediabrowser v0.3.7/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
|
||||
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.3 h1:7M3LGVDsqcd0VZH2U+x393obrzZisp7C0uEe921iRkU=
|
||||
github.com/itchyny/timefmt-go v0.1.3/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A=
|
||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
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.27 h1:8bxPamBFLD1Xqy6pf6M3Oc5GUQ0iU/flO0S64G1AsIM=
|
||||
github.com/hrfee/mediabrowser v0.3.27/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
|
||||
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
|
||||
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/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
||||
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/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||
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.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
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 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
|
||||
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||
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/mailgun/mailgun-go/v4 v4.6.0 h1:qSrgT3wP5fU7wF/tNUp4xeYe8wSUy+8V5NJPYnB6Hxo=
|
||||
github.com/mailgun/mailgun-go/v4 v4.6.0/go.mod h1:FJlF9rI5cQT+mrwujtJjPMbIVy3Ebor9bKTVsJ0QU40=
|
||||
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/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.18.1 h1:ShNH/wzj7albTF/6le011FF+DGMd3azcSKL4iO9AgeI=
|
||||
github.com/mailgun/mailgun-go/v4 v4.18.1/go.mod h1:+d4FCswFAukgYc1XtKK2IxOYaVxjVm8AN2z/5TBiT8M=
|
||||
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.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
|
||||
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-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 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA=
|
||||
github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
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.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/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/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/otiai10/copy v1.7.0 h1:hVoPiN+t+7d2nzzwMiDHPSOogsWAStewq3TwU05+clE=
|
||||
github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U=
|
||||
github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
|
||||
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
|
||||
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
|
||||
github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
|
||||
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/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||
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.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274 h1:qli3BGQK0tYDkSEvZ/FzZTi9ZrOX86Q6CIhKLGc489A=
|
||||
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274/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/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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
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.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
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/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
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 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
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/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=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E=
|
||||
github.com/swaggo/files v0.0.0-20210815190702-a29dd2bc99b2 h1:+iNTcqQJy0OZ5jk6a5NLib47eqXK8uYcPX+O4+cBpEM=
|
||||
github.com/swaggo/files v0.0.0-20210815190702-a29dd2bc99b2/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
|
||||
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.3.3 h1:XHyYmeNVFG5PbyWHG4jXtxOm2P4kiZapDCWsyDDiQ/I=
|
||||
github.com/swaggo/gin-swagger v1.3.3/go.mod h1:ymsZuGpbbu+S7ZoQ49QPpZoDBj6uqhb8WizgQPVgWl0=
|
||||
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/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.7.4/go.mod h1:zD8h6h4SPv7t3l+4BKdRquqW1ASWjKZgT6Qv9z3kNqI=
|
||||
github.com/swaggo/swag v1.7.6 h1:UbAqHyXkW2J+cDjs5S43MkuYR7a6stB7Am7SK8NBmRg=
|
||||
github.com/swaggo/swag v1.7.6/go.mod h1:7vLqNYEtYoIsD14wXgy9oDS65MNiDANrPtbk9rnLuj0=
|
||||
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
|
||||
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
|
||||
github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
|
||||
github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
|
||||
github.com/tidwall/gjson v1.10.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.12.1 h1:ikuZsLdhr8Ws0IdROXUS1Gi4v9Z4pGqpX/CvJkxvfpo=
|
||||
github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
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 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.3/go.mod h1:5WdjKx3AQMvCJ4RG6/2UYT7dLrGvJUV1x4jdTAyGvZs=
|
||||
github.com/tidwall/sjson v1.2.4 h1:cuiLzLnaMeBhRmEv00Lpk3tkYrcxpmbU81tAY4Dw0tc=
|
||||
github.com/tidwall/sjson v1.2.4/go.mod h1:098SZ494YoMWPmMO6ct4dcFnqxwj9r/gF0Etp19pSNM=
|
||||
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/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-20240103092955-90b7d1423f92 h1:flbMkdl6HxQkLs6DDhH1UkcnFpNBOu70391STjMS0O4=
|
||||
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92/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.6 h1:tGiWC9HENWE2tqYycIqFTNorMmFRVhNwCpDOpWqnk8E=
|
||||
github.com/ugorji/go v1.2.6/go.mod h1:anCg0y61KIhDlPZmnH+so+RQbysYVyDko0IMgJv0Nn0=
|
||||
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.6 h1:7kbGefxLoDBuYXOms4yD7223OpNMMPNPZxXk5TvFcyQ=
|
||||
github.com/ugorji/go/codec v1.2.6/go.mod h1:V6TCNZ4PHqoHGFZuSG1W8nrCzzdgA2DozYxWFFpvxTw=
|
||||
github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
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/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
|
||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||
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.10.0 h1:nib6RaJ4qVh5HD9UE9QJqnUZyWp3upv+Z6CFxaMj0V8=
|
||||
github.com/xhit/go-simple-mail/v2 v2.10.0/go.mod h1:kA1XbQfCI4JxQ9ccSN6VFyIEkkugOm7YiPkA5hKiQn4=
|
||||
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.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.mau.fi/util v0.8.1 h1:Ga43cz6esQBYqcjZ/onRoVnYWoUwjWbsxVeJg2jOTSo=
|
||||
go.mau.fi/util v0.8.1/go.mod h1:T1u/rD2rzidVrBLyaUdPpZiJdP/rsyi+aTzn0D+Q6wc=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/otel v1.9.0/go.mod h1:np4EoPGzoPs3O67xUVNoPPcmSvsfOxNlNA4F4AC+0Eo=
|
||||
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
|
||||
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
|
||||
go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
|
||||
go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
|
||||
go.opentelemetry.io/otel/trace v1.9.0/go.mod h1:2737Q0MuG8q1uILYm2YYVkAyLtOofiTNGg6VODnOiPo=
|
||||
go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
|
||||
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
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.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
|
||||
golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
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-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M=
|
||||
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
|
||||
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
|
||||
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
|
||||
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
|
||||
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/lint v0.0.0-20190930215403-16217165b5de/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.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38=
|
||||
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/mod v0.4.2/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.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
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=
|
||||
@@ -313,93 +432,137 @@ 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-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
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-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211020060615-d418f374d309/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211216030914-fe4d6282115f h1:hEYJvxw1lSnWIl8X9ofsYMklzaDs90JI2az5YMd4fPM=
|
||||
golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
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/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
|
||||
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
|
||||
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-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-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
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.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
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-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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-20210616045830-e2b7044e8c71/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-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/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-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
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/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
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.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||
golang.org/x/tools v0.1.8 h1:P1HhGGuLW4aAclzjtmJdf0mJOjVUZUzOTqkAkWL+l6w=
|
||||
golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
|
||||
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.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
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.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
||||
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
||||
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 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
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.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
||||
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||
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/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
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.66.2 h1:XfR1dOYubytKy4Shzc2LHrrGhU0lDCfDGG1yLPmpgsI=
|
||||
gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
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.3/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 h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maunium.net/go/maulogger/v2 v2.3.1 h1:fwBYJne0pHvJrrIPHK+TAPfyxxbBEz46oVGez2x0ODE=
|
||||
maunium.net/go/maulogger/v2 v2.3.1/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
|
||||
maunium.net/go/mautrix v0.10.7 h1:QV5vbCY4g50N7r1ihdG6zEPfaPn/EVYjM5H+qfLy4RM=
|
||||
maunium.net/go/mautrix v0.10.7/go.mod h1:k4Ng5oci83MEbqPL6KOjPdbU7f8v01KlMjR/zTQ+7mA=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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.21.1 h1:Z+e448jtlY977iC1kokNJTH5kg2WmDpcQCqn+v9oZOA=
|
||||
maunium.net/go/mautrix v0.21.1/go.mod h1:7F/S6XAdyc/6DW+Q7xyFXRSPb6IjfqMb1OMepQ8C8OE=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
|
||||
163
housekeeping-d.go
Normal file
@@ -0,0 +1,163 @@
|
||||
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()
|
||||
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 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.jf.CacheExpiry = time.Now() })
|
||||
}
|
||||
|
||||
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 }}">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="css/bundle.css">
|
||||
{{ template "header.html" . }}
|
||||
<title>404 - jfa-go</title>
|
||||
{{ template "header.html" . }}
|
||||
</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">
|
||||
|
||||
52
html/account-linking.html
Normal file
@@ -0,0 +1,52 @@
|
||||
{{ 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 }}
|
||||
822
html/admin.html
@@ -1,12 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link inline rel="stylesheet" type="text/css" href="bundle.css">
|
||||
<!--- This CSS is inlined so we should keep this here! -->
|
||||
<link inline rel="stylesheet" type="text/css" href="web/css/v3bundle.css">
|
||||
{{ template "header.html" . }}
|
||||
<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">
|
||||
<span class="heading">Crash report for jfa-go</span>
|
||||
@@ -18,7 +19,7 @@
|
||||
<a class="button ~critical mb-4" 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">
|
||||
<div class="flex flex-row justify-between">
|
||||
<span class="subheading">Full Log</span>
|
||||
<span class="button ~urge ml-4" id="copy-log">Copy</span>
|
||||
</div>
|
||||
@@ -40,6 +41,6 @@
|
||||
</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 }}">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="css/bundle.css">
|
||||
{{ template "header.html" . }}
|
||||
<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,22 +13,44 @@
|
||||
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 }};
|
||||
window.reCAPTCHA = {{ .reCAPTCHA }};
|
||||
window.reCAPTCHASiteKey = "{{ .reCAPTCHASiteKey }}";
|
||||
window.userPageEnabled = {{ .userPageEnabled }};
|
||||
window.userPageAddress = "{{ .userPageAddress }}";
|
||||
{{ 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 = () => {
|
||||
const el = document.getElementsByClassName("g-recaptcha")[0];
|
||||
grecaptcha.render(el, {
|
||||
"sitekey": window.reCAPTCHASiteKey,
|
||||
"theme": document.documentElement.classList.contains("dark") ? "dark" : "light"
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script src="https://www.google.com/recaptcha/api.js?onload=reCAPTCHACallback&render=explicit" async defer></script>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
159
html/form.html
@@ -1,110 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="{{ .cssClass }}">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="css/bundle.css">
|
||||
{{ template "header.html" . }}
|
||||
<title>
|
||||
{{ if .passwordReset }}
|
||||
{{ .strings.passwordReset }}
|
||||
{{ else }}
|
||||
{{ .strings.pageTitle }}
|
||||
{{ end }}
|
||||
</title>
|
||||
{{ if .passwordReset }}
|
||||
<title>{{ .strings.passwordReset }}</title>
|
||||
{{ else }}
|
||||
<title>{{ .strings.pageTitle }}</title>
|
||||
{{ end }}
|
||||
<script>
|
||||
window.redirectToJellyfin = {{ .redirectToJellyfin }};
|
||||
</script>
|
||||
</head>
|
||||
<body class="max-w-full overflow-x-hidden section">
|
||||
<div id="modal-success" class="modal">
|
||||
<div class="modal-content card">
|
||||
<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>
|
||||
<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="modal-content card">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
||||
<span class="heading mb-4">{{ .strings.confirmationRequired }}</span>
|
||||
<p class="content mb-4">{{ .strings.confirmationRequiredMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ if .telegramEnabled }}
|
||||
<div id="modal-telegram" class="modal">
|
||||
<div class="modal-content card">
|
||||
<span class="heading mb-4">{{ .strings.linkTelegram }}</span>
|
||||
<p class="content mb-4">{{ .strings.sendPIN }}</p>
|
||||
<p class="text-center text-2xl mb-2">{{ .telegramPIN }}</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 .discordEnabled }}
|
||||
<div id="modal-discord" class="modal">
|
||||
<div class="modal-content card">
|
||||
<span class="heading mb-4">{{ .strings.linkDiscord }}</span>
|
||||
<p class="content mb-4"> {{ .discordSendPINMessage }}</p>
|
||||
<h1 class="text-center text-2xl mb-2">{{ .discordPIN }}</h1>
|
||||
<a id="discord-invite"></a>
|
||||
<span class="button ~info @low full-width center mt-4" id="discord-waiting">{{ .strings.success }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .matrixEnabled }}
|
||||
<div id="modal-matrix" class="modal">
|
||||
<div class="modal-content card">
|
||||
<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 }}
|
||||
<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>
|
||||
{{ template "account-linking.html" . }}
|
||||
<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="row baseline">
|
||||
<span class="col heading">
|
||||
<div class="flex flex-col md:flex-row gap-3 items-baseline mb-2">
|
||||
<span class="heading mr-5">
|
||||
{{ if .passwordReset }}
|
||||
{{ .strings.passwordReset }}
|
||||
{{ else }}
|
||||
{{ .strings.createAccountHeader }}
|
||||
{{ end }}
|
||||
</span>
|
||||
<span class="col subheading">
|
||||
<span class="subheading">
|
||||
{{ if .passwordReset }}
|
||||
{{ .strings.enterYourPassword }}
|
||||
{{ .strings.enterYourPassword }}
|
||||
{{ else }}
|
||||
{{ .helpMessage }}
|
||||
{{ .helpMessage }}
|
||||
{{ end }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="flex flex-col md:flex-row gap-3">
|
||||
<div class="flex-1">
|
||||
{{ if .userExpiry }}
|
||||
<aside class="col aside sm ~warning" id="user-expiry-message"></aside>
|
||||
{{ end }}
|
||||
@@ -114,36 +67,36 @@
|
||||
{{ .strings.username }}
|
||||
<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 }}">
|
||||
{{ if .telegramEnabled }}
|
||||
<span class="button ~info @low full-width center mb-4" id="link-telegram">{{ .strings.linkTelegram }}</span>
|
||||
<span class="button ~info @low full-width center mb-4" id="link-telegram">{{ .strings.linkTelegram }} {{ if .telegramRequired }}({{ .strings.required }}){{ end }}</span>
|
||||
{{ end }}
|
||||
{{ if .discordEnabled }}
|
||||
<span class="button ~info @low full-width center mb-4" id="link-discord">{{ .strings.linkDiscord }}</span>
|
||||
<span class="button ~info @low full-width center mb-4" id="link-discord">{{ .strings.linkDiscord }} {{ if .discordRequired }}({{ .strings.required }}){{ end }}</span>
|
||||
{{ end }}
|
||||
{{ if .matrixEnabled }}
|
||||
<span class="button ~info @low full-width center mb-4" id="link-matrix">{{ .strings.linkMatrix }}</span>
|
||||
<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">
|
||||
<input type="radio" name="contact-via" value="email"><span>Contact through Email</span>
|
||||
<label class="row switch pb-4 unfocused">
|
||||
<input type="checkbox" name="contact-via" value="email" id="contact-via-email" class="mr-2"><span>Contact through Email</span>
|
||||
</label>
|
||||
{{ if .telegramEnabled }}
|
||||
<label class="row switch pb-4">
|
||||
<input type="radio" name="contact-via" value="telegram" id="contact-via-telegram"><span>Contact through Telegram</span>
|
||||
<label class="row switch pb-4 unfocused">
|
||||
<input type="checkbox" name="contact-via" value="telegram" id="contact-via-telegram" class="mr-2"><span>Contact through Telegram</span>
|
||||
</label>
|
||||
{{ end }}
|
||||
{{ if .discordEnabled }}
|
||||
<label class="row switch pb-4">
|
||||
<input type="radio" name="contact-via" value="discord" id="contact-via-discord"><span>Contact through Discord</span>
|
||||
<label class="row switch pb-4 unfocused">
|
||||
<input type="checkbox" name="contact-via" value="discord" id="contact-via-discord" class="mr-2"><span>Contact through Discord</span>
|
||||
</label>
|
||||
{{ end }}
|
||||
{{ if .matrixEnabled }}
|
||||
<label class="row switch pb-4">
|
||||
<input type="radio" name="contact-via" value="matrix" id="contact-via-matrix"><span>Contact through Matrix</span>
|
||||
<label class="row switch pb-4 unfocused">
|
||||
<input type="checkbox" name="contact-via" value="matrix" id="contact-via-matrix" class="mr-2"><span>Contact through Matrix</span>
|
||||
</label>
|
||||
{{ end }}
|
||||
</div>
|
||||
@@ -151,7 +104,7 @@
|
||||
{{ end }}
|
||||
<label class="label supra" for="create-password">{{ .strings.password }}</label>
|
||||
<input type="password" class="input ~neutral @high mt-2 mb-4" placeholder="{{ .strings.password }}" id="create-password" aria-label="{{ .strings.password }}">
|
||||
|
||||
|
||||
<label class="label supra" for="create-reenter-password">{{ .strings.reEnterPassword }}</label>
|
||||
<input type="password" class="input ~neutral @high mt-2 mb-4" placeholder="{{ .strings.password }}" id="create-reenter-password" aria-label="{{ .strings.reEnterPassword }}">
|
||||
<label>
|
||||
@@ -166,9 +119,12 @@
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col">
|
||||
<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 }}
|
||||
<div class="card ~neutral @low mb-4">
|
||||
<span class="label supra" for="inv-uses">{{ .strings.passwordRequirementsHeader }}</span>
|
||||
<span class="label supra">{{ .strings.passwordRequirementsHeader }}</span>
|
||||
<ul>
|
||||
{{ range $key, $value := .requirements }}
|
||||
<li class="" id="requirement-{{ $key }}" min="{{ $value }}">
|
||||
@@ -177,8 +133,17 @@
|
||||
{{ end }}
|
||||
</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>
|
||||
{{ if not .reCAPTCHA }}
|
||||
<input class="field ~neutral @low" id="captcha-input" class="mt-2" 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 +1,32 @@
|
||||
<meta charset="utf-8">
|
||||
<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="robots" content="noindex">
|
||||
|
||||
<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">
|
||||
<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">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<script>
|
||||
window.pages = {
|
||||
"Base": "{{ .pages.Base }}",
|
||||
"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 }}">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="css/bundle.css">
|
||||
{{ template "header.html" . }}
|
||||
<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">
|
||||
<i class="ri-global-line"></i>
|
||||
<span class="ml-2 chev"></span>
|
||||
</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"></div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
37
html/login-modal.html
Normal file
@@ -0,0 +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-11/12 sm:w-4/5 lg:w-1/2">
|
||||
{{ $hasTwoCards := 0 }}
|
||||
{{ if index . "LoginMessageEnabled" }}
|
||||
{{ if .LoginMessageEnabled }}
|
||||
{{ $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 }}
|
||||
{{ 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" href="{{ .pages.Base }}{{ .pages.MyAccount }}"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<form class="card mx-2 form-login w-full {{ 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">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,7 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="{{ .cssClass }}">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="css/bundle.css">
|
||||
{{ template "header.html" . }}
|
||||
<title>{{ .strings.passwordReset }} - jfa-go</title>
|
||||
</head>
|
||||
@@ -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>
|
||||
|
||||
864
html/setup.html
@@ -1,266 +1,340 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="light">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="css/bundle.css">
|
||||
{{ template "header.html" . }}
|
||||
<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 items-center">
|
||||
<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 }}</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"><span>{{ .lang.Login.adminOnly }}</span>
|
||||
</label>
|
||||
<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"><input type="radio" class="mr-2" name="email-24h" value="true" checked><span>{{ .lang.Strings.time24h }}</span></div>
|
||||
</label>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<div class="switch"><input type="radio" class="mr-2" 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>
|
||||
@@ -268,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>
|
||||
@@ -495,4 +574,3 @@
|
||||
<script src="js/setup.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
152
html/user.html
Normal file
@@ -0,0 +1,152 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="light">
|
||||
<head>
|
||||
<script>
|
||||
window.langFile = JSON.parse({{ .language }});
|
||||
window.linkResetEnabled = {{ .linkResetEnabled }};
|
||||
window.language = "{{ .langName }}";
|
||||
window.telegramRequired = {{ .telegramRequired }};
|
||||
window.telegramUsername = {{ .telegramUsername }};
|
||||
window.telegramURL = {{ .telegramURL }};
|
||||
window.emailRequired = {{ .emailRequired }};
|
||||
window.discordRequired = {{ .discordRequired }};
|
||||
window.discordServerName = "{{ .discordServerName }}";
|
||||
window.discordInviteLink = {{ .discordInviteLink }};
|
||||
window.discordSendPINMessage = "{{ .discordSendPINMessage }}";
|
||||
window.matrixRequired = {{ .matrixRequired }};
|
||||
window.matrixUserID = "{{ .matrixUser }}";
|
||||
window.validationStrings = JSON.parse({{ .validationStrings }});
|
||||
</script>
|
||||
{{ template "header.html" . }}
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{{ if .pwrEnabled }}
|
||||
<div id="modal-pwr" class="modal">
|
||||
<div class="card content relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
|
||||
<span class="heading">{{ .strings.resetPassword }}</span>
|
||||
<p class="content my-2">
|
||||
{{ if .linkResetEnabled }}
|
||||
{{ .strings.resetPasswordThroughLinkStart }}
|
||||
<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>
|
||||
{{ .strings.resetPasswordThroughLinkEnd }}
|
||||
{{ 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>
|
||||
{{ if .linkResetEnabled }}
|
||||
<span class="button ~info @low full-width center mt-4" id="pwr-submit">
|
||||
{{ .strings.submit }}
|
||||
</span>
|
||||
{{ else }}
|
||||
<a class="button ~info @low full-width center mt-4" href="{{ .jfLink }}" target="_blank">{{ .strings.continue }}</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ template "login-modal.html" . }}
|
||||
{{ template "account-linking.html" . }}
|
||||
<div id="notification-box"></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 mb-4 unfocused" id="logout-button">{{ .strings.logout }}</span>
|
||||
</div>
|
||||
<a class="button ~info unfocused h-min" href="/" id="admin-back-button"><i class="ri-arrow-left-fill mr-2"></i>{{ .strings.admin }}</a>
|
||||
</div>
|
||||
<div class="card @low dark:~d_neutral mb-4" id="card-user">
|
||||
<span class="heading mb-2"></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 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="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">
|
||||
<span class="label supra row">{{ .strings.passwordRequirementsHeader }}</span>
|
||||
<ul>
|
||||
{{ range $key, $value := .requirements }}
|
||||
<li class="" id="requirement-{{ $key }}" min="{{ $value }}">
|
||||
<span class="badge lg ~positive requirement-valid"></span> <span class="content requirement-content"></span>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="my-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 }}">
|
||||
<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 }}">
|
||||
|
||||
<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">
|
||||
{{ .strings.changePassword }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</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="user-expiry-countdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{ if .referralsEnabled }}
|
||||
<div>
|
||||
<div class="card @low dark:~d_neutral unfocused" id="card-referrals">
|
||||
<span class="heading mb-2">{{ .strings.referrals }}</span>
|
||||
<aside class="aside ~neutral my-4 col user-referrals-description"></aside>
|
||||
<div class="flex flex-row justify-between gap-2">
|
||||
<div class="user-referrals-info"></div>
|
||||
<div class="grid my-2">
|
||||
<button type="button" class="user-referrals-button button ~info dark:~d_info @low" title="Copy">{{ .strings.copyReferral }}<i class="ri-file-copy-line ml-2"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<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: 56 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: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 38 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 |
|
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 |
337
images/src/jfa-go-social-quicksand.svg
Normal file
|
After Width: | Height: | Size: 59 KiB |
@@ -1,3 +1,4 @@
|
||||
//go:build !external
|
||||
// +build !external
|
||||
|
||||
package main
|
||||
@@ -10,6 +11,8 @@ import (
|
||||
|
||||
const binaryType = "internal"
|
||||
|
||||
func BuildTagsExternal() {}
|
||||
|
||||
//go:embed data data/html data/web data/web/css data/web/js
|
||||
var loFS embed.FS
|
||||
|
||||
|
||||
50
invdaemon.go
@@ -1,50 +0,0 @@
|
||||
package main
|
||||
|
||||
import "time"
|
||||
|
||||
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
|
||||
|
||||
type inviteDaemon struct {
|
||||
Stopped bool
|
||||
ShutdownChannel chan string
|
||||
Interval time.Duration
|
||||
period time.Duration
|
||||
app *appContext
|
||||
}
|
||||
|
||||
func newInviteDaemon(interval time.Duration, app *appContext) *inviteDaemon {
|
||||
return &inviteDaemon{
|
||||
Stopped: false,
|
||||
ShutdownChannel: make(chan string),
|
||||
Interval: interval,
|
||||
period: interval,
|
||||
app: app,
|
||||
}
|
||||
}
|
||||
|
||||
func (rt *inviteDaemon) 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()
|
||||
rt.app.debug.Println("Daemon: Checking invites")
|
||||
rt.app.checkInvites()
|
||||
finished := time.Now()
|
||||
duration := finished.Sub(started)
|
||||
rt.period = rt.Interval - duration
|
||||
}
|
||||
}
|
||||
|
||||
func (rt *inviteDaemon) Shutdown() {
|
||||
rt.Stopped = true
|
||||
rt.ShutdownChannel <- "Down"
|
||||
<-rt.ShutdownChannel
|
||||
close(rt.ShutdownChannel)
|
||||
}
|
||||
82
jellyseerr-d.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/hrfee/jfa-go/jellyseerr"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
)
|
||||
|
||||
func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
|
||||
user, imported, err := app.js.GetOrImportUser(jfID)
|
||||
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 {
|
||||
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] = u.ChatID
|
||||
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() {
|
||||
users, err := app.jf.GetUsers(false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
|
||||
return
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func newJellyseerrDaemon(interval time.Duration, app *appContext) *GenericDaemon {
|
||||
d := NewGenericDaemon(interval, app,
|
||||
func(app *appContext) {
|
||||
app.SynchronizeJellyseerrUsers()
|
||||
},
|
||||
)
|
||||
d.Name("Jellyseerr import")
|
||||
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=
|
||||
423
jellyseerr/jellyseerr.go
Normal file
@@ -0,0 +1,423 @@
|
||||
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
|
||||
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{},
|
||||
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 {
|
||||
responseText, err = js.decodeResp(resp)
|
||||
if err != 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
|
||||
}
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) getUsers() error {
|
||||
if js.cacheExpiry.After(time.Now()) {
|
||||
return nil
|
||||
}
|
||||
js.cacheExpiry = time.Now().Add(js.cacheLength)
|
||||
pageCount := 1
|
||||
pageIndex := 0
|
||||
for {
|
||||
res, err := js.getUserPage(pageIndex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, u := range res.Results {
|
||||
if u.JellyfinUserID == "" {
|
||||
continue
|
||||
}
|
||||
js.userCache[u.JellyfinUserID] = u
|
||||
}
|
||||
pageCount = res.Page.Pages
|
||||
pageIndex++
|
||||
if pageIndex >= pageCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
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)
|
||||
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) (u User, imported bool, err error) {
|
||||
imported = false
|
||||
u, err = js.GetExistingUser(jfID)
|
||||
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) (u User, err error) {
|
||||
js.getUsers()
|
||||
ok := false
|
||||
err = nil
|
||||
if u, ok = js.userCache[jfID]; ok {
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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) 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
|
||||
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
|
||||
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
|
||||
}
|
||||
// Lazily just invalidate the cache.
|
||||
js.cacheExpiry = time.Now()
|
||||
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
|
||||
}
|
||||
|
||||
_, _, err = js.post(fmt.Sprintf(js.server+"/user/%d/settings/main", u.ID), conf, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Lazily just invalidate the cache.
|
||||
js.cacheExpiry = time.Now()
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
136
jellyseerr/models.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package jellyseerr
|
||||
|
||||
import "time"
|
||||
|
||||
type UserField string
|
||||
|
||||
const (
|
||||
FieldDisplayName UserField = "displayName"
|
||||
FieldEmail UserField = "email"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
UserTemplate // Note: You can set this with User.UserTemplate = value.
|
||||
UserType int64 `json:"userType,omitempty"`
|
||||
Warnings []any `json:"warnings,omitempty"`
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
PlexUsername string `json:"plexUsername,omitempty"`
|
||||
JellyfinUsername string `json:"jellyfinUsername,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
RecoveryLinkExpirationDate any `json:"recoveryLinkExpirationDate,omitempty"`
|
||||
PlexID string `json:"plexId,omitempty"`
|
||||
JellyfinUserID string `json:"jellyfinUserId,omitempty"`
|
||||
JellyfinDeviceID string `json:"jellyfinDeviceId,omitempty"`
|
||||
JellyfinAuthToken string `json:"jellyfinAuthToken,omitempty"`
|
||||
PlexToken string `json:"plexToken,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt,omitempty"`
|
||||
RequestCount int64 `json:"requestCount,omitempty"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
}
|
||||
|
||||
func (u User) Name() string {
|
||||
var n string
|
||||
if u.Username != "" {
|
||||
n = u.Username
|
||||
} else if u.JellyfinUsername != "" {
|
||||
n = u.JellyfinUsername
|
||||
}
|
||||
if u.DisplayName != "" {
|
||||
n += " (" + u.DisplayName + ")"
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
type UserTemplate struct {
|
||||
Permissions Permissions `json:"permissions,omitempty"`
|
||||
MovieQuotaLimit any `json:"movieQuotaLimit,omitempty"`
|
||||
MovieQuotaDays any `json:"movieQuotaDays,omitempty"`
|
||||
TvQuotaLimit any `json:"tvQuotaLimit,omitempty"`
|
||||
TvQuotaDays any `json:"tvQuotaDays,omitempty"`
|
||||
}
|
||||
|
||||
type PageInfo struct {
|
||||
Pages int `json:"pages,omitempty"`
|
||||
PageSize int `json:"pageSize,omitempty"`
|
||||
Results int `json:"results,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
}
|
||||
|
||||
type GetUsersDTO struct {
|
||||
Page PageInfo `json:"pageInfo,omitempty"`
|
||||
Results []User `json:"results,omitempty"`
|
||||
}
|
||||
|
||||
type permissionsDTO struct {
|
||||
Permissions Permissions `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
type Permissions int
|
||||
|
||||
type NotificationTypes struct {
|
||||
Discord int64 `json:"discord"`
|
||||
Email int64 `json:"email"`
|
||||
Pushbullet int64 `json:"pushbullet"`
|
||||
Pushover int64 `json:"pushover"`
|
||||
Slack int64 `json:"slack"`
|
||||
Telegram int64 `json:"telegram"`
|
||||
Webhook int64 `json:"webhook"`
|
||||
Webpush int64 `json:"webpush"`
|
||||
}
|
||||
|
||||
/* func (nt *NotificationTypes) Empty() bool {
|
||||
return nt.Discord == 0 && nt.Email == 0 && nt.Pushbullet == 0 && nt.Pushover == 0 && nt.Slack == 0 && nt.Telegram == 0 && nt.Webhook == 0 && nt.Webpush == 0
|
||||
} */
|
||||
|
||||
type NotificationsField string
|
||||
|
||||
const (
|
||||
FieldDiscord NotificationsField = "discordId"
|
||||
FieldTelegram NotificationsField = "telegramChatId"
|
||||
FieldEmailEnabled NotificationsField = "emailEnabled"
|
||||
FieldDiscordEnabled NotificationsField = "discordEnabled"
|
||||
FieldTelegramEnabled NotificationsField = "telegramEnabled"
|
||||
)
|
||||
|
||||
type Notifications struct {
|
||||
NotificationsTemplate
|
||||
PgpKey any `json:"pgpKey,omitempty"`
|
||||
DiscordID string `json:"discordId,omitempty"`
|
||||
PushbulletAccessToken any `json:"pushbulletAccessToken,omitempty"`
|
||||
PushoverApplicationToken any `json:"pushoverApplicationToken,omitempty"`
|
||||
PushoverUserKey any `json:"pushoverUserKey,omitempty"`
|
||||
TelegramChatID string `json:"telegramChatId,omitempty"`
|
||||
}
|
||||
|
||||
type NotificationsTemplate struct {
|
||||
EmailEnabled bool `json:"emailEnabled,omitempty"`
|
||||
DiscordEnabled bool `json:"discordEnabled,omitempty"`
|
||||
DiscordEnabledTypes int64 `json:"discordEnabledTypes,omitempty"`
|
||||
PushoverSound any `json:"pushoverSound,omitempty"`
|
||||
TelegramEnabled bool `json:"telegramEnabled,omitempty"`
|
||||
TelegramSendSilently any `json:"telegramSendSilently,omitempty"`
|
||||
WebPushEnabled bool `json:"webPushEnabled,omitempty"`
|
||||
NotifTypes NotificationTypes `json:"notificationTypes"`
|
||||
}
|
||||
|
||||
type MainUserSettings struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
DiscordID string `json:"discordId,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
OriginalLanguage any `json:"originalLanguage,omitempty"`
|
||||
MovieQuotaLimit any `json:"movieQuotaLimit,omitempty"`
|
||||
MovieQuotaDays any `json:"movieQuotaDays,omitempty"`
|
||||
TvQuotaLimit any `json:"tvQuotaLimit,omitempty"`
|
||||
TvQuotaDays any `json:"tvQuotaDays,omitempty"`
|
||||
WatchlistSyncMovies any `json:"watchlistSyncMovies,omitempty"`
|
||||
WatchlistSyncTv any `json:"watchlistSyncTv,omitempty"`
|
||||
}
|
||||
|
||||
type ErrorDTO struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
78
lang.go
@@ -1,5 +1,7 @@
|
||||
package main
|
||||
|
||||
import "github.com/hrfee/jfa-go/common"
|
||||
|
||||
type langMeta struct {
|
||||
Name string `json:"name"`
|
||||
// Language to fall back on if strings are missing. Defaults to en-us.
|
||||
@@ -13,11 +15,11 @@ type quantityString struct {
|
||||
|
||||
type adminLangs map[string]adminLang
|
||||
|
||||
func (ls *adminLangs) getOptions() [][2]string {
|
||||
opts := make([][2]string, len(*ls))
|
||||
func (ls *adminLangs) getOptions() []common.Option {
|
||||
opts := make([]common.Option, len(*ls))
|
||||
i := 0
|
||||
for key, lang := range *ls {
|
||||
opts[i] = [2]string{key, lang.Meta.Name}
|
||||
opts[i] = common.Option{key, lang.Meta.Name}
|
||||
i++
|
||||
}
|
||||
return opts
|
||||
@@ -26,8 +28,10 @@ func (ls *adminLangs) getOptions() [][2]string {
|
||||
type commonLangs map[string]commonLang
|
||||
|
||||
type commonLang struct {
|
||||
Meta langMeta `json:"meta"`
|
||||
Strings langSection `json:"strings"`
|
||||
Meta langMeta `json:"meta"`
|
||||
Strings langSection `json:"strings"`
|
||||
Notifications langSection `json:"notifications"`
|
||||
QuantityStrings map[string]quantityString `json:"quantityStrings"`
|
||||
}
|
||||
|
||||
type adminLang struct {
|
||||
@@ -38,34 +42,36 @@ type adminLang struct {
|
||||
JSON string
|
||||
}
|
||||
|
||||
type formLangs map[string]formLang
|
||||
type userLangs map[string]userLang
|
||||
|
||||
func (ls *formLangs) getOptions() [][2]string {
|
||||
opts := make([][2]string, len(*ls))
|
||||
func (ls *userLangs) getOptions() []common.Option {
|
||||
opts := make([]common.Option, len(*ls))
|
||||
i := 0
|
||||
for key, lang := range *ls {
|
||||
opts[i] = [2]string{key, lang.Meta.Name}
|
||||
opts[i] = common.Option{key, lang.Meta.Name}
|
||||
i++
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
type formLang struct {
|
||||
type userLang struct {
|
||||
Meta langMeta `json:"meta"`
|
||||
Strings langSection `json:"strings"`
|
||||
Notifications langSection `json:"notifications"`
|
||||
notificationsJSON string
|
||||
ValidationStrings map[string]quantityString `json:"validationStrings"`
|
||||
validationStringsJSON string
|
||||
QuantityStrings map[string]quantityString `json:"quantityStrings"`
|
||||
JSON string
|
||||
}
|
||||
|
||||
type pwrLangs map[string]pwrLang
|
||||
|
||||
func (ls *pwrLangs) getOptions() [][2]string {
|
||||
opts := make([][2]string, len(*ls))
|
||||
func (ls *pwrLangs) getOptions() []common.Option {
|
||||
opts := make([]common.Option, len(*ls))
|
||||
i := 0
|
||||
for key, lang := range *ls {
|
||||
opts[i] = [2]string{key, lang.Meta.Name}
|
||||
opts[i] = common.Option{key, lang.Meta.Name}
|
||||
i++
|
||||
}
|
||||
return opts
|
||||
@@ -78,29 +84,30 @@ type pwrLang struct {
|
||||
|
||||
type emailLangs map[string]emailLang
|
||||
|
||||
func (ls *emailLangs) getOptions() [][2]string {
|
||||
opts := make([][2]string, len(*ls))
|
||||
func (ls *emailLangs) getOptions() []common.Option {
|
||||
opts := make([]common.Option, len(*ls))
|
||||
i := 0
|
||||
for key, lang := range *ls {
|
||||
opts[i] = [2]string{key, lang.Meta.Name}
|
||||
opts[i] = common.Option{key, lang.Meta.Name}
|
||||
i++
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
type emailLang struct {
|
||||
Meta langMeta `json:"meta"`
|
||||
Strings langSection `json:"strings"`
|
||||
UserCreated langSection `json:"userCreated"`
|
||||
InviteExpiry langSection `json:"inviteExpiry"`
|
||||
PasswordReset langSection `json:"passwordReset"`
|
||||
UserDeleted langSection `json:"userDeleted"`
|
||||
UserDisabled langSection `json:"userDisabled"`
|
||||
UserEnabled langSection `json:"userEnabled"`
|
||||
InviteEmail langSection `json:"inviteEmail"`
|
||||
WelcomeEmail langSection `json:"welcomeEmail"`
|
||||
EmailConfirmation langSection `json:"emailConfirmation"`
|
||||
UserExpired langSection `json:"userExpired"`
|
||||
Meta langMeta `json:"meta"`
|
||||
Strings langSection `json:"strings"`
|
||||
UserCreated langSection `json:"userCreated"`
|
||||
InviteExpiry langSection `json:"inviteExpiry"`
|
||||
PasswordReset langSection `json:"passwordReset"`
|
||||
UserDeleted langSection `json:"userDeleted"`
|
||||
UserDisabled langSection `json:"userDisabled"`
|
||||
UserEnabled langSection `json:"userEnabled"`
|
||||
UserExpiryAdjusted langSection `json:"userExpiryAdjusted"`
|
||||
InviteEmail langSection `json:"inviteEmail"`
|
||||
WelcomeEmail langSection `json:"welcomeEmail"`
|
||||
EmailConfirmation langSection `json:"emailConfirmation"`
|
||||
UserExpired langSection `json:"userExpired"`
|
||||
}
|
||||
|
||||
type setupLangs map[string]setupLang
|
||||
@@ -112,13 +119,16 @@ type setupLang struct {
|
||||
EndPage langSection `json:"endPage"`
|
||||
General langSection `json:"general"`
|
||||
Updates langSection `json:"updates"`
|
||||
Proxy langSection `json:"proxy"`
|
||||
Language langSection `json:"language"`
|
||||
Login langSection `json:"login"`
|
||||
JellyfinEmby langSection `json:"jellyfinEmby"`
|
||||
Ombi langSection `json:"ombi"`
|
||||
Jellyseerr langSection `json:"jellyseerr"`
|
||||
Email langSection `json:"email"`
|
||||
Messages langSection `json:"messages"`
|
||||
Notifications langSection `json:"notifications"`
|
||||
UserPage langSection `json:"userPage"`
|
||||
WelcomeEmails langSection `json:"welcomeEmails"`
|
||||
PasswordResets langSection `json:"passwordResets"`
|
||||
InviteEmails langSection `json:"inviteEmails"`
|
||||
@@ -127,11 +137,11 @@ type setupLang struct {
|
||||
JSON string
|
||||
}
|
||||
|
||||
func (ls *setupLangs) getOptions() [][2]string {
|
||||
opts := make([][2]string, len(*ls))
|
||||
func (ls *setupLangs) getOptions() []common.Option {
|
||||
opts := make([]common.Option, len(*ls))
|
||||
i := 0
|
||||
for key, lang := range *ls {
|
||||
opts[i] = [2]string{key, lang.Meta.Name}
|
||||
opts[i] = common.Option{key, lang.Meta.Name}
|
||||
i++
|
||||
}
|
||||
return opts
|
||||
@@ -144,11 +154,11 @@ type telegramLang struct {
|
||||
Strings langSection `json:"strings"`
|
||||
}
|
||||
|
||||
func (ts *telegramLangs) getOptions() [][2]string {
|
||||
opts := make([][2]string, len(*ts))
|
||||
func (ts *telegramLangs) getOptions() []common.Option {
|
||||
opts := make([]common.Option, len(*ts))
|
||||
i := 0
|
||||
for key, lang := range *ts {
|
||||
opts[i] = [2]string{key, lang.Meta.Name}
|
||||
opts[i] = common.Option{key, lang.Meta.Name}
|
||||
i++
|
||||
}
|
||||
return opts
|
||||
|
||||
224
lang/admin/ar-aa.json
Normal file
@@ -0,0 +1,224 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "العربية (AR)"
|
||||
},
|
||||
"strings": {
|
||||
"invites": "الدعوات",
|
||||
"accounts": "الحسابات",
|
||||
"settings": "الإعدادات",
|
||||
"inviteMonths": "شهور",
|
||||
"inviteDays": "أيام",
|
||||
"inviteHours": "ساعات",
|
||||
"inviteMinutes": "دقائق",
|
||||
"inviteNumberOfUses": "عدد الاستخدامات",
|
||||
"inviteDuration": "مدة الدعوة",
|
||||
"warning": "تحذير",
|
||||
"inviteInfiniteUsesWarning": "الدعوات ذات الاستخدامات اللانهائية يمكن ان تستخدم بشكل مسيئ",
|
||||
"inviteSendToEmail": "إرسال إلى",
|
||||
"create": "إنشاء",
|
||||
"apply": "تطبيق",
|
||||
"select": "تحديد",
|
||||
"name": "الاسم",
|
||||
"date": "التاريخ",
|
||||
"setExpiry": "تعيين انتهاء الصلاحية",
|
||||
"updates": "التحديثات",
|
||||
"update": "تحديث",
|
||||
"download": "تنزيل",
|
||||
"search": "بحث",
|
||||
"advancedSettings": "إعدادات متقدمة",
|
||||
"lastActiveTime": "آخر نشاط",
|
||||
"from": "من",
|
||||
"after": "بعد",
|
||||
"before": "قبل",
|
||||
"user": "مستخدم",
|
||||
"userExpiry": "انتهاء صلاحية المستخدم",
|
||||
"userExpiryDescription": "بعد وقت محدد من تسجيل مستخدم جديد, jfa-go سوف يمسح\\يلغي تفعيل الحساب. بامكانك تغيير هذا السلوك في الاعدادات.",
|
||||
"aboutProgram": "حول",
|
||||
"version": "إصدار",
|
||||
"commitNoun": "فرض",
|
||||
"newUser": "مستخدم جديد",
|
||||
"profile": "حساب تعريفي",
|
||||
"unknown": "غير معروف",
|
||||
"label": "وسم",
|
||||
"logs": "السجلات",
|
||||
"announce": "إعلان",
|
||||
"templates": "قوالب",
|
||||
"subject": "الموضوع",
|
||||
"message": "الرسالة",
|
||||
"variables": "المتغيرات",
|
||||
"conditionals": "",
|
||||
"preview": "معاينة",
|
||||
"reset": "إعادة ضبط",
|
||||
"donate": "تبرع",
|
||||
"unlink": "إلغاء ربط الحساب",
|
||||
"sendPWR": "إرسال إعادة تعيين كلمة المرور",
|
||||
"contactThrough": "تواصل عن طريق:",
|
||||
"extendExpiry": "تمديد إنتهاء الصلاحية",
|
||||
"sendPWRManual": "",
|
||||
"sendPWRSuccess": "تم إرسال رابط إعادة تعيين كلمة المرور.",
|
||||
"sendPWRSuccessManual": "",
|
||||
"sendPWRValidFor": "",
|
||||
"customizeMessages": "",
|
||||
"customizeMessagesDescription": "",
|
||||
"markdownSupported": "",
|
||||
"modifySettings": "",
|
||||
"modifySettingsDescription": "",
|
||||
"applyHomescreenLayout": "تطبيق ترتيب الصفحه الرئيسيه",
|
||||
"sendDeleteNotificationEmail": "ارسال رساله اشعار",
|
||||
"sendDeleteNotifiationExample": "تم حذف حسابك.",
|
||||
"settingsRestart": "اعاده تشغيل",
|
||||
"settingsRestarting": "اعاده التشغيل…",
|
||||
"settingsRestartRequired": "يجب اعاده التشغيل",
|
||||
"settingsRestartRequiredDescription": "يجب اعاده التشغيل لتطبيق بعض الاعدادات التي تم تغييرها. اعاده التشغيل الان ام لاحقا؟",
|
||||
"settingsApplyRestartLater": "تطبيق الاعدادات, اعاده التشغيل لاحقا",
|
||||
"settingsApplyRestartNow": "تطبيق الاعدادات و اعاده التشغيل",
|
||||
"settingsApplied": "تم تطبيق الاعدادات.",
|
||||
"settingsRefreshPage": "اعد انعاش الصفحه بعد بضع ثوان.",
|
||||
"settingsRequiredOrRestartMessage": "ملاحظه: {n} تشير الى حقل اجباري, {n} تشير ان التغييرات تحتاج لاعاده التشغيل.",
|
||||
"settingsSave": "حفظ",
|
||||
"ombiProfile": "",
|
||||
"ombiUserDefaultsDescription": "",
|
||||
"userProfiles": "",
|
||||
"userProfilesDescription": "",
|
||||
"userProfilesIsDefault": "",
|
||||
"userProfilesLibraries": "",
|
||||
"addProfile": "",
|
||||
"addProfileDescription": "",
|
||||
"addProfileNameOf": "",
|
||||
"addProfileStoreHomescreenLayout": "",
|
||||
"inviteNoUsersCreated": "",
|
||||
"inviteUsersCreated": "",
|
||||
"inviteNoProfile": "",
|
||||
"inviteDateCreated": "",
|
||||
"inviteRemainingUses": "",
|
||||
"inviteNoInvites": "",
|
||||
"inviteExpiresInTime": "",
|
||||
"notifyEvent": "",
|
||||
"notifyInviteExpiry": "",
|
||||
"notifyUserCreation": "",
|
||||
"sendPIN": "",
|
||||
"searchDiscordUser": "",
|
||||
"findDiscordUser": "",
|
||||
"linkMatrixDescription": "",
|
||||
"matrixHomeServer": "",
|
||||
"saveAsTemplate": "",
|
||||
"deleteTemplate": "",
|
||||
"templateEnterName": "",
|
||||
"accessJFA": "",
|
||||
"accessJFASettings": "",
|
||||
"sortingBy": "",
|
||||
"filters": "",
|
||||
"clickToRemoveFilter": "",
|
||||
"clearSearch": "",
|
||||
"actions": "",
|
||||
"searchOptions": "",
|
||||
"matchText": "",
|
||||
"jellyfinID": "",
|
||||
"userPageLogin": "",
|
||||
"userPagePage": "",
|
||||
"buildTime": "",
|
||||
"builtBy": "",
|
||||
"activity": "الانشطه",
|
||||
"userLabel": "وسم المستخدم",
|
||||
"userLabelDescription": "الوسام للمستخدمين المفعلين من هذه الدعوه.",
|
||||
"enableReferrals": "تفعيل الاحالات",
|
||||
"disableReferrals": "ابطال الاحالات",
|
||||
"invite": "دعوه",
|
||||
"enableReferralsProfileDescription": "تمكين المستخدمين من هذا الحساب التعريفي للاحالات الخاصه, لارسالها للعائله\\الاصدقاء. انشاء دعوه بالاعدادات المطلوبه, ثم اختارها هنا. كل احاله سوف تكون مبنيه على اعدادات هذه الدعوه. بامكانك مسح الدعوه عند لانتهاء.",
|
||||
"enableReferralsDescription": "تمكين المستخدمين لاستعمال احالات خاصه مثل الدعوه, لارسالها للعائله\\للاصدقاء. ممكن اصدارها من قوالب الاحالات في الحساب التعريفي, او من دعوه مفعله."
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "",
|
||||
"userCreated": "",
|
||||
"createProfile": "",
|
||||
"saveSettings": "",
|
||||
"saveEmail": "",
|
||||
"sentAnnouncement": "",
|
||||
"savedAnnouncement": "",
|
||||
"setOmbiProfile": "",
|
||||
"updateApplied": "",
|
||||
"updateAppliedRefresh": "",
|
||||
"telegramVerified": "",
|
||||
"accountConnected": "",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "",
|
||||
"errorHomescreenAppliedNoSettings": "",
|
||||
"errorSettingsFailed": "",
|
||||
"errorSaveEmail": "",
|
||||
"errorBlankFields": "",
|
||||
"errorDeleteProfile": "",
|
||||
"errorLoadProfiles": "",
|
||||
"errorCreateProfile": "",
|
||||
"errorSetDefaultProfile": "",
|
||||
"errorLoadUsers": "",
|
||||
"errorLoadSettings": "",
|
||||
"errorSetOmbiProfile": "",
|
||||
"errorLoadOmbiUsers": "",
|
||||
"errorChangedEmailAddress": "",
|
||||
"errorFailureCheckLogs": "",
|
||||
"errorPartialFailureCheckLogs": "",
|
||||
"errorUserCreated": "",
|
||||
"errorSendWelcomeEmail": "",
|
||||
"errorApplyUpdate": "",
|
||||
"errorCheckUpdate": "",
|
||||
"updateAvailable": "",
|
||||
"noUpdatesAvailable": ""
|
||||
},
|
||||
"quantityStrings": {
|
||||
"modifySettingsFor": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"deleteNUsers": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"disableUsers": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"reEnableUsers": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"addUser": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"deleteUser": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"deletedUser": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"disabledUser": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"enabledUser": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"announceTo": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"appliedSettings": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"extendExpiry": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"setExpiry": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
},
|
||||
"extendedExpiry": {
|
||||
"singular": "",
|
||||
"plural": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
229
lang/admin/cs-cz.json
Normal file
@@ -0,0 +1,229 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Čeština (CZ)"
|
||||
},
|
||||
"strings": {
|
||||
"invites": "Pozvánky",
|
||||
"invite": "Pozvat",
|
||||
"accounts": "Účty",
|
||||
"settings": "Nastavení",
|
||||
"inviteMonths": "Měsíce",
|
||||
"inviteDays": "Dny",
|
||||
"inviteHours": "Hodiny",
|
||||
"inviteMinutes": "Minut",
|
||||
"inviteNumberOfUses": "Počet použití",
|
||||
"inviteDuration": "Doba trvání pozvánky",
|
||||
"warning": "Varování",
|
||||
"inviteInfiniteUsesWarning": "pozvánky s nekonečným využitím mohou být zneužity",
|
||||
"inviteSendToEmail": "Poslat komu",
|
||||
"create": "Vytvořit",
|
||||
"apply": "Aplikovat",
|
||||
"select": "Vybrat",
|
||||
"name": "Název",
|
||||
"date": "Datum",
|
||||
"setExpiry": "Nastavit expiraci",
|
||||
"updates": "Aktualizace",
|
||||
"update": "Aktualizace",
|
||||
"download": "Stažení",
|
||||
"search": "Vyhledávání",
|
||||
"advancedSettings": "Pokročilé nastavení",
|
||||
"lastActiveTime": "Naposled aktivní",
|
||||
"from": "Z",
|
||||
"after": "Po",
|
||||
"before": "Před",
|
||||
"user": "Uživatel",
|
||||
"userExpiry": "Vypršení platnosti",
|
||||
"userExpiryDescription": "Zadanou dobu po každé registraci jfa-go smaže/zakáže účet. Toto chování můžete změnit v nastavení.",
|
||||
"aboutProgram": "O",
|
||||
"version": "Verze",
|
||||
"commitNoun": "Zavázat se",
|
||||
"newUser": "Nový uživatel",
|
||||
"profile": "Profil",
|
||||
"unknown": "Neznámý",
|
||||
"label": "Štítek",
|
||||
"userLabel": "Uživatelský štítek",
|
||||
"userLabelDescription": "Štítek, který se použije pro uživatele vytvořené pomocí této pozvánky.",
|
||||
"logs": "Protokoly",
|
||||
"announce": "Oznámit",
|
||||
"templates": "Šablony",
|
||||
"subject": "Předmět",
|
||||
"message": "Zpráva",
|
||||
"variables": "Proměnné",
|
||||
"conditionals": "Podmínky",
|
||||
"preview": "Náhled",
|
||||
"reset": "Resetovat",
|
||||
"donate": "Darovat",
|
||||
"unlink": "Odpojit účet",
|
||||
"sendPWR": "Odeslat resetování hesla",
|
||||
"contactThrough": "Kontakt přes:",
|
||||
"extendExpiry": "Prodloužit platnost",
|
||||
"sendPWRManual": "Uživatel {n} nemá žádný způsob kontaktu, stisknutím tlačítka Kopírovat získáte odkaz, který mu chcete poslat.",
|
||||
"sendPWRSuccess": "Odkaz pro resetování hesla byl odeslán.",
|
||||
"sendPWRSuccessManual": "Pokud jej uživatel neobdržel, stisknutím tlačítka Kopírovat získáte odkaz, který mu můžete ručně odeslat.",
|
||||
"sendPWRValidFor": "Odkaz je platný 30m.",
|
||||
"customizeMessages": "Přizpůsobit zprávy",
|
||||
"customizeMessagesDescription": "Pokud nechcete používat šablony zpráv jfa-go, můžete si vytvořit vlastní pomocí Markdown.",
|
||||
"markdownSupported": "Markdown je podporován.",
|
||||
"modifySettings": "Upravit nastavení",
|
||||
"modifySettingsDescription": "Použít nastavení ze stávajícího profilu nebo je získat přímo od uživatele.",
|
||||
"enableReferrals": "Povolit doporučení",
|
||||
"disableReferrals": "Zakázat doporučení",
|
||||
"enableReferralsDescription": "Poskytněte uživatelům osobní doporučující odkaz podobný pozvánce, kterou můžete poslat přátelům/rodině. Lze je získat ze šablony doporučení v profilu nebo z existující pozvánky.",
|
||||
"enableReferralsProfileDescription": "Poskytněte uživatelům vytvořeným pomocí tohoto profilu osobní doporučující odkaz podobný pozvánce, aby jej poslali přátelům/rodině. Vytvořte pozvánku s požadovaným nastavením a poté ji vyberte zde. Každé doporučení pak bude založeno na této pozvánce. Po dokončení můžete pozvánku smazat.",
|
||||
"applyHomescreenLayout": "Použít rozložení domovské obrazovky",
|
||||
"sendDeleteNotificationEmail": "Odeslat zprávu s upozorněním",
|
||||
"sendDeleteNotifiationExample": "Váš účet byl smazán.",
|
||||
"settingsRestart": "Restartovat",
|
||||
"settingsRestarting": "Restartování…",
|
||||
"settingsRestartRequired": "Je potřeba restart",
|
||||
"settingsRestartRequiredDescription": "K použití některých změn, které jste změnili, je nutný restart. Restartovat hned nebo později?",
|
||||
"settingsApplyRestartLater": "Použít, restartovat později",
|
||||
"settingsApplyRestartNow": "Použít a restartovat",
|
||||
"settingsApplied": "Nastavení byla použita.",
|
||||
"settingsRefreshPage": "Obnovte stránku během několika sekund.",
|
||||
"settingsRequiredOrRestartMessage": "Poznámka: {n} označuje povinné pole, {n} označuje, že změny vyžadují restart.",
|
||||
"settingsSave": "Uložit",
|
||||
"ombiProfile": "Ombi uživatelský profil",
|
||||
"ombiUserDefaultsDescription": "Vytvořte uživatele Ombi a nakonfigurujte jej, poté jej vyberte níže. Když je tento profil vybrán, jeho nastavení/oprávnění budou uložena a použita pro nové uživatele Ombi vytvořené jfa-go.",
|
||||
"userProfiles": "Uživatelské profily",
|
||||
"userProfilesDescription": "Profily se použijí pro uživatele, když si vytvoří účet. Profil zahrnuje přístupová práva ke knihovně a rozvržení domovské obrazovky.",
|
||||
"userProfilesIsDefault": "Výchozí",
|
||||
"userProfilesLibraries": "Knihovny",
|
||||
"addProfile": "Přidat profil",
|
||||
"addProfileDescription": "Vytvořte uživatele Jellyfin a nakonfigurujte jej, poté jej vyberte níže. Když se tento profil použije na pozvánku, vytvoří se noví uživatelé s nastavením.",
|
||||
"addProfileNameOf": "Jméno profilu",
|
||||
"addProfileStoreHomescreenLayout": "Uložit rozložení domovské obrazovky",
|
||||
"inviteNoUsersCreated": "Ještě žádný!",
|
||||
"inviteUsersCreated": "Vytvoření uživatelé",
|
||||
"inviteNoProfile": "Žádný profil",
|
||||
"inviteDateCreated": "Vytvořeno",
|
||||
"inviteNoInvites": "Žádný",
|
||||
"inviteExpiresInTime": "Platnost vyprší za {n}",
|
||||
"notifyEvent": "Upozornit na:",
|
||||
"notifyInviteExpiry": "Při vypršení platnosti",
|
||||
"notifyUserCreation": "Při vytvoření uživatele",
|
||||
"sendPIN": "Požádejte uživatele, aby robotovi zaslal níže uvedený PIN.",
|
||||
"searchDiscordUser": "Začněte psát uživatelské jméno Discord a vyhledejte uživatele.",
|
||||
"findDiscordUser": "Najít uživatele Discordu",
|
||||
"linkMatrixDescription": "Zadejte uživatelské jméno a heslo uživatele, který chcete použít jako robot. Po odeslání se aplikace restartuje.",
|
||||
"matrixHomeServer": "Adresa domovského serveru",
|
||||
"saveAsTemplate": "Uložit jako šablonu",
|
||||
"deleteTemplate": "Smazat šablonu",
|
||||
"templateEnterName": "Zadejte název pro uložení této šablony.",
|
||||
"accessJFA": "Přístup k jfa-go",
|
||||
"accessJFASettings": "Nelze změnit, protože v Nastavení > Obecné bylo nastaveno \"Pouze správce\" nebo \"Povolit vše\".",
|
||||
"sortingBy": "Řazení podle",
|
||||
"filters": "Filtry",
|
||||
"clickToRemoveFilter": "Kliknutím tento filtr odstraníte.",
|
||||
"clearSearch": "Vymazat vyhledávání",
|
||||
"actions": "Akce",
|
||||
"searchOptions": "Možnosti hledání",
|
||||
"matchText": "Shoda textu",
|
||||
"jellyfinID": "Jellyfin ID",
|
||||
"userPageLogin": "Uživatelská stránka: Přihlášení",
|
||||
"userPagePage": "Uživatelská stránka: Stránka",
|
||||
"buildTime": "Čas sestavení",
|
||||
"builtBy": "Postaven",
|
||||
"loginNotAdmin": "Nejste správce?"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "Změněna e-mailová adresa uživatele {n}.",
|
||||
"userCreated": "Uživatel {n} byl vytvořen.",
|
||||
"createProfile": "Vytvořen profil {n}.",
|
||||
"saveSettings": "Nastavení byla uložena",
|
||||
"saveEmail": "Email byl uložen.",
|
||||
"sentAnnouncement": "Oznámení odesláno.",
|
||||
"savedAnnouncement": "Oznámení uloženo.",
|
||||
"setOmbiProfile": "Uložený ombi profil.",
|
||||
"updateApplied": "Aktualizace byla použita, restartujte prosím.",
|
||||
"updateAppliedRefresh": "Aktualizace byla použita, obnovte ji.",
|
||||
"telegramVerified": "Účet telegramu ověřen.",
|
||||
"accountConnected": "Účet připojen.",
|
||||
"referralsEnabled": "Doporučení povolena.",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "Nastavení byla použita, ale použití rozvržení domovské obrazovky mohlo selhat.",
|
||||
"errorHomescreenAppliedNoSettings": "Bylo použito rozvržení domovské obrazovky, ale použití nastavení mohlo selhat.",
|
||||
"errorSettingsFailed": "Aplikace se nezdařila.",
|
||||
"errorSaveEmail": "Uložení e-mailu se nezdařilo.",
|
||||
"errorBlankFields": "Pole zůstala prázdná",
|
||||
"errorDeleteProfile": "Smazání profilu {n} se nezdařilo",
|
||||
"errorLoadProfiles": "Načtení profilů se nezdařilo.",
|
||||
"errorCreateProfile": "Nepodařilo se vytvořit profil {n}",
|
||||
"errorSetDefaultProfile": "Nepodařilo se nastavit výchozí profil.",
|
||||
"errorLoadUsers": "Uživatele se nepodařilo načíst.",
|
||||
"errorLoadSettings": "Nastavení se nepodařilo načíst.",
|
||||
"errorSetOmbiProfile": "Uložení profilu ombi se nezdařilo.",
|
||||
"errorLoadOmbiUsers": "Uživatele ombi se nepodařilo načíst.",
|
||||
"errorChangedEmailAddress": "E-mailovou adresu uživatele {n} se nepodařilo změnit.",
|
||||
"errorFailureCheckLogs": "Selhalo (zkontrolujte konzolu/protokoly)",
|
||||
"errorPartialFailureCheckLogs": "Částečná chyba (zkontrolujte konzolu/protokoly)",
|
||||
"errorUserCreated": "Nepodařilo se vytvořit uživatele {n}.",
|
||||
"errorSendWelcomeEmail": "Nepodařilo se odeslat uvítací zprávu (zkontrolujte konzolu/protokoly)",
|
||||
"errorApplyUpdate": "Aktualizaci se nepodařilo použít, zkuste to ručně.",
|
||||
"errorCheckUpdate": "Kontrola aktualizace se nezdařila.",
|
||||
"errorNoReferralTemplate": "Profil neobsahuje šablonu doporučení, přidejte si ji v nastavení.",
|
||||
"updateAvailable": "Je k dispozici nová aktualizace, zkontrolujte nastavení.",
|
||||
"noUpdatesAvailable": "Nejsou k dispozici žádné nové aktualizace."
|
||||
},
|
||||
"quantityStrings": {
|
||||
"modifySettingsFor": {
|
||||
"singular": "Upravit nastavení pro {n} uživatele",
|
||||
"plural": "Upravit nastavení pro {n} uživatelů"
|
||||
},
|
||||
"enableReferralsFor": {
|
||||
"singular": "Povolit doporučení pro {n} uživatele",
|
||||
"plural": "Povolit doporučení pro {n} uživatelů"
|
||||
},
|
||||
"deleteNUsers": {
|
||||
"singular": "Smazat {n} uživatele",
|
||||
"plural": "Smazat {n} uživatelů"
|
||||
},
|
||||
"disableUsers": {
|
||||
"singular": "Zakázat {n} uživatele",
|
||||
"plural": "Zakázat {n} uživatelů"
|
||||
},
|
||||
"reEnableUsers": {
|
||||
"singular": "Znovu povolte {n} uživatele",
|
||||
"plural": "Znovu povolit {n} uživatelů"
|
||||
},
|
||||
"addUser": {
|
||||
"singular": "Přidat uživatele",
|
||||
"plural": "Přidat uživatele"
|
||||
},
|
||||
"deleteUser": {
|
||||
"singular": "Smazat uživatele",
|
||||
"plural": "Smazat uživatele"
|
||||
},
|
||||
"deletedUser": {
|
||||
"singular": "Smazán {n} uživatel.",
|
||||
"plural": "Smazaní {n} uživatelé."
|
||||
},
|
||||
"disabledUser": {
|
||||
"singular": "Deaktivován {n} uživatel.",
|
||||
"plural": "Zakázaných {n} uživatelů."
|
||||
},
|
||||
"enabledUser": {
|
||||
"singular": "Povoleno {n} uživatele.",
|
||||
"plural": "Povolených {n} uživatelů."
|
||||
},
|
||||
"announceTo": {
|
||||
"singular": "Oznámeno {n} uživateli",
|
||||
"plural": "Oznámit {n} uživatelům"
|
||||
},
|
||||
"appliedSettings": {
|
||||
"singular": "Nastavení byla použita na {n} uživatele.",
|
||||
"plural": "Nastavení byla použita na {n} uživatelů."
|
||||
},
|
||||
"extendExpiry": {
|
||||
"singular": "Prodloužit platnost pro {n} uživatele",
|
||||
"plural": "Prodloužit platnost pro {n} uživatelů"
|
||||
},
|
||||
"setExpiry": {
|
||||
"singular": "Nastavit vypršení platnosti pro {n} uživatele",
|
||||
"plural": "Nastavit vypršení platnosti pro {n} uživatelů"
|
||||
},
|
||||
"extendedExpiry": {
|
||||
"singular": "Prodloužená platnost pro {n} uživatele.",
|
||||
"plural": "Prodloužená platnost pro {n} uživatelů."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Dansk"
|
||||
"name": "Dansk (DK)"
|
||||
},
|
||||
"strings": {
|
||||
"invites": "Invitationer",
|
||||
@@ -15,20 +15,11 @@
|
||||
"warning": "Advarsel",
|
||||
"inviteInfiniteUsesWarning": "invitationer med uendelig brug kan blive misbrugt",
|
||||
"inviteSendToEmail": "Send til",
|
||||
"login": "Log på",
|
||||
"logout": "Log ud",
|
||||
"create": "Opret",
|
||||
"apply": "Anvend",
|
||||
"delete": "Slet",
|
||||
"add": "Tilføj",
|
||||
"select": "Vælg",
|
||||
"name": "Navn",
|
||||
"date": "Dato",
|
||||
"enabled": "Aktiveret",
|
||||
"disabled": "Deaktiveret",
|
||||
"reEnable": "Genaktiver",
|
||||
"disable": "Deaktiver",
|
||||
"admin": "Administrator",
|
||||
"updates": "Opdateringer",
|
||||
"update": "Opdatering",
|
||||
"download": "Hent",
|
||||
@@ -37,7 +28,6 @@
|
||||
"lastActiveTime": "Sidst Aktiv",
|
||||
"from": "Fra",
|
||||
"user": "Bruger",
|
||||
"expiry": "Udløb",
|
||||
"userExpiry": "Brugerens Udløb",
|
||||
"userExpiryDescription": "En specificeret tid efter hver tilmelding, sletter/deaktiverer jfa-go kontoen. Du kan ændre denne adfærd i indstillingerne.",
|
||||
"aboutProgram": "Om",
|
||||
@@ -47,24 +37,23 @@
|
||||
"profile": "Profil",
|
||||
"unknown": "Ukendt",
|
||||
"label": "Etiket",
|
||||
"announce": "Annoncere",
|
||||
"announce": "Meddelelse",
|
||||
"subject": "Emne",
|
||||
"message": "Meddelelse",
|
||||
"message": "Besked",
|
||||
"variables": "Variabler",
|
||||
"conditionals": "Betingelser",
|
||||
"preview": "Eksempel",
|
||||
"reset": "Nulstil",
|
||||
"edit": "Rediger",
|
||||
"donate": "Doner",
|
||||
"contactThrough": "Kontakt gennem:",
|
||||
"extendExpiry": "Forlæng udløb",
|
||||
"customizeMessages": "Tilpas Meddelelser",
|
||||
"customizeMessagesDescription": "Hvis du ikke vil bruge jfa-go's meddelelses skabeloner, kan du oprette din egen ved hjælp af Markdown.",
|
||||
"customizeMessages": "Tilpas Beskeder",
|
||||
"customizeMessagesDescription": "Hvis du ikke vil bruge jfa-go's besked skabeloner, kan du oprette din egen ved hjælp af Markdown.",
|
||||
"markdownSupported": "Markdown understøttes.",
|
||||
"modifySettings": "Rediger indstillinger",
|
||||
"modifySettingsDescription": "Anvend indstillinger fra en eksisterende profil, eller hent dem direkte fra en bruger.",
|
||||
"applyHomescreenLayout": "Anvend startskærmens layout",
|
||||
"sendDeleteNotificationEmail": "Send notifikations meddelelse",
|
||||
"sendDeleteNotificationEmail": "Send notifikations besked",
|
||||
"sendDeleteNotifiationExample": "Din konto er blevet slettet.",
|
||||
"settingsRestart": "Genstart",
|
||||
"settingsRestarting": "Genstarter…",
|
||||
@@ -77,7 +66,7 @@
|
||||
"settingsRequiredOrRestartMessage": "Bemærk: {n} angiver et obligatorisk felt, {n} angiver at ændringer kræver genstart.",
|
||||
"settingsSave": "Gem",
|
||||
"ombiUserDefaults": "Ombi bruger standarder",
|
||||
"ombiUserDefaultsDescription": "Opret en Ombi bruger og konfigurer den, vælg den derefter nedenfor. Brugerens indstillinger/tilladelser gemmes og anvendes på nye Ombi brugere oprettet af jfa-go",
|
||||
"ombiUserDefaultsDescription": "Opret en Ombi bruger og konfigurer den, vælg den derefter nedenfor. Brugerens indstillinger/tilladelser gemmes og anvendes på nye Ombi brugere oprettet af jfa-go når denne profil er valgt.",
|
||||
"userProfiles": "Bruger Profiler",
|
||||
"userProfilesDescription": "Profiler anvendes på brugere når de opretter en konto. En profil inkluderer adgangsrettigheder til biblioteket og layout på startskærmen.",
|
||||
"userProfilesIsDefault": "Standard",
|
||||
@@ -90,7 +79,6 @@
|
||||
"inviteUsersCreated": "Oprettet brugere",
|
||||
"inviteNoProfile": "Ingen Profil",
|
||||
"inviteDateCreated": "Oprettet",
|
||||
"inviteRemainingUses": "Resterende anvendelser",
|
||||
"inviteNoInvites": "Ingen",
|
||||
"inviteExpiresInTime": "Udløber om {n}",
|
||||
"notifyEvent": "Meddel den:",
|
||||
@@ -104,7 +92,45 @@
|
||||
"saveAsTemplate": "Gem som skabelon",
|
||||
"templates": "Skabeloner",
|
||||
"deleteTemplate": "Slet skabelon",
|
||||
"templateEnterName": "Indtast et navn for at gemme denne skabelon."
|
||||
"templateEnterName": "Indtast et navn for at gemme denne skabelon.",
|
||||
"ombiProfile": "Ombi bruger profil",
|
||||
"setExpiry": "Sæt udløb",
|
||||
"logs": "Log",
|
||||
"sendPWR": "Send Nulstilling af Adgangskode",
|
||||
"sendPWRManual": "Brugeren {n} har ingen kontaktinformation, tryk kopier for at få et link du kan sende til dem.",
|
||||
"sendPWRSuccess": "Link til nulstilling af adgangskode sendt.",
|
||||
"sendPWRSuccessManual": "Hvis brugeren ikke er modtaget den, så tryk på kopier for manuelt at sende et link til dem.",
|
||||
"sendPWRValidFor": "Dette link er gyldigt i 30m.",
|
||||
"accessJFA": "Få adgang til jfa-go",
|
||||
"accessJFASettings": "Kan ikke ændres, da enten \"Kun administrator\" eller \"Tillad alle\" er blevet indstillet i Indstillinger > Generelt.",
|
||||
"after": "Efter",
|
||||
"settingsHiddenDependency": "Matchende indstillinger er skjult, fordi de afhænger af værdien af en anden indstilling:",
|
||||
"userPageLogin": "Brugerside: Login",
|
||||
"buildTime": "Bygnings Tid",
|
||||
"invite": "inviter",
|
||||
"loginNotAdmin": "Ikke en Admin?",
|
||||
"userLabel": "Brugeretiket",
|
||||
"userLabelDescription": "Etiket, der skal anvendes på brugere, der er oprettet med denne invitation.",
|
||||
"sortingBy": "Sortering Efter",
|
||||
"clickToRemoveFilter": "Klik for at fjerne dette filter.",
|
||||
"clearSearch": "Ryd søgning",
|
||||
"actions": "Handlinger",
|
||||
"unlink": "Fjern linket til konto",
|
||||
"enableReferrals": "Aktiver henvisninger",
|
||||
"disableReferrals": "Deaktiver henvisninger",
|
||||
"enableReferralsDescription": "Giv brugerne et personligt henvisningslink, der ligner en invitation, til at sende til venner/familie. Kan hentes fra en henvisningsskabelon i en profil eller fra en eksisterende invitation.",
|
||||
"enableReferralsProfileDescription": "Giv brugere oprettet med denne profil et personligt henvisningslink, der ligner en invitation, til at sende til venner/familie. Opret en invitation med de ønskede indstillinger, og vælg den her. Hver henvisning vil så være baseret på denne invitation. Du kan slette invitationen, når den er fuldført.",
|
||||
"before": "Før",
|
||||
"noResultsFound": "Ingen Resultater Fundet",
|
||||
"settingsDependsOn": "{setting}: afhænger af {dependency}",
|
||||
"settingsMaybeUnderAdvanced": "Tip: Du finder muligvis det du leder efter, ved at aktivere Avancerede indstillinger.",
|
||||
"settingsAdvancedMode": "{setting}: Avanceret Indstillinger skal være aktiveret",
|
||||
"filters": "Filtre",
|
||||
"searchOptions": "Søge Indstillinger",
|
||||
"matchText": "Match Tekst",
|
||||
"jellyfinID": "Jellyfin ID",
|
||||
"userPagePage": "Brugerside: Side",
|
||||
"builtBy": "Bygget Af"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "Ændret e-mail adresse på {n}.",
|
||||
@@ -118,13 +144,9 @@
|
||||
"updateAppliedRefresh": "Opdatering anvendt, genindlæs venligst siden.",
|
||||
"telegramVerified": "Telegram konto verificeret.",
|
||||
"accountConnected": "Konto tilsluttet.",
|
||||
"errorConnection": "Kunne ikke oprette forbindelse til jfa-go.",
|
||||
"error401Unauthorized": "Adgang nægtet. Prøv at genindlæse siden.",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "Indstillingerne blev anvendt, men anvendelse af startskærmens layout mislykkedes muligvis.",
|
||||
"errorHomescreenAppliedNoSettings": "Startskærmens layout blev anvendt, men anvendelsen af indstillingerne mislykkedes muligvis.",
|
||||
"errorSettingsFailed": "Ansøgningen mislykkedes.",
|
||||
"errorLoginBlank": "Brugernavnet og/eller adgangskoden blev efterladt tomme.",
|
||||
"errorUnknown": "Ukendt fejl.",
|
||||
"errorSaveEmail": "Kunne ikke gemme e-mail.",
|
||||
"errorBlankFields": "Felter blev efterladt tomme",
|
||||
"errorDeleteProfile": "Kunne ikke slette profilen {n}",
|
||||
@@ -132,7 +154,6 @@
|
||||
"errorCreateProfile": "Kunne ikke oprette profilen {n}",
|
||||
"errorSetDefaultProfile": "Standard profilen kunne ikke indstilles.",
|
||||
"errorLoadUsers": "Kunne ikke indlæse brugere.",
|
||||
"errorSaveSettings": "Kunne ikke gemme indstillingerne.",
|
||||
"errorLoadSettings": "Indstillingerne kunne ikke indlæses.",
|
||||
"errorSetOmbiDefaults": "Ombi standarderne kunne ikke gemmes.",
|
||||
"errorLoadOmbiUsers": "Kunne ikke indlæse ombi brugere.",
|
||||
@@ -140,12 +161,16 @@
|
||||
"errorFailureCheckLogs": "Mislykkedes (tjek konsol/logfiler)",
|
||||
"errorPartialFailureCheckLogs": "Delvis fejl (tjek konsol/logfiler)",
|
||||
"errorUserCreated": "Kunne ikke oprette bruger {n}.",
|
||||
"errorSendWelcomeEmail": "Kunne ikke sende velkomst meddelelse (tjek konsol/logfiler",
|
||||
"errorSendWelcomeEmail": "Kunne ikke sende velkomst besked (tjek konsol/logfiler",
|
||||
"errorApplyUpdate": "Kunne ikke anvende opdateringen, prøv manuelt.",
|
||||
"errorCheckUpdate": "Kunne ikke kontrollere for opdatering.",
|
||||
"updateAvailable": "En ny opdatering er tilgængelig, tjek indstillingerne.",
|
||||
"noUpdatesAvailable": "Ingen nye opdateringer tilgængelige.",
|
||||
"savedAnnouncement": "Meddelelse gemt."
|
||||
"savedAnnouncement": "Meddelelse gemt.",
|
||||
"setOmbiProfile": "Gemt i ombi profilen.",
|
||||
"errorSetOmbiProfile": "Ombi profilen kunne ikke gemmes.",
|
||||
"referralsEnabled": "Henvisninger aktiveret.",
|
||||
"errorNoReferralTemplate": "Profilen indeholder ikke en henvisningsskabelon. Tilføj en i indstillingerne."
|
||||
},
|
||||
"quantityStrings": {
|
||||
"modifySettingsFor": {
|
||||
@@ -185,8 +210,8 @@
|
||||
"plural": "Aktiveret {n} brugere."
|
||||
},
|
||||
"announceTo": {
|
||||
"singular": "Annoncer til {n} bruger",
|
||||
"plural": "Annoncer til {n} brugere"
|
||||
"singular": "Send Meddelelse til {n} bruger",
|
||||
"plural": "Send Meddelelse til {n} brugere"
|
||||
},
|
||||
"appliedSettings": {
|
||||
"singular": "Anvendte indstillinger til {n} bruger.",
|
||||
@@ -199,6 +224,14 @@
|
||||
"extendedExpiry": {
|
||||
"singular": "Forlængede udløb for {n} bruger.",
|
||||
"plural": "Forlængede udløb for {n} brugere."
|
||||
},
|
||||
"setExpiry": {
|
||||
"singular": "Indstil udløb for {n} bruger",
|
||||
"plural": "Indstil udløb for {n} brugere"
|
||||
},
|
||||
"enableReferralsFor": {
|
||||
"singular": "Aktiver Henvisninger for {n} bruger",
|
||||
"plural": "Aktiver Henvisninger for {n} brugere"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Deutsch (DE)"
|
||||
},
|
||||
"strings": {
|
||||
"invites": "Invites",
|
||||
"invites": "Einladungen",
|
||||
"accounts": "Konten",
|
||||
"settings": "Einstellungen",
|
||||
"inviteDays": "Tage",
|
||||
@@ -13,11 +13,8 @@
|
||||
"warning": "Warnung",
|
||||
"inviteInfiniteUsesWarning": "Invites mit unendlich vielen Verwendungen können missbräuchlich verwendet werden",
|
||||
"inviteSendToEmail": "Senden an",
|
||||
"login": "Anmelden",
|
||||
"logout": "Abmelden",
|
||||
"create": "Erstellen",
|
||||
"apply": "Anwenden",
|
||||
"delete": "Löschen",
|
||||
"name": "Name",
|
||||
"date": "Datum",
|
||||
"lastActiveTime": "Zuletzt aktiv",
|
||||
@@ -43,7 +40,7 @@
|
||||
"settingsRequiredOrRestartMessage": "Hinweis: {n} zeigt ein erforderliches Feld an, {n} zeigt an, dass Änderungen einen Neustart erfordern.",
|
||||
"settingsSave": "Speichern",
|
||||
"ombiUserDefaults": "Ombi-Benutzerstandardeinstellungen",
|
||||
"ombiUserDefaultsDescription": "Erstelle einen Ombi-Benutzer und konfiguriere ihn, dann wähle ihn unten aus. Seine Einstellungen/Berechtigungen werden gespeichert und auf neue Ombi-Benutzer, erstellt von jfa-go, angewendet",
|
||||
"ombiUserDefaultsDescription": "Erstelle einen Ombi-Benutzer, konfiguriere ihn und wähle ihn dann unten aus. Seine Einstellungen/Berechtigungen werden gespeichert und auf neue Ombi-Benutzer, welche von jfa-go erstellt werden, angewendet, sofern dieses Profil ausgewählt ist.",
|
||||
"userProfiles": "Benutzerprofile",
|
||||
"userProfilesDescription": "Profile werden auf Benutzer angewendet, wenn sie ein Konto erstellen. Ein Profil beinhaltet Bibliothekszugriffsrechte und das Startbildschirmlayout.",
|
||||
"userProfilesIsDefault": "Standard",
|
||||
@@ -56,7 +53,6 @@
|
||||
"inviteUsersCreated": "Erstellte Benutzer",
|
||||
"inviteNoProfile": "Kein Profil",
|
||||
"inviteDateCreated": "Erstellt",
|
||||
"inviteRemainingUses": "Verbleibende Verwendungen",
|
||||
"inviteNoInvites": "Keine",
|
||||
"inviteExpiresInTime": "Läuft in {n} ab",
|
||||
"notifyEvent": "Benachrichtigen bei:",
|
||||
@@ -68,8 +64,7 @@
|
||||
"variables": "Variablen",
|
||||
"preview": "Vorschau",
|
||||
"reset": "Zurücksetzen",
|
||||
"edit": "Bearbeiten",
|
||||
"customizeMessages": "E-Mails anpassen",
|
||||
"customizeMessages": "Benachrichtigungen anpassen",
|
||||
"customizeMessagesDescription": "Wenn du jfa-go's E-Mail-Vorlagen nicht benutzen willst, kannst du deinen eigenen unter Verwendung von Markdown erstellen.",
|
||||
"announce": "Ankündigen",
|
||||
"subject": "Betreff",
|
||||
@@ -79,29 +74,69 @@
|
||||
"search": "Suchen",
|
||||
"userExpiry": "Benutzer Ablaufdatum",
|
||||
"inviteDuration": "Invite Dauer",
|
||||
"enabled": "Aktiviert",
|
||||
"userExpiryDescription": "Eine bestimmte Zeit nach der Anmeldung wird jfa-go das Konto löschen/deaktivieren. Du kannst dieses Verhalten in den Einstellungen ändern.",
|
||||
"disabled": "Deaktiviert",
|
||||
"admin": "Admin",
|
||||
"download": "Herunterladen",
|
||||
"update": "Aktualisieren",
|
||||
"updates": "Aktualisierungen",
|
||||
"expiry": "Ablaufdatum",
|
||||
"extendExpiry": "Ablaufdatum verlängern",
|
||||
"reEnable": "Wieder aktivieren",
|
||||
"disable": "Deaktivieren",
|
||||
"donate": "Spenden",
|
||||
"conditionals": "Bedingungen",
|
||||
"contactThrough": "Kontakt über:",
|
||||
"sendPIN": "Bitte den Benutzer, die unten stehende PIN an den Bot zu senden.",
|
||||
"inviteMonths": "Monate",
|
||||
"add": "Hinzufügen",
|
||||
"select": "Auswählen",
|
||||
"searchDiscordUser": "Gib den Discord-Benutzername ein, um den Benutzer zu finden.",
|
||||
"findDiscordUser": "Suche Discord-Benutzer",
|
||||
"linkMatrixDescription": "Gib den Benutzernamen und das Passwort des Benutzers ein, der als Bot verwendet werden soll. Nach dem Absenden wird die App neu gestartet.",
|
||||
"matrixHomeServer": "Adresse des Homeservers",
|
||||
"templates": "Vorlagen"
|
||||
"templates": "Vorlagen",
|
||||
"ombiProfile": "Ombi-Benutzerprofil",
|
||||
"accessJFA": "jfa-go Zugriff",
|
||||
"sendPWRValidFor": "Der Link ist 30m gültig.",
|
||||
"logs": "Logdaten",
|
||||
"setExpiry": "Ablaufdatum setzen",
|
||||
"sendPWRSuccess": "Link zur Passwortrücksetzung versandt.",
|
||||
"sendPWRSuccessManual": "Falls der Benutzer ihn nicht erhalten hat, klicke \"Kopieren\" und sende ihm den Link manuell.",
|
||||
"sendPWR": "Sende Passwortrücksetzung",
|
||||
"sendPWRManual": "Benutzer {n} hat keine Kontaktmöglichkeit hinterlegt. Klicke \"Kopieren\" um einen Link zu erhalten, den du dem Benutzer manuell senden kannst.",
|
||||
"accessJFASettings": "Kann nicht geändert werden, da entweder \"Nur Admin-Benutzer\" oder \"Erlaube allen Jellyfin-Nutzern sich anzumelden\" in Einstellungen > Allgemein aktiviert ist.",
|
||||
"saveAsTemplate": "Als Vorlage speichern",
|
||||
"deleteTemplate": "Vorlage löschen",
|
||||
"templateEnterName": "Gebe einen Namen ein, um diese Vorlage zu speichern.",
|
||||
"filters": "Filter",
|
||||
"clickToRemoveFilter": "zum Entfernen des Filters klicken.",
|
||||
"clearSearch": "Suche löschen",
|
||||
"actions": "Aktionen",
|
||||
"searchOptions": "Suchoptionen",
|
||||
"matchText": "Textübereinstummung",
|
||||
"jellyfinID": "Jellyfin ID",
|
||||
"userPageLogin": "Benutzer Seite: Login",
|
||||
"userPagePage": "Benutzer Seite: Seite",
|
||||
"after": "nach",
|
||||
"before": "vor",
|
||||
"unlink": "Account trennen",
|
||||
"sortingBy": "Sortieren nach",
|
||||
"activity": "Aktivität",
|
||||
"settingsMaybeUnderAdvanced": "Tipp: Du könntest finden, wonach Du suchst, indem Du die erweiterten Einstellungen aktivierst.",
|
||||
"enableReferralsProfileDescription": "Gib Benutzern, die mit diesem Profil erstellt wurden, einen persönlichen Empfehlungslink, ähnlich einer Einladung, den sie an Freunde und Familie senden können. Erstelle eine Einladung mit den gewünschten Einstellungen und wähle sie dann hier aus. Jede Empfehlung basiert dann auf dieser Einladung. Du kannst die Einladung nach Abschluss löschen.",
|
||||
"removeExpiry": "Ablaufdatum entfernen",
|
||||
"enterExpiry": "Ablaufdatum eingeben",
|
||||
"keepSearchingDescription": "Die Suche umfasst nur bereits geladene Aktivitäten. Klicke unten um alle Aktivitäten zu durchsuchen.",
|
||||
"useInviteExpiry": "Ablaufdatum des Profils/der Einladung setzen",
|
||||
"useInviteExpiryNote": "Standardmässig laufen Einladungen nach 90 Tagen ab, können jedoch vom Benutzer erneuert werden. Aktiviere diese Option, damit die Empfehlung nach der festgelegten Zeit deaktiviert wird.",
|
||||
"settingsHiddenDependency": "Zutreffende Einstellungen sind ausgeblendet, da sie vom Wert einer anderen Einstellung abhängen:",
|
||||
"deleted": "Gelöscht",
|
||||
"disabled": "Deaktiviert",
|
||||
"keepSearching": "Weiter suchen",
|
||||
"enableReferralsDescription": "Gib Benutzern einen persönlichen Empfehlungslink, ähnlich einer Einladung, den sie an Freunde und Familie senden können. Dieser kann aus einer Empfehlungsvorlage im Profil oder aus einer bestehenden Einladung stammen.",
|
||||
"settingsDependsOn": "{setting}: abhängig von {dependency}",
|
||||
"settingsAdvancedMode": "{setting}: Erweiterte Einstellungen müssen aktiviert sein",
|
||||
"invite": "Einladung",
|
||||
"userLabelDescription": "Label welches auf Benutzer angewendet wird, die mit dieser Einladung erstellt wurden.",
|
||||
"enableReferrals": "Empfehlungen aktivieren",
|
||||
"disableReferrals": "Empfehlungen deaktivieren",
|
||||
"userLabel": "Benutzer Label",
|
||||
"noResultsFound": "Keine Resultate gefunden"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "E-Mail-Adresse von {n} geändert.",
|
||||
@@ -109,20 +144,15 @@
|
||||
"createProfile": "Profil {n} erstellt.",
|
||||
"saveSettings": "Einstellungen wurden gespeichert",
|
||||
"setOmbiDefaults": "Ombi-Standardeinstellungen gespeichert.",
|
||||
"errorConnection": "Konnte keine Verbindung zu jfa-go herstellen.",
|
||||
"error401Unauthorized": "Unberechtigt. Versuch, die Seite zu aktualisieren.",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "Einstellungen wurden angewendet, aber die Anwendung des Startbildschirmlayouts ist möglicherweise fehlgeschlagen.",
|
||||
"errorHomescreenAppliedNoSettings": "Startbildschirmlayout wurde angewendet, aber die Anwendung der Einstellungen ist möglicherweise fehlgeschlagen.",
|
||||
"errorSettingsFailed": "Anwendung ist fehlgeschlagen.",
|
||||
"errorLoginBlank": "Der Benutzername und/oder das Passwort wurden nicht ausgefüllt.",
|
||||
"errorUnknown": "Unbekannter Fehler.",
|
||||
"errorBlankFields": "Felder wurden nicht ausgefüllt",
|
||||
"errorDeleteProfile": "Fehler beim Löschen des Profils {n}",
|
||||
"errorLoadProfiles": "Fehler beim Laden der Profile.",
|
||||
"errorCreateProfile": "Fehler beim Erstellen des Profils {n}",
|
||||
"errorSetDefaultProfile": "Fehler beim Setzen des Standardprofils.",
|
||||
"errorLoadUsers": "Fehler beim Laden der Benutzer.",
|
||||
"errorSaveSettings": "Einstellungen konnten nicht gespeichert werden.",
|
||||
"errorLoadSettings": "Fehler beim Laden der Einstellungen.",
|
||||
"errorSetOmbiDefaults": "Fehler beim Speichern der Ombi-Standardeinstellungen.",
|
||||
"errorLoadOmbiUsers": "Fehler beim Laden der Ombi-Benutzer.",
|
||||
@@ -142,7 +172,9 @@
|
||||
"updateAppliedRefresh": "Update angewendet, bitte aktualisieren.",
|
||||
"telegramVerified": "Telegram-Konto verifiziert.",
|
||||
"accountConnected": "Konto verbunden.",
|
||||
"savedAnnouncement": "Ankündigung gespeichert."
|
||||
"savedAnnouncement": "Ankündigung gespeichert.",
|
||||
"errorSetOmbiProfile": "Ombi-Profil konnte nicht gespeichert werden.",
|
||||
"setOmbiProfile": "Ombi-Profil gespeichert."
|
||||
},
|
||||
"quantityStrings": {
|
||||
"modifySettingsFor": {
|
||||
@@ -196,6 +228,10 @@
|
||||
"reEnableUsers": {
|
||||
"singular": "Benutzer {n} wieder aktivieren",
|
||||
"plural": "Benutzer {n} wieder aktivieren"
|
||||
},
|
||||
"setExpiry": {
|
||||
"singular": "Ablauf für {n} Benutzer setzen",
|
||||
"plural": "Ablauf für {n} Benutzer setzen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,8 @@
|
||||
"warning": "Προσοχή",
|
||||
"inviteInfiniteUsesWarning": "μπορεί να γίνει κατάχρηση των προσκλήσεων με άπειρες χρήσεις",
|
||||
"inviteSendToEmail": "Αποστολή σε",
|
||||
"login": "Σύνδεση",
|
||||
"logout": "Αποσύνδεση",
|
||||
"create": "Δημιουργία",
|
||||
"apply": "Εφαρμογή",
|
||||
"delete": "Διαγραφή",
|
||||
"name": "Όνομα",
|
||||
"date": "Ημερομηνία",
|
||||
"lastActiveTime": "Τελευταία Ενεργός",
|
||||
@@ -59,7 +56,6 @@
|
||||
"inviteUsersCreated": "Δημιουργηθέντες χρήστες",
|
||||
"inviteNoProfile": "Κανένα Προφίλ",
|
||||
"inviteDateCreated": "Δημιουργηθέντα",
|
||||
"inviteRemainingUses": "Εναπομείναντες χρήσεις",
|
||||
"inviteNoInvites": "Καμία",
|
||||
"inviteExpiresInTime": "Λήγει σε {n}",
|
||||
"notifyEvent": "Ενημέρωση όταν:",
|
||||
@@ -68,7 +64,6 @@
|
||||
"variables": "Μεταβλητές",
|
||||
"preview": "Προεπισκόπηση",
|
||||
"reset": "Επαναφορά",
|
||||
"edit": "Επεξεργασία",
|
||||
"customizeMessages": "Παραμετροποίηση Emails",
|
||||
"advancedSettings": "Προχωρημένες Ρυθμίσεις",
|
||||
"customizeMessagesDescription": "Αν δεν θέλετε να ζρησιμοποιήσετε τα πρότυπα email του jfa-go, μπορείτε να δημιουργήσετε τα δικά σας με χρήση Markdown.",
|
||||
@@ -77,10 +72,6 @@
|
||||
"download": "Λήψη",
|
||||
"search": "Αναζήτηση",
|
||||
"inviteDuration": "Διάρκεια Πρόσκλησης",
|
||||
"enabled": "Ενεργοποιημένο",
|
||||
"disabled": "Απενεργοποιημένο",
|
||||
"admin": "Διαχειριστής",
|
||||
"expiry": "Λήξη",
|
||||
"userExpiry": "Λήξη Χρήστη",
|
||||
"userExpiryDescription": "Μετά απο ένα καθορισμένο χρόνο μετά απο κάθε εγγραφή, το jfa-go θα διαγράφει/απενεργοποιεί τον λογαριασμό. Μπορείτε να αλλάξετε αυτή την συμπεριφορά στις ρυθμίσεις.",
|
||||
"announce": "Ανακοίνωση",
|
||||
@@ -88,8 +79,6 @@
|
||||
"message": "Μήνυμα",
|
||||
"extendExpiry": "Παράταση λήξης",
|
||||
"markdownSupported": "Το Markdown υποστυρίζεται.",
|
||||
"reEnable": "Επανα-ενεργοποίηση",
|
||||
"disable": "Απενεργοποίηση",
|
||||
"inviteMonths": "Μήνες"
|
||||
},
|
||||
"notifications": {
|
||||
@@ -98,20 +87,15 @@
|
||||
"createProfile": "Δημιουργήθηκε το {n} προφίλ.",
|
||||
"saveSettings": "Οι ρυθμίσεις αποθηκεύτηκαν",
|
||||
"setOmbiDefaults": "Αποθηκεύτηκαν οι προκαθορισμένες ρυθμίσεις του ombi.",
|
||||
"errorConnection": "Δεν μπόρεσε να συνδεθεί με το jfa-go.",
|
||||
"error401Unauthorized": "Ανεξουσιοδότητος. Προσπαθήστε να κάνετε επαναφόρτωση την σελίδα.",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "Οι ρυθμίσεις αποθηκεύτηκαν, αλλά η καταχώρηση δομής αρχικής οθόνης ίσως απέτυχε.",
|
||||
"errorHomescreenAppliedNoSettings": "Η δομή αρχικής οθόνης εφαρμόστηκε, αλλά οι ρυθμίσεις ίσως απέτυχαν.",
|
||||
"errorSettingsFailed": "Η εφαρμογή απέτυχε.",
|
||||
"errorLoginBlank": "Το όνομα χρήστη και/ή ο κωδικός ήταν κενά.",
|
||||
"errorUnknown": "Άγνωστο σφάλμα.",
|
||||
"errorBlankFields": "Τα πεφία ήταν κενά",
|
||||
"errorDeleteProfile": "Αποτυχία διαγραφής του προφίλ {n}",
|
||||
"errorLoadProfiles": "Αποτυχία φόρτωσης των προφίλ.",
|
||||
"errorCreateProfile": "Αποτυχία δημιουργίας του προφίλ {n}",
|
||||
"errorSetDefaultProfile": "Αποτυχία ορισμού του προκαθορισμένου προφίλ.",
|
||||
"errorLoadUsers": "Αποτυχία φόρτωσης χρηστών.",
|
||||
"errorSaveSettings": "Αποτυχία αποθήκευσης ρυθμίσεων.",
|
||||
"errorLoadSettings": "Αποτυχία φόρτωσης ρυθμίσεων.",
|
||||
"errorSetOmbiDefaults": "Αποτυχία αποθήκευσης προκαθορισμένων ρυθμίσεων για το Ombi.",
|
||||
"errorLoadOmbiUsers": "Αποτυχία φόρτωσης χρηστών Ombi.",
|
||||
@@ -183,4 +167,4 @@
|
||||
"plural": "Εργοποιήθηκαν {n} χρήστες."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,200 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "English (GB)"
|
||||
},
|
||||
"quantityStrings": {
|
||||
"deleteUser": {
|
||||
"singular": "Delete User",
|
||||
"plural": "Delete Users"
|
||||
},
|
||||
"deletedUser": {
|
||||
"singular": "Deleted {n} user.",
|
||||
"plural": "Deleted {n} users."
|
||||
},
|
||||
"disabledUser": {
|
||||
"plural": "Disabled {n} users.",
|
||||
"singular": "Disabled {n} user."
|
||||
},
|
||||
"extendExpiry": {
|
||||
"singular": "Extend expiry for {n} user",
|
||||
"plural": "Extend expiry for {n} users"
|
||||
},
|
||||
"extendedExpiry": {
|
||||
"plural": "Extended expiry for {n} users.",
|
||||
"singular": "Extended expiry for {n} user."
|
||||
},
|
||||
"addUser": {
|
||||
"singular": "Add user",
|
||||
"plural": "Add users"
|
||||
},
|
||||
"modifySettingsFor": {
|
||||
"singular": "Modify Settings for {n} user",
|
||||
"plural": "Modify Settings for {n} users"
|
||||
},
|
||||
"deleteNUsers": {
|
||||
"plural": "Delete {n} users",
|
||||
"singular": "Delete {n} user"
|
||||
},
|
||||
"disableUsers": {
|
||||
"singular": "Disable {n} user",
|
||||
"plural": "Disable {n} users"
|
||||
},
|
||||
"enabledUser": {
|
||||
"singular": "Enabled {n} user.",
|
||||
"plural": "Enabled {n} users."
|
||||
},
|
||||
"announceTo": {
|
||||
"singular": "Announce to {n} user",
|
||||
"plural": "Announce to {n} users"
|
||||
},
|
||||
"appliedSettings": {
|
||||
"singular": "Applied settings to {n} user.",
|
||||
"plural": "Applied settings to {n} users."
|
||||
},
|
||||
"setExpiry": {
|
||||
"singular": "Set expiry for {n} user",
|
||||
"plural": "Set expiry for {n} users"
|
||||
},
|
||||
"reEnableUsers": {
|
||||
"singular": "Re-enable {n} user",
|
||||
"plural": "Re-enable {n} users"
|
||||
}
|
||||
},
|
||||
"strings": {
|
||||
"invites": "Invites",
|
||||
"accounts": "Accounts",
|
||||
"settings": "Settings",
|
||||
"inviteDays": "Days",
|
||||
"inviteHours": "Hours",
|
||||
"inviteInfiniteUsesWarning": "invites with infinite uses can be used abusively",
|
||||
"inviteSendToEmail": "Send to",
|
||||
"apply": "Apply",
|
||||
"updates": "Updates",
|
||||
"variables": "Variables",
|
||||
"preview": "Preview",
|
||||
"markdownSupported": "Markdown is supported.",
|
||||
"applyHomescreenLayout": "Apply homescreen layout",
|
||||
"ombiProfile": "Ombi user profile",
|
||||
"settingsApplyRestartNow": "Apply & restart",
|
||||
"settingsApplied": "Settings applied.",
|
||||
"userProfiles": "User Profiles",
|
||||
"addProfile": "Add Profile",
|
||||
"userProfilesLibraries": "Libraries",
|
||||
"addProfileNameOf": "Profile Name",
|
||||
"inviteDateCreated": "Created",
|
||||
"settingsRestart": "Restart",
|
||||
"inviteMinutes": "Minutes",
|
||||
"inviteNumberOfUses": "Number of uses",
|
||||
"warning": "Warning",
|
||||
"create": "Create",
|
||||
"name": "Name",
|
||||
"conditionals": "Conditionals",
|
||||
"contactThrough": "Contact through:",
|
||||
"select": "Select",
|
||||
"date": "Date",
|
||||
"extendExpiry": "Extend expiry",
|
||||
"sendPWR": "Send Password Reset",
|
||||
"inviteMonths": "Months",
|
||||
"inviteDuration": "Invite Duration",
|
||||
"update": "Update",
|
||||
"user": "User",
|
||||
"userExpiryDescription": "A specified amount of time after each signup, jfa-go will delete/disable the account. You can change this behaviour in settings.",
|
||||
"templates": "Templates",
|
||||
"accessJFA": "Access jfa-go",
|
||||
"message": "Message",
|
||||
"reset": "Reset",
|
||||
"donate": "Donate",
|
||||
"sendPWRSuccessManual": "If the user hasn't received it, press copy to get a link to manually send to them.",
|
||||
"modifySettingsDescription": "Apply settings from an existing profile, or source them directly from a user.",
|
||||
"logs": "Logs",
|
||||
"sendPWRManual": "User {n} has no method of contact, press copy to get a link to send to them.",
|
||||
"sendPWRSuccess": "Password reset link sent.",
|
||||
"customizeMessages": "Customise Messages",
|
||||
"customizeMessagesDescription": "If you don't want to use jfa-go's message templates, you can create your own using Markdown.",
|
||||
"modifySettings": "Modify Settings",
|
||||
"sendDeleteNotificationEmail": "Send notification message",
|
||||
"sendDeleteNotifiationExample": "Your account has been deleted.",
|
||||
"settingsRestarting": "Restarting…",
|
||||
"settingsRestartRequired": "Restart needed",
|
||||
"settingsRefreshPage": "Refresh the page in a few seconds.",
|
||||
"settingsRequiredOrRestartMessage": "Note: {n} indicates a required field, {n} indicates changes require a restart.",
|
||||
"settingsSave": "Save",
|
||||
"userProfilesDescription": "Profiles are applied to users when they create an account. A profile include library access rights and homescreen layout.",
|
||||
"addProfileDescription": "Create a Jellyfin user and configure it, then select it below. When this profile is applied to an invite, new users will be created with the settings.",
|
||||
"addProfileStoreHomescreenLayout": "Store homescreen layout",
|
||||
"inviteNoUsersCreated": "None yet!",
|
||||
"inviteUsersCreated": "Created users",
|
||||
"inviteNoInvites": "None",
|
||||
"inviteExpiresInTime": "Expires in {n}",
|
||||
"notifyEvent": "Notify on:",
|
||||
"notifyInviteExpiry": "On expiry",
|
||||
"notifyUserCreation": "On user creation",
|
||||
"sendPIN": "Ask the user to send the PIN below to the bot.",
|
||||
"findDiscordUser": "Find Discord user",
|
||||
"linkMatrixDescription": "Enter the username and password of the user to use as a bot. Once submitted, the app will restart.",
|
||||
"matrixHomeServer": "Home server address",
|
||||
"saveAsTemplate": "Save as template",
|
||||
"deleteTemplate": "Delete template",
|
||||
"settingsRestartRequiredDescription": "A restart is necessary to apply some settings you changed. Restart now or later?",
|
||||
"settingsApplyRestartLater": "Apply, restart later",
|
||||
"subject": "Subject",
|
||||
"setExpiry": "Set expiry",
|
||||
"download": "Download",
|
||||
"search": "Search",
|
||||
"advancedSettings": "Advanced Settings",
|
||||
"lastActiveTime": "Last Active",
|
||||
"from": "From",
|
||||
"userExpiry": "User Expiry",
|
||||
"aboutProgram": "About",
|
||||
"version": "Version",
|
||||
"commitNoun": "Commit",
|
||||
"newUser": "New User",
|
||||
"profile": "Profile",
|
||||
"unknown": "Unknown",
|
||||
"label": "Label",
|
||||
"announce": "Announce",
|
||||
"sendPWRValidFor": "The link is valid for 30m.",
|
||||
"ombiUserDefaultsDescription": "Create an Ombi user and configure it, then select it below. It's settings/permissions will be stored and applied to new Ombi users created by jfa-go when this profile is selected.",
|
||||
"userProfilesIsDefault": "Default",
|
||||
"inviteNoProfile": "No Profile",
|
||||
"searchDiscordUser": "Start typing the Discord username to find the user.",
|
||||
"templateEnterName": "Enter a name to save this template.",
|
||||
"accessJFASettings": "Cannot be changed as either \"Admin Only\" or \"Allow All\" has been set in Settings > General."
|
||||
},
|
||||
"notifications": {
|
||||
"errorSettingsFailed": "Application failed.",
|
||||
"errorLoadSettings": "Failed to load settings.",
|
||||
"errorDeleteProfile": "Failed to delete profile {n}",
|
||||
"sentAnnouncement": "Announcement sent.",
|
||||
"savedAnnouncement": "Announcement saved.",
|
||||
"setOmbiProfile": "Stored ombi profile.",
|
||||
"updateApplied": "Update applied, please restart.",
|
||||
"updateAppliedRefresh": "Update applied, please refresh.",
|
||||
"telegramVerified": "Telegram account verified.",
|
||||
"accountConnected": "Account connected.",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.",
|
||||
"errorSaveEmail": "Failed to save email.",
|
||||
"errorLoadProfiles": "Failed to load profiles.",
|
||||
"errorCreateProfile": "Failed to create profile {n}",
|
||||
"errorLoadUsers": "Failed to load users.",
|
||||
"errorSetOmbiProfile": "Failed to store ombi profile.",
|
||||
"errorLoadOmbiUsers": "Failed to load ombi users.",
|
||||
"errorFailureCheckLogs": "Failed (check console/logs)",
|
||||
"errorPartialFailureCheckLogs": "Partial failure (check console/logs)",
|
||||
"errorUserCreated": "Failed to create user {n}.",
|
||||
"errorSendWelcomeEmail": "Failed to send welcome message (check console/logs)",
|
||||
"errorApplyUpdate": "Failed to apply update, try manually.",
|
||||
"errorCheckUpdate": "Failed to check for update.",
|
||||
"noUpdatesAvailable": "No new updates available.",
|
||||
"changedEmailAddress": "Changed email address of {n}.",
|
||||
"userCreated": "User {n} created.",
|
||||
"saveEmail": "Email saved.",
|
||||
"createProfile": "Created profile {n}.",
|
||||
"saveSettings": "Settings were saved",
|
||||
"errorHomescreenAppliedNoSettings": "Homescreen layout was applied, but applying settings may have failed.",
|
||||
"errorBlankFields": "Fields were left blank",
|
||||
"errorSetDefaultProfile": "Failed to set default profile.",
|
||||
"errorChangedEmailAddress": "Couldn't change email address of {n}.",
|
||||
"updateAvailable": "A new update is available, check settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@
|
||||
},
|
||||
"strings": {
|
||||
"invites": "Invites",
|
||||
"invite": "Invite",
|
||||
"accounts": "Accounts",
|
||||
"activity": "Activity",
|
||||
"settings": "Settings",
|
||||
"inviteMonths": "Months",
|
||||
"inviteDays": "Days",
|
||||
@@ -15,21 +17,11 @@
|
||||
"warning": "Warning",
|
||||
"inviteInfiniteUsesWarning": "invites with infinite uses can be used abusively",
|
||||
"inviteSendToEmail": "Send to",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"create": "Create",
|
||||
"apply": "Apply",
|
||||
"delete": "Delete",
|
||||
"add": "Add",
|
||||
"select": "Select",
|
||||
"name": "Name",
|
||||
"date": "Date",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"reEnable": "Re-enable",
|
||||
"setExpiry": "Set expiry",
|
||||
"disable": "Disable",
|
||||
"admin": "Admin",
|
||||
"updates": "Updates",
|
||||
"update": "Update",
|
||||
"download": "Download",
|
||||
@@ -37,8 +29,9 @@
|
||||
"advancedSettings": "Advanced Settings",
|
||||
"lastActiveTime": "Last Active",
|
||||
"from": "From",
|
||||
"after": "After",
|
||||
"before": "Before",
|
||||
"user": "User",
|
||||
"expiry": "Expiry",
|
||||
"userExpiry": "User Expiry",
|
||||
"userExpiryDescription": "A specified amount of time after each signup, jfa-go will delete/disable the account. You can change this behaviour in settings.",
|
||||
"aboutProgram": "About",
|
||||
@@ -48,6 +41,9 @@
|
||||
"profile": "Profile",
|
||||
"unknown": "Unknown",
|
||||
"label": "Label",
|
||||
"userLabel": "User Label",
|
||||
"userLabelDescription": "Label to apply to users created with this invite.",
|
||||
"logs": "Logs",
|
||||
"announce": "Announce",
|
||||
"templates": "Templates",
|
||||
"subject": "Subject",
|
||||
@@ -56,11 +52,20 @@
|
||||
"conditionals": "Conditionals",
|
||||
"preview": "Preview",
|
||||
"reset": "Reset",
|
||||
"edit": "Edit",
|
||||
"donate": "Donate",
|
||||
"unlink": "Unlink Account",
|
||||
"deleted": "Deleted",
|
||||
"disabled": "Disabled",
|
||||
"sendPWR": "Send Password Reset",
|
||||
"noResultsFound": "No Results Found",
|
||||
"noResultsFoundLocally": "Only loaded records were searched. You can load more, or perform the search over all records on the server.",
|
||||
"keepSearching": "Keep Searching",
|
||||
"keepSearchingDescription": "Only the current loaded activities were searched. Click below if you wish to search all activities.",
|
||||
"contactThrough": "Contact through:",
|
||||
"extendExpiry": "Extend expiry",
|
||||
"setExpiry": "Set expiry",
|
||||
"removeExpiry": "Remove expiry",
|
||||
"enterExpiry": "Enter an expiry",
|
||||
"sendPWRManual": "User {n} has no method of contact, press copy to get a link to send to them.",
|
||||
"sendPWRSuccess": "Password reset link sent.",
|
||||
"sendPWRSuccessManual": "If the user hasn't received it, press copy to get a link to manually send to them.",
|
||||
@@ -70,7 +75,16 @@
|
||||
"markdownSupported": "Markdown is supported.",
|
||||
"modifySettings": "Modify Settings",
|
||||
"modifySettingsDescription": "Apply settings from an existing profile, or source them directly from a user.",
|
||||
"enableReferrals": "Enable Referrals",
|
||||
"disableReferrals": "Disable Referrals",
|
||||
"enableReferralsDescription": "Give users a personal referral link similiar to an invite, to send to friends/family. Can be sourced from a referral template in a profile, or from an existing invite.",
|
||||
"enableReferralsProfileDescription": "Give users created with this profile a personal referral link similiar to an invite, to send to friends/family. Create an invite with the desired settings, then select it here. Each referral will then be based on this invite. You can delete the invite once complete.",
|
||||
"useInviteExpiry": "Set expiry from profile/invite",
|
||||
"useInviteExpiryNote": "By default, invites expire after 90 days but can be renewed by the user. Enable for the referral to be disabled after the time set.",
|
||||
"applyHomescreenLayout": "Apply homescreen layout",
|
||||
"applyConfigurationAndPolicy": "Apply Jellyfin configuration/policy",
|
||||
"applyOmbi": "Apply Ombi profile (if available)",
|
||||
"applyJellyseerr": "Apply Jellyseerr profile (if available)",
|
||||
"sendDeleteNotificationEmail": "Send notification message",
|
||||
"sendDeleteNotifiationExample": "Your account has been deleted.",
|
||||
"settingsRestart": "Restart",
|
||||
@@ -83,10 +97,16 @@
|
||||
"settingsRefreshPage": "Refresh the page in a few seconds.",
|
||||
"settingsRequiredOrRestartMessage": "Note: {n} indicates a required field, {n} indicates changes require a restart.",
|
||||
"settingsSave": "Save",
|
||||
"settingsHiddenDependency": "Matching settings are hidden because they depend on the value of another setting:",
|
||||
"settingsDependsOn": "{setting}: Depends on {dependency}",
|
||||
"settingsAdvancedMode": "{setting}: Advanced Settings must be enabled",
|
||||
"settingsMaybeUnderAdvanced": "Tip: You might find what you're looking for by enabling Advanced Settings.",
|
||||
"ombiProfile": "Ombi user profile",
|
||||
"ombiUserDefaultsDescription": "Create an Ombi user and configure it, then select it below. It's settings/permissions will be stored and applied to new Ombi users created by jfa-go when this profile is selected.",
|
||||
"jellyseerrProfile": "Jellyseerr user profile",
|
||||
"jellyseerrUserDefaultsDescription": "Create a Jellyseerr user and configure it, then select it below. It's settings/permissions will be stored and applied to new Jellyseerr users created by jfa-go when this profile is selected.",
|
||||
"userProfiles": "User Profiles",
|
||||
"userProfilesDescription": "Profiles are applied to users when they create an account. A profile include library access rights and homescreen layout.",
|
||||
"userProfilesDescription": "Profiles are applied to users when they create an account. A profile includes library access rights and homescreen layout.",
|
||||
"userProfilesIsDefault": "Default",
|
||||
"userProfilesLibraries": "Libraries",
|
||||
"addProfile": "Add Profile",
|
||||
@@ -97,7 +117,6 @@
|
||||
"inviteUsersCreated": "Created users",
|
||||
"inviteNoProfile": "No Profile",
|
||||
"inviteDateCreated": "Created",
|
||||
"inviteRemainingUses": "Remaining uses",
|
||||
"inviteNoInvites": "None",
|
||||
"inviteExpiresInTime": "Expires in {n}",
|
||||
"notifyEvent": "Notify on:",
|
||||
@@ -110,9 +129,88 @@
|
||||
"matrixHomeServer": "Home server address",
|
||||
"saveAsTemplate": "Save as template",
|
||||
"deleteTemplate": "Delete template",
|
||||
"templateEnterName": "Enter a name to save this template."
|
||||
"templateEnterName": "Enter a name to save this template.",
|
||||
"accessJFA": "Access jfa-go",
|
||||
"accessJFASettings": "Cannot be changed as either \"Admin Only\" or \"Allow All\" has been set in Settings > General.",
|
||||
"sortingBy": "Sorting By",
|
||||
"sortDirection": "Sort Direction",
|
||||
"filters": "Filters",
|
||||
"clickToRemoveFilter": "Click to remove this filter.",
|
||||
"clearSearch": "Clear search",
|
||||
"searchAll": "Search/sort all",
|
||||
"searchAllRecords": "Search/sort all records (on server)",
|
||||
"actions": "Actions",
|
||||
"searchOptions": "Search Options",
|
||||
"matchText": "Match Text",
|
||||
"jellyfinID": "Jellyfin ID",
|
||||
"userPageLogin": "User Page: Login",
|
||||
"userPagePage": "User Page: Page",
|
||||
"postSignupCard": "Post-signup help card",
|
||||
"postSignupCardDescription": "Card shown to user after signing up. Overrides \"Success Message\". Overriden by \"Auto redirect on success\" setting.",
|
||||
"buildTime": "Build Time",
|
||||
"builtBy": "Built By",
|
||||
"buildTags": "Build Tags",
|
||||
"loginNotAdmin": "Not an Admin?",
|
||||
"referrer": "Referrer",
|
||||
"accountLinked": "{contactMethod} linked: {user}",
|
||||
"accountUnlinked": "{contactMethod} removed: {user}",
|
||||
"accountResetPassword": "{user} reset their password",
|
||||
"accountChangedPassword": "{user} changed their password",
|
||||
"accountCreated": "Account created: {user}",
|
||||
"accountDeleted": "Account deleted: {user}",
|
||||
"accountDisabled": "Account disabled: {user}",
|
||||
"accountReEnabled": "Account re-enabled: {user}",
|
||||
"accountExpired": "Account expired: {user}",
|
||||
"accountWillExpire": "Account will expire on {date}.",
|
||||
"expirationBasedOn": "Given date based on 1st user.",
|
||||
"userDeleted": "User was deleted.",
|
||||
"userDisabled": "User was disabled",
|
||||
"inviteCreated": "Invite created: {invite}",
|
||||
"inviteDeleted": "Invite deleted: {invite}",
|
||||
"inviteExpired": "Invite expired: {invite}",
|
||||
"fromInvite": "From Invite",
|
||||
"byAdmin": "By Admin",
|
||||
"byUser": "By User",
|
||||
"byJfaGo": "By jfa-go",
|
||||
"activityID": "Activity ID",
|
||||
"title": "Title",
|
||||
"usersMentioned": "User mentioned",
|
||||
"actor": "Actor",
|
||||
"actorDescription": "The thing that caused this action. \"user\"/\"admin\"/\"daemon\" or a username.",
|
||||
"accountCreationFilter": "Account Creation",
|
||||
"accountDeletionFilter": "Account Deletion",
|
||||
"accountDisabledFilter": "Account Disabled",
|
||||
"accountEnabledFilter": "Account Enabled",
|
||||
"contactLinkedFilter": "Contact Linked",
|
||||
"contactUnlinkedFilter": "Contact Unlinked",
|
||||
"passwordChangeFilter": "Password Changed",
|
||||
"passwordResetFilter": "Password Reset",
|
||||
"inviteCreatedFilter": "Invite Created",
|
||||
"inviteDeletedFilter": "Invite Deleted/Expired",
|
||||
"loadMore": "Load More",
|
||||
"loadAll": "Load All",
|
||||
"noMoreResults": "No more results.",
|
||||
"totalRecords": "{n} Total Records",
|
||||
"loadedRecords": "{n} Loaded",
|
||||
"shownRecords": "{n} Shown",
|
||||
"selectedRecords": "{n} Selected",
|
||||
"backups": "Backups",
|
||||
"backupsDescription": "Backups of the database can be made, restored, or downloaded from here.",
|
||||
"backupsFormatNote": "Only backup files with the standard name format will be shown here. To use any other, upload the backup manually.",
|
||||
"backupsCopy": "When applying a backup, a copy of the original \"db\" folder will be made next to it, in case anything goes wrong.",
|
||||
"backupDownloadRestore": "Download / Restore",
|
||||
"backupUpload": "Upload & Restore Backup",
|
||||
"backupDownload": "Download Backup",
|
||||
"backupRestore": "Restore Backup",
|
||||
"backupNow": "Backup Now",
|
||||
"backupCreated": "Backup created",
|
||||
"backupCanBeFound": "The backup can be found on the server at {filepath}.",
|
||||
"backupCanDownload": "Alternatively, click below to download the backup.",
|
||||
"wikiPage": "Wiki Page",
|
||||
"wiki": "Wiki"
|
||||
},
|
||||
"notifications": {
|
||||
"pathCopied": "Full path copied to clipboard.",
|
||||
"changedEmailAddress": "Changed email address of {n}.",
|
||||
"userCreated": "User {n} created.",
|
||||
"createProfile": "Created profile {n}.",
|
||||
@@ -121,25 +219,26 @@
|
||||
"sentAnnouncement": "Announcement sent.",
|
||||
"savedAnnouncement": "Announcement saved.",
|
||||
"setOmbiProfile": "Stored ombi profile.",
|
||||
"savedProfile": "Stored profile changes.",
|
||||
"updateApplied": "Update applied, please restart.",
|
||||
"updateAppliedRefresh": "Update applied, please refresh.",
|
||||
"telegramVerified": "Telegram account verified.",
|
||||
"accountConnected": "Account connected.",
|
||||
"errorConnection": "Couldn't connect to jfa-go.",
|
||||
"error401Unauthorized": "Unauthorized. Try refreshing the page.",
|
||||
"referralsEnabled": "Referrals enabled.",
|
||||
"activityDeleted": "Activity Deleted.",
|
||||
"errorInviteNoLongerExists": "Invite no longer exists.",
|
||||
"errorInviteNotFound": "Invite not found.",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.",
|
||||
"errorHomescreenAppliedNoSettings": "Homescreen layout was applied, but applying settings may have failed.",
|
||||
"errorSettingsFailed": "Application failed.",
|
||||
"errorLoginBlank": "The username and/or password were left blank.",
|
||||
"errorUnknown": "Unknown error.",
|
||||
"errorSaveEmail": "Failed to save email.",
|
||||
"errorBlankFields": "Fields were left blank",
|
||||
"errorDeleteProfile": "Failed to delete profile {n}",
|
||||
"errorLoadProfiles": "Failed to load profiles.",
|
||||
"errorCreateProfile": "Failed to create profile {n}",
|
||||
"errorSavedProfile": "Failed to save profile {n}",
|
||||
"errorSetDefaultProfile": "Failed to set default profile.",
|
||||
"errorLoadUsers": "Failed to load users.",
|
||||
"errorSaveSettings": "Couldn't save settings.",
|
||||
"errorLoadSettings": "Failed to load settings.",
|
||||
"errorSetOmbiProfile": "Failed to store ombi profile.",
|
||||
"errorLoadOmbiUsers": "Failed to load ombi users.",
|
||||
@@ -150,6 +249,9 @@
|
||||
"errorSendWelcomeEmail": "Failed to send welcome message (check console/logs)",
|
||||
"errorApplyUpdate": "Failed to apply update, try manually.",
|
||||
"errorCheckUpdate": "Failed to check for update.",
|
||||
"errorNoReferralTemplate": "Profile doesn't contain referral template, add one in settings.",
|
||||
"errorLoadActivities": "Failed to load activities.",
|
||||
"errorInvalidDate": "Date is invalid.",
|
||||
"updateAvailable": "A new update is available, check settings.",
|
||||
"noUpdatesAvailable": "No new updates available."
|
||||
},
|
||||
@@ -158,6 +260,10 @@
|
||||
"singular": "Modify Settings for {n} user",
|
||||
"plural": "Modify Settings for {n} users"
|
||||
},
|
||||
"enableReferralsFor": {
|
||||
"singular": "Enable Referrals for {n} user",
|
||||
"plural": "Enable Referrals for {n} users"
|
||||
},
|
||||
"deleteNUsers": {
|
||||
"singular": "Delete {n} user",
|
||||
"plural": "Delete {n} users"
|
||||
@@ -202,6 +308,10 @@
|
||||
"singular": "Extend expiry for {n} user",
|
||||
"plural": "Extend expiry for {n} users"
|
||||
},
|
||||
"setExpiry": {
|
||||
"singular": "Set expiry for {n} user",
|
||||
"plural": "Set expiry for {n} users"
|
||||
},
|
||||
"extendedExpiry": {
|
||||
"singular": "Extended expiry for {n} user.",
|
||||
"plural": "Extended expiry for {n} users."
|
||||
|
||||
@@ -15,18 +15,10 @@
|
||||
"warning": "Advertencia",
|
||||
"inviteInfiniteUsesWarning": "Las invitaciones con usos infinitos se pueden usar de forma abusiva",
|
||||
"inviteSendToEmail": "Enviar a",
|
||||
"login": "Acceso",
|
||||
"logout": "Cerrar sesión",
|
||||
"create": "Crear",
|
||||
"apply": "Aplicar",
|
||||
"delete": "Eliminar",
|
||||
"name": "Nombre",
|
||||
"date": "Fecha",
|
||||
"enabled": "Activado",
|
||||
"disabled": "Desactivado",
|
||||
"reEnable": "Reactivar",
|
||||
"disable": "Desactivar",
|
||||
"admin": "Administrador",
|
||||
"updates": "Actualizaciones",
|
||||
"update": "Actualizar",
|
||||
"download": "Descargar",
|
||||
@@ -35,7 +27,6 @@
|
||||
"lastActiveTime": "Último activo",
|
||||
"from": "De",
|
||||
"user": "Usuario",
|
||||
"expiry": "Expiración",
|
||||
"userExpiry": "Caducidad del usuario",
|
||||
"userExpiryDescription": "Una cantidad de tiempo específica después de cada registro, jfa-go eliminará / deshabilitará la cuenta. Puede cambiar este comportamiento en la configuración.",
|
||||
"aboutProgram": "Acerca de",
|
||||
@@ -51,7 +42,6 @@
|
||||
"variables": "Variables",
|
||||
"preview": "Vista previa",
|
||||
"reset": "Reiniciar",
|
||||
"edit": "Editar",
|
||||
"extendExpiry": "Extender el vencimiento",
|
||||
"customizeMessages": "Personalizar mensajes",
|
||||
"customizeMessagesDescription": "Si no desea utilizar las plantillas de mensajes de jfa-go, puede crear las suyas con Markdown.",
|
||||
@@ -85,7 +75,6 @@
|
||||
"inviteUsersCreated": "Usuarios creados",
|
||||
"inviteNoProfile": "Sin perfil",
|
||||
"inviteDateCreated": "Creado",
|
||||
"inviteRemainingUses": "Usos restantes",
|
||||
"inviteNoInvites": "Ninguno",
|
||||
"inviteExpiresInTime": "Caduca en {n}",
|
||||
"notifyEvent": "Notificar en:",
|
||||
@@ -93,7 +82,6 @@
|
||||
"notifyUserCreation": "Sobre la creación de usuarios",
|
||||
"conditionals": "Condicionales",
|
||||
"donate": "Donar",
|
||||
"add": "Agregar",
|
||||
"templates": "Plantillas",
|
||||
"contactThrough": "Contactar a través de:",
|
||||
"select": "Seleccionar",
|
||||
@@ -111,7 +99,44 @@
|
||||
"sendPWRSuccessManual": "Si el usuario no lo ha recibido, presione copiar para generar el enlace y enviárselo manualmente.",
|
||||
"sendPWRValidFor": "El enlace es válido por 30m.",
|
||||
"sendPWRManual": "El usuario {n} no tiene ningún método de contacto, presione copiar para generar el enlace para enviarle.",
|
||||
"ombiProfile": "Perfil de usuario de Ombi"
|
||||
"ombiProfile": "Perfil de usuario de Ombi",
|
||||
"logs": "Registros",
|
||||
"accessJFA": "Acceso",
|
||||
"accessJFASettings": "No se puede cambia, ya que se ha establecido \"Solo administradores\" o \"Permitir a todos\" en Configuración > General.",
|
||||
"buildTime": "Tiempo de construcción",
|
||||
"builtBy": "Construido por",
|
||||
"sortingBy": "Ordenar por",
|
||||
"filters": "Filtros",
|
||||
"clearSearch": "Borrar búsqueda",
|
||||
"searchOptions": "Opciones de búsqueda",
|
||||
"matchText": "Coincidir texto",
|
||||
"jellyfinID": "Jellyfin ID",
|
||||
"userPageLogin": "Página de usuario: Iniciar sesión",
|
||||
"userPagePage": "Página de usuario: Página",
|
||||
"after": "Después",
|
||||
"before": "Antes",
|
||||
"unlink": "Desvincular cuenta",
|
||||
"clickToRemoveFilter": "Haga clic para eliminar el filtro.",
|
||||
"removeExpiry": "Eliminar caducidad",
|
||||
"enterExpiry": "Introduzca una caducidad",
|
||||
"useInviteExpiry": "Establecer caducidad desde el perfil/invitación",
|
||||
"noResultsFound": "Ningún resultado encontrado",
|
||||
"settingsDependsOn": "{setting}: Depende de {dependency}",
|
||||
"activity": "Actividad",
|
||||
"disabled": "Desactivado",
|
||||
"deleted": "Eliminado",
|
||||
"keepSearching": "Seguir buscando",
|
||||
"keepSearchingDescription": "Solo se ha buscado en las actividades cargadas actualmente. Clique a continuación si quiere buscar en todas las actividades.",
|
||||
"settingsAdvancedMode": "{setting}: Los ajustes avanzados deben estar habilitados",
|
||||
"settingsMaybeUnderAdvanced": "Consejo: Puede que encuentre lo que busca si habilita los Ajustes avanzados.",
|
||||
"invite": "Invitar",
|
||||
"userLabel": "Etiqueta de usuario",
|
||||
"userLabelDescription": "Etiqueta que aplicar a usuarios creados con esta invitación.",
|
||||
"enableReferrals": "Habilitar referencias",
|
||||
"disableReferrals": "Deshabilitar referencias",
|
||||
"enableReferralsDescription": "Proporciona a los usuarios un enlace personal de referencia, parecido a una invitación, para que lo compartan con amigos y familiares. Puede conseguirse a través de una plantilla de referencia en un perfil, o a través de una invitación existente.",
|
||||
"enableReferralsProfileDescription": "Proporciona a los usuarios creados con este perfil un enlace personal de referencia, parecido a una invitación, para que lo compartan con amigos y familiares. Cree una invitación con los ajustes deseados y selecciónela aquí. Cada referencia se basará en esta invitación. Puede eliminar la invitación una vez completado.",
|
||||
"useInviteExpiryNote": "Por defecto las invitaciones caducan a los 90 días, pero pueden ser renovadas por el usuario. Habilite que la referencia sea desactivada cuando pase el tiempo establecido."
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "Se cambió la dirección de correo electrónico de {n}.",
|
||||
@@ -122,13 +147,9 @@
|
||||
"sentAnnouncement": "Anuncio enviado.",
|
||||
"setOmbiDefaults": "Valores predeterminados de ombi almacenados.",
|
||||
"updateApplied": "Actualización aplicada, por favor reinicie.",
|
||||
"errorConnection": "No se pudo conectar a jfa-go.",
|
||||
"error401Unauthorized": "No autorizado. Intente actualizar la página.",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "Se aplicó la configuración, pero es posible que no se haya aplicado el diseño de la pantalla de inicio.",
|
||||
"errorHomescreenAppliedNoSettings": "Se aplicó el diseño de la pantalla de inicio, pero es posible que la aplicación de la configuración haya fallado.",
|
||||
"errorSettingsFailed": "La aplicación falló.",
|
||||
"errorLoginBlank": "El nombre de usuario y/o la contraseña se dejaron en blanco.",
|
||||
"errorUnknown": "Error desconocido.",
|
||||
"errorSaveEmail": "No se pudo guardar el correo electrónico.",
|
||||
"errorBlankFields": "Los campos se dejaron en blanco",
|
||||
"errorDeleteProfile": "No se pudo borrar el perfil {n}",
|
||||
@@ -136,7 +157,6 @@
|
||||
"errorCreateProfile": "No se pudo crear el perfil {n}",
|
||||
"errorSetDefaultProfile": "No se pudo establecer el perfil predeterminado.",
|
||||
"errorLoadUsers": "No se pudieron cargar los usuarios.",
|
||||
"errorSaveSettings": "No se pudo guardar la configuración.",
|
||||
"errorLoadSettings": "No se pudo cargar la configuración.",
|
||||
"errorSetOmbiDefaults": "No se pudieron almacenar los valores predeterminados de ombi.",
|
||||
"errorLoadOmbiUsers": "No se pudieron cargar los usuarios de Ombi.",
|
||||
@@ -208,6 +228,10 @@
|
||||
"extendedExpiry": {
|
||||
"singular": "Caducidad extendida para {n} usuario.",
|
||||
"plural": "Caducidad extendida para {n} usuarios."
|
||||
},
|
||||
"setExpiry": {
|
||||
"singular": "Fijar la caducidad del usuario {n}",
|
||||
"plural": "Establecer la caducidad para {n} usuarios"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,8 @@
|
||||
"warning": "Attention",
|
||||
"inviteInfiniteUsesWarning": "les invitations infinies peuvent être utilisées abusivement",
|
||||
"inviteSendToEmail": "Envoyer à",
|
||||
"login": "S'identifier",
|
||||
"logout": "Se déconnecter",
|
||||
"create": "Créer",
|
||||
"apply": "Appliquer",
|
||||
"delete": "Effacer",
|
||||
"name": "Nom",
|
||||
"date": "Date",
|
||||
"lastActiveTime": "Dernière activité",
|
||||
@@ -55,16 +52,15 @@
|
||||
"addProfileNameOf": "Nom de profil",
|
||||
"addProfileStoreHomescreenLayout": "Enregistrer la disposition de l'écran d'accueil",
|
||||
"inviteNoUsersCreated": "Aucun pour l'instant !",
|
||||
"inviteUsersCreated": "Utilisateurs créer",
|
||||
"inviteUsersCreated": "Utilisateurs créés",
|
||||
"inviteNoProfile": "Aucun profil",
|
||||
"inviteDateCreated": "Créer",
|
||||
"inviteRemainingUses": "Utilisations restantes",
|
||||
"inviteNoInvites": "Aucune",
|
||||
"inviteExpiresInTime": "Expires dans {n}",
|
||||
"notifyEvent": "Notifier sur :",
|
||||
"notifyInviteExpiry": "À l'expiration",
|
||||
"notifyUserCreation": "à la création de l'utilisateur",
|
||||
"label": "Etiquette",
|
||||
"label": "Nom",
|
||||
"settingsRestarting": "Redémarrage…",
|
||||
"settingsRestart": "Redémarrer",
|
||||
"announce": "Annoncer",
|
||||
@@ -75,15 +71,8 @@
|
||||
"variables": "Variables",
|
||||
"preview": "Aperçu",
|
||||
"reset": "Réinitialisation",
|
||||
"edit": "Éditer",
|
||||
"customizeMessages": "Personnaliser les e-mails",
|
||||
"inviteDuration": "Durée de l'invitation",
|
||||
"enabled": "Activé",
|
||||
"disabled": "Désactivé",
|
||||
"reEnable": "Ré-activé",
|
||||
"disable": "Désactivé",
|
||||
"admin": "Administrateur",
|
||||
"expiry": "Expiration",
|
||||
"advancedSettings": "Paramètres avancés",
|
||||
"userExpiry": "Expiration de l'utilisateur",
|
||||
"updates": "Mises à jour",
|
||||
@@ -96,7 +85,6 @@
|
||||
"extendExpiry": "Prolonger l'expiration",
|
||||
"contactThrough": "Contacté par :",
|
||||
"sendPIN": "Demandez à l'utilisateur d'envoyer le code PIN ci-dessous au bot.",
|
||||
"add": "Ajouter",
|
||||
"select": "Sélectionner",
|
||||
"findDiscordUser": "Trouver l'utilisateur Discord",
|
||||
"linkMatrixDescription": "Entrez le nom d'utilisateur et le mot de passe de l'utilisateur pour l’utilisateur comme bot. Une fois soumis, l'application va redémarrer.",
|
||||
@@ -112,7 +100,103 @@
|
||||
"sendPWRValidFor": "Ce lien est valable 30min.",
|
||||
"sendPWRManual": "L'utilisateur {n} n'a pas indiqué de méthode de contact, appuyez sur copier pour recevoir un lien à lui envoyer.",
|
||||
"sendPWRSuccessManual": "Si l'utilisateur ne l'a pas reçu, appuyez sur copier pour recevoir un lien à lui envoyer manuellement.",
|
||||
"ombiProfile": "Profil d'utilisateur Ombi"
|
||||
"ombiProfile": "Profil d'utilisateur Ombi",
|
||||
"logs": "Logs",
|
||||
"accessJFA": "Accès à jfa-go",
|
||||
"accessJFASettings": "Ne peut pas être changé car \"Admin Only\" ou \"Allow All\" a été défini dans Paramètres > Général.",
|
||||
"buildTime": "Heure de la version",
|
||||
"builtBy": "Version créée par",
|
||||
"sortingBy": "Trier par",
|
||||
"filters": "Filtres",
|
||||
"clickToRemoveFilter": "Cliquer pour supprimer ce filtre.",
|
||||
"clearSearch": "Réinitialiser la recherche",
|
||||
"actions": "Actions",
|
||||
"searchOptions": "Recherche avancée",
|
||||
"matchText": "Texte correspondant",
|
||||
"jellyfinID": "ID Jellyfin",
|
||||
"userPageLogin": "Page utilisateur : Connexion",
|
||||
"userPagePage": "Page utilisateur : Page",
|
||||
"after": "Après",
|
||||
"before": "Avant",
|
||||
"unlink": "Délier le compte",
|
||||
"enableReferrals": "Activer Parrainage",
|
||||
"enableReferralsDescription": "Offrez aux utilisateurs un lien de parrainage personnel semblable à une invitation, à envoyer à vos amis/famille. Peut provenir modèle de profil ou d’une invitation existante.",
|
||||
"invite": "Inviter",
|
||||
"userLabel": "Étiquette",
|
||||
"userLabelDescription": "Étiquette à appliquer aux utilisateurs créés avec cette invitation.",
|
||||
"disableReferrals": "Désactiver Parrainage",
|
||||
"enableReferralsProfileDescription": "Donnez aux utilisateurs créés avec ce profil un lien de parrainage personnel semblable à une invitation, à envoyer à vos amis/famille. Créez une invitation avec les paramètres souhaités, puis sélectionnez-la ici. Chaque référence sera alors basée sur cette invitation. Vous pouvez supprimer l'invitation une fois terminée.",
|
||||
"loginNotAdmin": "Vous n'êtes pas administrateur ?",
|
||||
"removeExpiry": "Supprimer l'expiration",
|
||||
"enterExpiry": "Entrez une date d'expiration",
|
||||
"useInviteExpiry": "Définir l'expiration du profil/invitation",
|
||||
"sortDirection": "Ordre de trie",
|
||||
"referrer": "Référence",
|
||||
"accountLinked": "{contactMethod} lié : {user}",
|
||||
"accountUnlinked": "{contactMethod} supprimé : {user}",
|
||||
"accountResetPassword": "{user} réinitialise son mot de passe",
|
||||
"expirationBasedOn": "Date donnée basée sur le 1er utilisateur.",
|
||||
"accountDeleted": "Compte supprimé : {user}",
|
||||
"accountChangedPassword": "{user} a changé son mot de passe",
|
||||
"accountCreated": "Compte créé : {user}",
|
||||
"accountDisabled": "Compte désactivé : {user}",
|
||||
"accountReEnabled": "Compte réactivé : {user}",
|
||||
"accountExpired": "Compte expiré : {user}",
|
||||
"accountWillExpire": "Le compte expirera le {date}.",
|
||||
"backups": "Sauvegardes",
|
||||
"backupsDescription": "Des sauvegardes de la base de données peuvent être effectuées, restaurées ou téléchargées à partir d'ici.",
|
||||
"backupsCopy": "Lors de l'application d'une sauvegarde, une copie du dossier \"db\" d'origine sera créée à côté, en cas de problème.",
|
||||
"backupDownloadRestore": "Télécharger/Restaurer",
|
||||
"backupUpload": "Télécharger et restaurer la sauvegarde",
|
||||
"backupDownload": "Télécharger la sauvegarde",
|
||||
"backupRestore": "Restaurer la sauvegarde",
|
||||
"backupNow": "Sauvegarder maintenant",
|
||||
"backupCreated": "Sauvegarde créée",
|
||||
"backupCanDownload": "Vous pouvez également cliquer ci-dessous pour télécharger la sauvegarde.",
|
||||
"wikiPage": "Wiki page",
|
||||
"activity": "Activité",
|
||||
"deleted": "Supprimé",
|
||||
"disabled": "Désactivé",
|
||||
"keepSearching": "Continuer la recherche",
|
||||
"keepSearchingDescription": "Seules les activités actuellement chargées ont été recherchées. Cliquez ci-dessous si vous souhaitez rechercher toutes les activités.",
|
||||
"settingsHiddenDependency": "Les paramètres correspondants sont masqués car ils dépendent de la valeur d'un autre paramètre :",
|
||||
"settingsDependsOn": "{setting} : dépend de {dependency}",
|
||||
"settingsMaybeUnderAdvanced": "Astuce : Vous trouverez peut-être ce que vous cherchez en activant les paramètres avancés.",
|
||||
"settingsAdvancedMode": "{setting} : les paramètres avancés doivent être activés",
|
||||
"actorDescription": "La chose qui a provoqué cette action. \"user\"/\"admin\"/\"service\" ou un nom d'utilisateur.",
|
||||
"activityID": "ID d'activité",
|
||||
"byUser": "Par Utilisateur",
|
||||
"inviteExpired": "Invitation expirée : {invite}",
|
||||
"byJfaGo": "Par jfa-go",
|
||||
"accountDisabledFilter": "Compte désactivé",
|
||||
"inviteCreated": "Invitation créée : {invite}",
|
||||
"inviteDeleted": "Invitation supprimée : {invite}",
|
||||
"fromInvite": "À partir de l'invitation",
|
||||
"accountDeletionFilter": "Suppression de compte",
|
||||
"userDeleted": "L'utilisateur a été supprimé.",
|
||||
"userDisabled": "L'utilisateur a été désactivé",
|
||||
"accountCreationFilter": "Création de compte",
|
||||
"title": "Titre",
|
||||
"usersMentioned": "Utilisateur mentionné",
|
||||
"actor": "Acteur",
|
||||
"byAdmin": "Par Administrateur",
|
||||
"passwordResetFilter": "Réinitialisation du mot de passe",
|
||||
"loadMore": "Charger plus",
|
||||
"accountEnabledFilter": "Compte activé",
|
||||
"inviteCreatedFilter": "Invitation crée",
|
||||
"inviteDeletedFilter": "Invitation supprimée/expirée",
|
||||
"noMoreResults": "Plus de résultats.",
|
||||
"totalRecords": "{n} Nombre total d'enregistrements",
|
||||
"passwordChangeFilter": "Mot de passe changé",
|
||||
"loadedRecords": "{n} Chargé",
|
||||
"shownRecords": "{n} affiché",
|
||||
"contactUnlinkedFilter": "Contact sans lien",
|
||||
"contactLinkedFilter": "Contact lié",
|
||||
"loadAll": "Tout charger",
|
||||
"noResultsFound": "Aucun résultat trouvé",
|
||||
"useInviteExpiryNote": "Par défaut, les invitations expirent après 90 jours mais peuvent être renouvelées par l'utilisateur. Activez la désactivation de la référence après le délai défini.",
|
||||
"backupsFormatNote": "Seuls les fichiers de sauvegarde au format standard seront affichés ici. Pour en utiliser un autre, veuillez charger la sauvegarde manuellement.",
|
||||
"backupCanBeFound": "La sauvegarde peut être trouvée sur le serveur à {filepath}."
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "Adresse e-mail modifiée de {n}.",
|
||||
@@ -120,20 +204,15 @@
|
||||
"createProfile": "Profil créé {n}.",
|
||||
"saveSettings": "Les paramètres ont été enregistrés",
|
||||
"setOmbiDefaults": "Valeurs par défaut de Ombi.",
|
||||
"errorConnection": "Impossible de se connecter à jfa-go.",
|
||||
"error401Unauthorized": "Non autorisé. Essayez d'actualiser la page.",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "Les paramètres ont été appliqués, mais l'application de la disposition de l'écran d'accueil a peut-être échoué.",
|
||||
"errorHomescreenAppliedNoSettings": "La disposition de l'écran d'accueil a été appliquée, mais l'application des paramètres a peut-être échoué.",
|
||||
"errorSettingsFailed": "L'application a échoué.",
|
||||
"errorLoginBlank": "Le nom d'utilisateur et/ou le mot de passe sont vides.",
|
||||
"errorUnknown": "Erreur inconnue.",
|
||||
"errorBlankFields": "Les champs sont vides",
|
||||
"errorDeleteProfile": "Échec de la suppression du profil {n}",
|
||||
"errorLoadProfiles": "Échec du chargement des profils.",
|
||||
"errorCreateProfile": "Échec de la création du profil {n}",
|
||||
"errorSetDefaultProfile": "Échec de la définition du profil par défaut.",
|
||||
"errorLoadUsers": "Échec du chargement des utilisateurs.",
|
||||
"errorSaveSettings": "Impossible d'enregistrer les paramètres.",
|
||||
"errorLoadSettings": "Échec du chargement des paramètres.",
|
||||
"errorSetOmbiDefaults": "Impossible de stocker les valeurs par défaut d'Ombi.",
|
||||
"errorLoadOmbiUsers": "Échec du chargement des utilisateurs Ombi.",
|
||||
@@ -155,7 +234,15 @@
|
||||
"accountConnected": "Compte connecté.",
|
||||
"savedAnnouncement": "Annonce enregistrée.",
|
||||
"setOmbiProfile": "Profil ombi enregistré.",
|
||||
"errorSetOmbiProfile": "Echec de la sauvegarde du profil ombi."
|
||||
"errorSetOmbiProfile": "Echec de la sauvegarde du profil ombi.",
|
||||
"errorNoReferralTemplate": "Le profil ne contient pas de modèle de référence, ajoutez-en un dans les paramètres.",
|
||||
"referralsEnabled": "Parrainage activer.",
|
||||
"errorLoadActivities": "Échec du chargement des activités.",
|
||||
"pathCopied": "Chemin complet copié dans le presse-papiers.",
|
||||
"activityDeleted": "Activité supprimée.",
|
||||
"errorInviteNoLongerExists": "L'invitation n'existe plus.",
|
||||
"errorInviteNotFound": "Invitation introuvable.",
|
||||
"errorInvalidDate": "La date n'est pas valide."
|
||||
},
|
||||
"quantityStrings": {
|
||||
"modifySettingsFor": {
|
||||
@@ -209,6 +296,14 @@
|
||||
"disabledUser": {
|
||||
"singular": "{n} utilisateur désactivé.",
|
||||
"plural": "{n} utilisateurs désactivés."
|
||||
},
|
||||
"setExpiry": {
|
||||
"singular": "Définir l'expiration pour {n} utilisateur",
|
||||
"plural": "Définir l'expiration pour {n} utilisateurs"
|
||||
},
|
||||
"enableReferralsFor": {
|
||||
"singular": "Activer les parrainages pour {n} utilisateur",
|
||||
"plural": "Activer les parrainages pour {n} utilisateur"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||