Compare commits
446 Commits
accounts-l
...
jellyseerr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -18,6 +18,8 @@ steps:
|
||||
from_secret: BUILDRONE_KEY
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
JFA_GO_BUILT_BY:
|
||||
from_secret: BUILT_BY
|
||||
commands:
|
||||
- curl -sL https://git.io/goreleaser > ../goreleaser
|
||||
- chmod +x ../goreleaser
|
||||
@@ -26,6 +28,7 @@ steps:
|
||||
- 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 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "rm /repo/incoming/*.deb"'
|
||||
- bash -c 'python3 ../upload.py https://builds.hrfee.pw hrfee jfa-go --tag internal=true'
|
||||
volumes:
|
||||
- name: ssh_key
|
||||
@@ -42,9 +45,9 @@ type: docker
|
||||
steps:
|
||||
- name: build-deploy
|
||||
image: appleboy/drone-ssh
|
||||
volumes:
|
||||
- name: ssh_key
|
||||
path: /root/drone_rsa
|
||||
environment:
|
||||
BUILDRONE_KEY:
|
||||
from_secret: BUILDRONE_KEY
|
||||
settings:
|
||||
host:
|
||||
from_secret: ssh2_host
|
||||
@@ -52,23 +55,20 @@ steps:
|
||||
from_secret: ssh2_username
|
||||
port:
|
||||
from_secret: ssh2_port
|
||||
volumes:
|
||||
- /root/.ssh/docker-build:/root/drone_rsa
|
||||
key_path: /root/drone_rsa
|
||||
envs:
|
||||
- buildrone_key
|
||||
key:
|
||||
from_secret: ssh2_key
|
||||
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'
|
||||
- bash -c 'cd /mnt/buildx/jfa-go/jfa-go && 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
|
||||
@@ -85,7 +85,7 @@ steps:
|
||||
commands:
|
||||
- curl -sL https://git.io/goreleaser > goreleaser
|
||||
- chmod +x goreleaser
|
||||
- ./scripts/version.sh ./goreleaser --snapshot --skip-publish --rm-dist
|
||||
- ./scripts/version.sh ./goreleaser --snapshot --skip=publish --clean
|
||||
- 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"'
|
||||
@@ -93,10 +93,14 @@ steps:
|
||||
# - 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 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "rm /repo/incoming/*.deb"'
|
||||
- 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
|
||||
JFA_GO_BUILT_BY:
|
||||
from_secret: BUILT_BY
|
||||
JFA_GO_SNAPSHOT: y
|
||||
|
||||
volumes:
|
||||
- name: ssh_key
|
||||
@@ -121,9 +125,9 @@ type: docker
|
||||
steps:
|
||||
- name: build-deploy
|
||||
image: appleboy/drone-ssh
|
||||
volumes:
|
||||
- name: ssh_key
|
||||
path: /root/drone_rsa
|
||||
environment:
|
||||
BUILDRONE_KEY:
|
||||
from_secret: BUILDRONE_KEY
|
||||
settings:
|
||||
host:
|
||||
from_secret: ssh2_host
|
||||
@@ -131,15 +135,16 @@ steps:
|
||||
from_secret: ssh2_username
|
||||
port:
|
||||
from_secret: ssh2_port
|
||||
volumes:
|
||||
- /root/.ssh/docker-build:/root/drone_rsa
|
||||
key_path: /root/drone_rsa
|
||||
envs:
|
||||
- buildrone_key
|
||||
key:
|
||||
from_secret: ssh2_key
|
||||
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'
|
||||
- bash -c 'cd /mnt/buildx/jfa-go/jfa-go && 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:
|
||||
@@ -148,10 +153,6 @@ trigger:
|
||||
exclude:
|
||||
- pull_request
|
||||
|
||||
volumes:
|
||||
- name: ssh_key
|
||||
host:
|
||||
path: /root/.ssh/docker-build
|
||||
---
|
||||
name: jfa-go-pr
|
||||
kind: pipeline
|
||||
@@ -163,7 +164,7 @@ steps:
|
||||
commands:
|
||||
- curl -sL https://git.io/goreleaser > goreleaser
|
||||
- chmod +x goreleaser
|
||||
- ./scripts/version.sh ./goreleaser --snapshot --skip-publish --rm-dist
|
||||
- ./scripts/version.sh ./goreleaser --snapshot --skip=publish --clean
|
||||
|
||||
trigger:
|
||||
event:
|
||||
4
.gitignore
vendored
@@ -21,3 +21,7 @@ cl.md
|
||||
mautrix/
|
||||
tempts/
|
||||
matacc.txt
|
||||
scripts/langmover/lang
|
||||
scripts/langmover/lang2
|
||||
scripts/langmover/out
|
||||
tinyproxy.conf
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
||||
version: 2
|
||||
project_name: jfa-go
|
||||
release:
|
||||
github:
|
||||
@@ -8,11 +10,10 @@ before:
|
||||
hooks:
|
||||
- go mod download
|
||||
- rm -rf data/web
|
||||
- mkdir -p data
|
||||
- cp -r static data/web
|
||||
- mkdir -p data/web/css
|
||||
- bash -c 'cp -r static/* data/web/'
|
||||
- npm install
|
||||
- npm install esbuild
|
||||
- mkdir -p data/web/css
|
||||
- cp node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 data/web/css/
|
||||
- cp -r html data/
|
||||
- node scripts/missing-colors.js html data/html
|
||||
@@ -26,13 +27,17 @@ before:
|
||||
- cp -r ts tempts
|
||||
- scripts/dark-variant.sh tempts
|
||||
- scripts/dark-variant.sh tempts/modules
|
||||
- npx esbuild --bundle tempts/admin.ts --outfile=./data/web/js/admin.js --minify
|
||||
- npx esbuild --bundle tempts/pwr.ts --outfile=./data/web/js/pwr.js --minify
|
||||
- npx esbuild --bundle tempts/form.ts --outfile=./data/web/js/form.js --minify
|
||||
- npx esbuild --bundle tempts/setup.ts --outfile=./data/web/js/setup.js --minify
|
||||
- npx esbuild --bundle tempts/crash.ts --outfile=./data/crash.js --minify
|
||||
- mkdir -p data/web/js
|
||||
- npx esbuild --target=es6 --bundle tempts/admin.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/admin.js {{.Env.JFA_GO_MINIFY}}
|
||||
- npx esbuild --target=es6 --bundle tempts/user.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/user.js {{.Env.JFA_GO_MINIFY}}
|
||||
- npx esbuild --target=es6 --bundle tempts/pwr.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/pwr.js {{.Env.JFA_GO_MINIFY}}
|
||||
- npx esbuild --target=es6 --bundle tempts/pwr-pin.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/pwr-pin.js {{.Env.JFA_GO_MINIFY}}
|
||||
- npx esbuild --target=es6 --bundle tempts/form.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/form.js {{.Env.JFA_GO_MINIFY}}
|
||||
- npx esbuild --target=es6 --bundle tempts/setup.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/setup.js {{.Env.JFA_GO_MINIFY}}
|
||||
- npx esbuild --target=es6 --bundle tempts/crash.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/crash.js {{.Env.JFA_GO_MINIFY}}
|
||||
- bash -c "{{.Env.JFA_GO_COPYTS}}"
|
||||
- rm -r tempts
|
||||
- npx esbuild --bundle css/base.css --outfile=./data/web/css/bundle.css --external:remixicon.css --minify
|
||||
- npx esbuild --bundle css/base.css --outfile=./data/web/css/bundle.css --external:remixicon.css --external:../fonts/hanken* --minify
|
||||
- cp html/crash.html data/
|
||||
- npx tailwindcss -i data/web/css/bundle.css -o data/bundle.css --content "html/crash.html"
|
||||
- node scripts/inline.js root data data/crash.html data/crash.html
|
||||
@@ -48,10 +53,11 @@ builds:
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
ldflags:
|
||||
- -s -w -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}}
|
||||
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary {{.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
|
||||
@@ -65,7 +71,7 @@ builds:
|
||||
flags:
|
||||
- -tags=tray
|
||||
ldflags:
|
||||
- -s -w -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -H=windowsgui
|
||||
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}" -H=windowsgui
|
||||
goos:
|
||||
- windows
|
||||
goarch:
|
||||
@@ -74,10 +80,13 @@ builds:
|
||||
dir: ./
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=x86_64-linux-gnu-gcc
|
||||
- CXX=x86_64-linux-gnu-gcc
|
||||
- PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH
|
||||
flags:
|
||||
- -tags=tray
|
||||
ldflags:
|
||||
- -s -w -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}}
|
||||
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary {{.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:
|
||||
@@ -87,32 +96,32 @@ archives:
|
||||
builds:
|
||||
- windows-tray
|
||||
format: zip
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_TrayIcon_{{ .Os }}_{{ .Arch }}"
|
||||
replacements:
|
||||
darwin: macOS
|
||||
linux: Linux
|
||||
windows: Windows
|
||||
amd64: x86_64
|
||||
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:
|
||||
- linux-tray
|
||||
format: zip
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_TrayIcon_{{ .Os }}_{{ .Arch }}"
|
||||
replacements:
|
||||
darwin: macOS
|
||||
linux: Linux
|
||||
windows: Windows
|
||||
amd64: x86_64
|
||||
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:
|
||||
- notray
|
||||
format: zip
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
replacements:
|
||||
darwin: macOS
|
||||
linux: Linux
|
||||
windows: Windows
|
||||
amd64: x86_64
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_{{ .Version }}_
|
||||
{{- if eq .Os "darwin" }}macOS
|
||||
{{- else }}{{- title .Os }}{{ end }}_
|
||||
{{- if eq .Arch "amd64" }}x86_64
|
||||
{{- else }}{{ .Arch }}{{ end }}
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
snapshot:
|
||||
@@ -167,10 +176,10 @@ nfpms:
|
||||
replaces:
|
||||
- jfa-go
|
||||
dependencies:
|
||||
- libappindicator3-1
|
||||
- libayatana-appindicator
|
||||
rpm:
|
||||
dependencies:
|
||||
- libappindicator-gtk3
|
||||
apk:
|
||||
dependencies:
|
||||
- libappindicator
|
||||
- libayatana-appindicator
|
||||
|
||||
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: 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: 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: 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/).
|
||||
|
||||
18
Dockerfile
@@ -1,18 +1,22 @@
|
||||
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 hrfee/jfa-go-build-docker AS support
|
||||
|
||||
COPY . /opt/build
|
||||
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install build-essential python3-pip curl software-properties-common sed -y \
|
||||
&& (curl -sL https://deb.nodesource.com/setup_current.x | bash -) \
|
||||
&& apt-get install nodejs \
|
||||
&& (cd /opt/build; make configuration npm email typescript variants-html bundle-css inline-css swagger copy INTERNAL=off GOESBUILD=on) \
|
||||
# Uncomment this if hrfee/jfa-go-build-docker doesn't support your architecture
|
||||
# RUN apt-get update -y \
|
||||
# && apt-get install build-essential python3-pip -y \
|
||||
# && (curl -sL https://deb.nodesource.com/setup_current.x | bash -) \
|
||||
# && apt-get install nodejs
|
||||
RUN (cd /opt/build; make configuration npm email typescript variants-html bundle-css inline-css swagger copy INTERNAL=off GOESBUILD=on) \
|
||||
&& sed -i 's#id="password_resets-watch_directory" placeholder="/config/jellyfin"#id="password_resets-watch_directory" value="/jf" disabled#g' /opt/build/build/data/html/setup.html
|
||||
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:latest AS build
|
||||
ARG TARGETARCH
|
||||
ENV GOARCH=$TARGETARCH
|
||||
ARG BUILT_BY
|
||||
ENV BUILTBY=$BUILT_BY
|
||||
|
||||
COPY --from=support /opt/build /opt/build
|
||||
|
||||
|
||||
37
Makefile
@@ -11,9 +11,10 @@ CSSVERSION ?= v3
|
||||
VERSION ?= $(shell git describe --exact-match HEAD 2> /dev/null || echo vgit)
|
||||
VERSION := $(shell echo $(VERSION) | sed 's/v//g')
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD || echo unknown)
|
||||
BUILDTIME ?= $(shell date +%s)
|
||||
|
||||
UPDATER ?= off
|
||||
LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.cssVersion=$(CSSVERSION)
|
||||
LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.cssVersion=$(CSSVERSION) -X main.buildTimeUnix=$(BUILDTIME) $(if $(BUILTBY),-X 'main.builtBy=$(BUILTBY)',)
|
||||
ifeq ($(UPDATER), on)
|
||||
LDFLAGS := $(LDFLAGS) -X main.updater=binary
|
||||
else ifneq ($(UPDATER), off)
|
||||
@@ -74,14 +75,28 @@ else
|
||||
RACEDETECTOR :=
|
||||
endif
|
||||
|
||||
ifeq (, $(shell which esbuild))
|
||||
ESBUILDINSTALL := go install github.com/evanw/esbuild/cmd/esbuild@latest
|
||||
else
|
||||
ESBUILDINSTALL :=
|
||||
endif
|
||||
|
||||
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
|
||||
|
||||
npm:
|
||||
$(info installing npm dependencies)
|
||||
npm install
|
||||
@if [ "$(GOESBUILD)" = "off" ]; then\
|
||||
npm install esbuild;\
|
||||
else\
|
||||
go install github.com/evanw/esbuild/cmd/esbuild@latest;\
|
||||
fi
|
||||
npm install $(NPMOPTS)
|
||||
|
||||
configuration:
|
||||
$(info Fixing config-base)
|
||||
@@ -104,14 +119,16 @@ typescript:
|
||||
$(info compiling typescript)
|
||||
mkdir -p $(DATA)/web/js
|
||||
$(ESBUILD) --target=es6 --bundle tempts/admin.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/admin.js --minify
|
||||
$(ESBUILD) --target=es6 --bundle tempts/user.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/user.js --minify
|
||||
$(ESBUILD) --target=es6 --bundle tempts/pwr.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/pwr.js --minify
|
||||
$(ESBUILD) --target=es6 --bundle tempts/pwr-pin.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/pwr-pin.js --minify
|
||||
$(ESBUILD) --target=es6 --bundle tempts/form.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/form.js --minify
|
||||
$(ESBUILD) --target=es6 --bundle tempts/setup.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/setup.js --minify
|
||||
$(ESBUILD) --target=es6 --bundle tempts/crash.ts --outfile=./$(DATA)/crash.js --minify
|
||||
$(COPYTS)
|
||||
|
||||
swagger:
|
||||
$(GOBINARY) install github.com/swaggo/swag/cmd/swag@latest
|
||||
$(SWAGINSTALL)
|
||||
swag init -g main.go
|
||||
|
||||
compile:
|
||||
@@ -129,7 +146,7 @@ bundle-css:
|
||||
$(info copying fonts)
|
||||
cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 $(DATA)/web/css/
|
||||
$(info bundling css)
|
||||
$(ESBUILD) --bundle css/base.css --outfile=$(DATA)/web/css/bundle.css --external:remixicon.css --minify
|
||||
$(ESBUILD) --bundle css/base.css --outfile=$(DATA)/web/css/bundle.css --external:remixicon.css --external:../fonts/hanken* --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
|
||||
|
||||
@@ -177,4 +194,6 @@ clean:
|
||||
-rm docs/docs.go docs/swagger.json docs/swagger.yaml
|
||||
go clean
|
||||
|
||||
quick: configuration typescript variants-html bundle-css inline-css copy compile
|
||||
|
||||
all: configuration npm email typescript variants-html bundle-css inline-css swagger copy compile
|
||||
|
||||
67
README.md
@@ -9,43 +9,38 @@
|
||||
##### [docker](#docker) | [debian/ubuntu](#debian) | [arch (aur)](#aur) | [other platforms](#other-platforms)
|
||||
|
||||
---
|
||||
## Project Status: Active-ish
|
||||
Studies mean I can't work on this project a lot outside of breaks, however I hope i'll be able to fit in general support and things like bug fixes into my time. New features and such will likely come in short bursts throughout the year (if they do at all).
|
||||
|
||||
## Project Status
|
||||
Due to studies and general lack of enthusiasm for work on this project, new features are unlikely, and while occasionally I might fix a bug or two, I won't be supporting the project a lot.
|
||||
|
||||
#### Does it still work?
|
||||
jfa-go still appears to work on the latest version of Jellyfin (10.8.9), and unless any large architectural changes occur to it, functionality should still remain.
|
||||
#### Does/Will it still work?
|
||||
jfa-go currently works on Jellyfin 10.8.13, the latest version as of 26/12/23. I should be able to maintain compatability in the future, unless any big changes occur.
|
||||
|
||||
#### Alternatives
|
||||
None of these have been tested by myself, but I have seen them mentioned quite frequently.
|
||||
If you want a bit more 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.
|
||||
* [Organizr](https://github.com/causefx/Organizr) doesn't focus on Jellyfin, but allows putting self-hosted services into "tabs" on a central page, and allows creating users, which lets one control who can access what.
|
||||
---
|
||||
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) (and now [Emby](https://emby.media/)) that provides invite-based account creation as well as other features that make one's instance much easier to manage.
|
||||
|
||||
a rewrite of [jellyfin-accounts](https://github.com/hrfee/jellyfin-accounts) (original naming for both, ik
|
||||
😂).
|
||||
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) (and [Emby](https://emby.media/) as 2nd class) that provides invite-based account creation as well as other features that make 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 can be enabled to avoid bots
|
||||
* 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 them via email/telegram.
|
||||
* Admin Notifications: Get notified when someone creates an account, or an invite expires.
|
||||
* 🔗 Ombi Integration: Automatically creates Ombi accounts for new users using their email address and login details, and your own defined set of permissions. See [wiki](https://wiki.jfa-go.com/docs/ombi/) for a warning on this one.
|
||||
* 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
|
||||
@@ -53,13 +48,10 @@ 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
|
||||
@@ -73,7 +65,7 @@ docker create \
|
||||
-p 8056:8056 \
|
||||
# -p 8057:8057 if using tls
|
||||
-v /path/to/.config/jfa-go:/data \ # Path to wherever you want to store the config file and other data
|
||||
-v /path/to/jellyfin:/jf \ # Path to Jellyfin config directory, ignore if using Emby
|
||||
-v /path/to/jellyfin:/jf \ # Only needed for password resets through Jellyfin, ignore if not using or using Emby
|
||||
-v /etc/localtime:/etc/localtime:ro \ # Makes sure time is correct
|
||||
hrfee/jfa-go # hrfee/jfa-go:unstable for latest build from git
|
||||
```
|
||||
@@ -81,7 +73,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
|
||||
@@ -109,7 +101,7 @@ Available on the AUR as:
|
||||
##### Other platforms
|
||||
Download precompiled binaries from:
|
||||
* [The releases section](https://github.com/hrfee/jfa-go/releases) (stable)
|
||||
* [Buildrone](https://builds.hrfee.dev/view/hrfee/jfa-go) (nightly)
|
||||
* [dl.jfa-go.com](https://dl.jfa-go.com) (nightly)
|
||||
|
||||
unzip the `jfa-go`/`jfa-go.exe` executable to somewhere useful.
|
||||
* For \*nix/macOS users, `chmod +x jfa-go` then place it somewhere in your PATH like `/usr/bin`.
|
||||
@@ -148,6 +140,8 @@ Usage of jfa-go:
|
||||
alternate port to host web ui on.
|
||||
-pprof
|
||||
Exposes pprof profiler on /debug/pprof.
|
||||
-restore string
|
||||
path to database backup to restore.
|
||||
-swagger
|
||||
Enable swagger at /swagger/index.html
|
||||
```
|
||||
@@ -155,18 +149,9 @@ Usage of jfa-go:
|
||||
#### Systemd
|
||||
jfa-go does not run as a daemon by default. Run `jfa-go systemd` to create a systemd `.service` file in your current directory, which you can copy into `~/.config/systemd/user` or somewhere else.
|
||||
|
||||
---
|
||||
|
||||
If you're switching from jellyfin-accounts, copy your existing `~/.jf-accounts` to:
|
||||
|
||||
* `XDG_CONFIG_DIR/jfa-go` (usually ~/.config/jfa-go) on \*nix systems,
|
||||
* `%AppData%/jfa-go` on Windows,
|
||||
* `~/Library/Application Support/jfa-go` on macOS.
|
||||
|
||||
(or specify config/data path with `-config/-data` respectively.)
|
||||
|
||||
#### Contributing
|
||||
See [the wiki page](https://wiki.jfa-go.com/docs/dev/) or [CONTRIBUTING.md](https://github.com/hrfee/jfa-go/blob/main/CONTRIBUTING.md).
|
||||
See [the wiki page](https://wiki.jfa-go.com/docs/dev/).
|
||||
##### Translation
|
||||
[](https://weblate.jfa-go.com/engage/jfa-go/)
|
||||
|
||||
|
||||
187
api-activities.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
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 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 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.
|
||||
// @Produce json
|
||||
// @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters"
|
||||
// @Success 200 {object} GetActivitiesRespDTO
|
||||
// @Router /activity [post]
|
||||
// @Security Bearer
|
||||
// @tags Activity
|
||||
func (app *appContext) GetActivities(gc *gin.Context) {
|
||||
req := GetActivitiesDTO{}
|
||||
gc.BindJSON(&req)
|
||||
query := &badgerhold.Query{}
|
||||
activityTypes := make([]interface{}, len(req.Type))
|
||||
for i, v := range req.Type {
|
||||
activityTypes[i] = stringToActivityType(v)
|
||||
}
|
||||
if len(activityTypes) != 0 {
|
||||
query = badgerhold.Where("Type").In(activityTypes...)
|
||||
}
|
||||
|
||||
if !req.Ascending {
|
||||
query = query.Reverse()
|
||||
}
|
||||
|
||||
query = query.SortBy("Time")
|
||||
|
||||
if req.Limit == 0 {
|
||||
req.Limit = 10
|
||||
}
|
||||
|
||||
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("Failed to read activities from DB: %v\n", err)
|
||||
}
|
||||
|
||||
resp := GetActivitiesRespDTO{
|
||||
Activities: make([]ActivityDTO, len(results)),
|
||||
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,
|
||||
}
|
||||
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
|
||||
resp.Activities[i].Username = act.Value
|
||||
resp.Activities[i].Value = ""
|
||||
} else if user, status, err := app.jf.UserByID(act.UserID, false); status == 200 && err == nil {
|
||||
resp.Activities[i].Username = user.Name
|
||||
}
|
||||
|
||||
if (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != "" {
|
||||
user, status, err := app.jf.UserByID(act.Source, false)
|
||||
if status == 200 && err == nil {
|
||||
resp.Activities[i].SourceUsername = user.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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} GetActivityCountDTO
|
||||
// @Router /activity/count [get]
|
||||
// @Security Bearer
|
||||
// @tags Activity
|
||||
func (app *appContext) GetActivityCount(gc *gin.Context) {
|
||||
resp := GetActivityCountDTO{}
|
||||
var err error
|
||||
resp.Count, err = app.storage.db.Count(&Activity{}, &badgerhold.Query{})
|
||||
if err != nil {
|
||||
resp.Count = 0
|
||||
}
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
117
api-backups.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// @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.
|
||||
ok := (strings.HasPrefix(fname, BACKUP_PREFIX) || strings.HasPrefix(fname, BACKUP_UPLOAD_PREFIX+BACKUP_PREFIX)) && strings.HasSuffix(fname, BACKUP_SUFFIX)
|
||||
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(fname, BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX), BACKUP_SUFFIX))
|
||||
if !ok || err != nil || t.IsZero() {
|
||||
app.debug.Printf("Ignoring backup DL request due to fname: %v\n", 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.dates[i].Unix()
|
||||
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.
|
||||
ok := strings.HasPrefix(fname, BACKUP_PREFIX) && strings.HasSuffix(fname, BACKUP_SUFFIX)
|
||||
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(fname, BACKUP_PREFIX), BACKUP_SUFFIX))
|
||||
if !ok || err != nil || t.IsZero() {
|
||||
app.debug.Printf("Ignoring backup DL request due to fname: %v\n", 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("Failed to get file from form data: %v\n", err)
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
app.debug.Printf("Got uploaded file \"%s\"\n", file.Filename)
|
||||
path := app.config.Section("backups").Key("path").String()
|
||||
fullpath := filepath.Join(path, BACKUP_UPLOAD_PREFIX+BACKUP_PREFIX+time.Now().Local().Format(BACKUP_DATEFMT)+BACKUP_SUFFIX)
|
||||
gc.SaveUploadedFile(file, fullpath)
|
||||
app.debug.Printf("Saved to \"%s\"\n", fullpath)
|
||||
LOADBAK = fullpath
|
||||
app.restart(gc)
|
||||
}
|
||||
234
api-invites.go
@@ -10,21 +10,62 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/itchyny/timefmt-go"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
CAPTCHA_VALIDITY = 20 * 60 // Seconds
|
||||
)
|
||||
|
||||
// GenerateInviteCode generates an invite code in the correct format.
|
||||
func GenerateInviteCode() string {
|
||||
// make sure code doesn't begin with number
|
||||
inviteCode := shortuuid.New()
|
||||
_, err := strconv.Atoi(string(inviteCode[0]))
|
||||
for err == nil {
|
||||
inviteCode = shortuuid.New()
|
||||
_, err = strconv.Atoi(string(inviteCode[0]))
|
||||
}
|
||||
return inviteCode
|
||||
}
|
||||
|
||||
func (app *appContext) checkInvites() {
|
||||
currentTime := time.Now()
|
||||
app.storage.loadInvites()
|
||||
changed := false
|
||||
for code, data := range app.storage.invites {
|
||||
for _, data := range app.storage.GetInvites() {
|
||||
captchas := data.Captchas
|
||||
captchasExpired := false
|
||||
for key, capt := range data.Captchas {
|
||||
if time.Now().After(capt.Generated.Add(CAPTCHA_VALIDITY * time.Second)) {
|
||||
delete(captchas, key)
|
||||
captchasExpired = true
|
||||
}
|
||||
}
|
||||
if captchasExpired {
|
||||
data.Captchas = captchas
|
||||
app.storage.SetInvitesKey(data.Code, data)
|
||||
}
|
||||
|
||||
if data.IsReferral && (!data.UseReferralExpiry || data.ReferrerJellyfinID == "") {
|
||||
continue
|
||||
}
|
||||
expiry := data.ValidTill
|
||||
if !currentTime.After(expiry) {
|
||||
continue
|
||||
}
|
||||
app.debug.Printf("Housekeeping: Deleting old invite %s", code)
|
||||
|
||||
app.debug.Printf("Housekeeping: Deleting old invite %s", 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)
|
||||
}
|
||||
}
|
||||
notify := data.Notify
|
||||
if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
|
||||
app.debug.Printf("%s: Expiry notification", code)
|
||||
app.debug.Printf("%s: Expiry notification", data.Code)
|
||||
var wait sync.WaitGroup
|
||||
for address, settings := range notify {
|
||||
if !settings["notify-expiry"] {
|
||||
@@ -33,9 +74,9 @@ func (app *appContext) checkInvites() {
|
||||
wait.Add(1)
|
||||
go func(addr string) {
|
||||
defer wait.Done()
|
||||
msg, err := app.email.constructExpiry(code, data, app, false)
|
||||
msg, err := app.email.constructExpiry(data.Code, data, app, false)
|
||||
if err != nil {
|
||||
app.err.Printf("%s: Failed to construct expiry notification: %v", code, err)
|
||||
app.err.Printf("%s: Failed to construct expiry notification: %v", data.Code, err)
|
||||
} else {
|
||||
// Check whether notify "address" is an email address of Jellyfin ID
|
||||
if strings.Contains(addr, "@") {
|
||||
@@ -44,7 +85,7 @@ func (app *appContext) checkInvites() {
|
||||
err = app.sendByID(msg, addr)
|
||||
}
|
||||
if err != nil {
|
||||
app.err.Printf("%s: Failed to send expiry notification: %v", code, err)
|
||||
app.err.Printf("%s: Failed to send expiry notification: %v", data.Code, err)
|
||||
} else {
|
||||
app.info.Printf("Sent expiry notification to %s", addr)
|
||||
}
|
||||
@@ -53,19 +94,21 @@ func (app *appContext) checkInvites() {
|
||||
}
|
||||
wait.Wait()
|
||||
}
|
||||
changed = true
|
||||
delete(app.storage.invites, code)
|
||||
}
|
||||
if changed {
|
||||
app.storage.storeInvites()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *appContext) checkInvite(code string, used bool, username string) bool {
|
||||
currentTime := time.Now()
|
||||
app.storage.loadInvites()
|
||||
changed := false
|
||||
inv, match := app.storage.invites[code]
|
||||
inv, match := app.storage.GetInvitesKey(code)
|
||||
if !match {
|
||||
return false
|
||||
}
|
||||
@@ -103,28 +146,44 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
|
||||
}
|
||||
wait.Wait()
|
||||
}
|
||||
changed = true
|
||||
if inv.IsReferral && inv.ReferrerJellyfinID != "" && inv.UseReferralExpiry {
|
||||
user, ok := app.storage.GetEmailsKey(inv.ReferrerJellyfinID)
|
||||
if ok {
|
||||
user.ReferralTemplateKey = ""
|
||||
app.storage.SetEmailsKey(inv.ReferrerJellyfinID, user)
|
||||
}
|
||||
}
|
||||
match = false
|
||||
delete(app.storage.invites, code)
|
||||
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 used {
|
||||
changed = true
|
||||
del := false
|
||||
newInv := inv
|
||||
if newInv.RemainingUses == 1 {
|
||||
del = true
|
||||
delete(app.storage.invites, code)
|
||||
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.invites[code] = newInv
|
||||
app.storage.SetInvitesKey(code, newInv)
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
app.storage.storeInvites()
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
@@ -138,22 +197,18 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
|
||||
func (app *appContext) GenerateInvite(gc *gin.Context) {
|
||||
var req generateInviteDTO
|
||||
app.debug.Println("Generating new invite")
|
||||
app.storage.loadInvites()
|
||||
gc.BindJSON(&req)
|
||||
currentTime := time.Now()
|
||||
validTill := currentTime.AddDate(0, req.Months, req.Days)
|
||||
validTill = validTill.Add(time.Hour*time.Duration(req.Hours) + time.Minute*time.Duration(req.Minutes))
|
||||
// make sure code doesn't begin with number
|
||||
inviteCode := shortuuid.New()
|
||||
_, err := strconv.Atoi(string(inviteCode[0]))
|
||||
for err == nil {
|
||||
inviteCode = shortuuid.New()
|
||||
_, err = strconv.Atoi(string(inviteCode[0]))
|
||||
}
|
||||
var invite Invite
|
||||
invite.Code = GenerateInviteCode()
|
||||
if req.Label != "" {
|
||||
invite.Label = req.Label
|
||||
}
|
||||
if req.UserLabel != "" {
|
||||
invite.UserLabel = req.UserLabel
|
||||
}
|
||||
invite.Created = currentTime
|
||||
if req.MultipleUses {
|
||||
if req.NoLimit {
|
||||
@@ -175,8 +230,8 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
|
||||
if req.SendTo != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
|
||||
addressValid := false
|
||||
discord := ""
|
||||
app.debug.Printf("%s: Sending invite message", inviteCode)
|
||||
if discordEnabled && !strings.Contains(req.SendTo, "@") {
|
||||
app.debug.Printf("%s: Sending invite message", invite.Code)
|
||||
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
|
||||
users := app.discord.GetUsers(req.SendTo)
|
||||
if len(users) == 0 {
|
||||
invite.SendTo = fmt.Sprintf("Failed: User not found: \"%s\"", req.SendTo)
|
||||
@@ -192,10 +247,10 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
|
||||
invite.SendTo = req.SendTo
|
||||
}
|
||||
if addressValid {
|
||||
msg, err := app.email.constructInvite(inviteCode, invite, app, false)
|
||||
msg, err := app.email.constructInvite(invite.Code, invite, app, false)
|
||||
if err != nil {
|
||||
invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo)
|
||||
app.err.Printf("%s: Failed to construct invite message: %v", inviteCode, err)
|
||||
app.err.Printf("%s: Failed to construct invite message: %v", invite.Code, err)
|
||||
} else {
|
||||
var err error
|
||||
if discord != "" {
|
||||
@@ -205,22 +260,33 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
|
||||
}
|
||||
if err != nil {
|
||||
invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo)
|
||||
app.err.Printf("%s: %s: %v", inviteCode, invite.SendTo, err)
|
||||
app.err.Printf("%s: %s: %v", invite.Code, invite.SendTo, err)
|
||||
} else {
|
||||
app.info.Printf("%s: Sent invite email to \"%s\"", inviteCode, req.SendTo)
|
||||
app.info.Printf("%s: Sent invite email to \"%s\"", invite.Code, req.SendTo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.Profile != "" {
|
||||
if _, ok := app.storage.profiles[req.Profile]; ok {
|
||||
if _, ok := app.storage.GetProfileKey(req.Profile); ok {
|
||||
invite.Profile = req.Profile
|
||||
} else {
|
||||
invite.Profile = "Default"
|
||||
}
|
||||
}
|
||||
app.storage.invites[inviteCode] = invite
|
||||
app.storage.storeInvites()
|
||||
app.storage.SetInvitesKey(invite.Code, invite)
|
||||
|
||||
// Record activity
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityCreateInvite,
|
||||
UserID: "",
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
InviteCode: invite.Code,
|
||||
Value: invite.Label,
|
||||
Time: time.Now(),
|
||||
}, gc, false)
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -233,13 +299,16 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
|
||||
func (app *appContext) GetInvites(gc *gin.Context) {
|
||||
app.debug.Println("Invites requested")
|
||||
currentTime := time.Now()
|
||||
app.storage.loadInvites()
|
||||
app.checkInvites()
|
||||
var invites []inviteDTO
|
||||
for code, inv := range app.storage.invites {
|
||||
_, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
|
||||
for _, inv := range app.storage.GetInvites() {
|
||||
if inv.IsReferral {
|
||||
continue
|
||||
}
|
||||
years, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
|
||||
months += years * 12
|
||||
invite := inviteDTO{
|
||||
Code: code,
|
||||
Code: inv.Code,
|
||||
Months: months,
|
||||
Days: days,
|
||||
Hours: hours,
|
||||
@@ -253,6 +322,7 @@ func (app *appContext) GetInvites(gc *gin.Context) {
|
||||
Profile: inv.Profile,
|
||||
NoLimit: inv.NoLimit,
|
||||
Label: inv.Label,
|
||||
UserLabel: inv.UserLabel,
|
||||
}
|
||||
if len(inv.UsedBy) != 0 {
|
||||
invite.UsedBy = map[string]int64{}
|
||||
@@ -277,37 +347,36 @@ func (app *appContext) GetInvites(gc *gin.Context) {
|
||||
invite.SendTo = inv.SendTo
|
||||
}
|
||||
if len(inv.Notify) != 0 {
|
||||
var address string
|
||||
// app.err.Printf("%s has notify section: %+v, you are %s\n", inv.Code, inv.Notify, gc.GetString("jfId"))
|
||||
var addressOrID string
|
||||
if app.config.Section("ui").Key("jellyfin_login").MustBool(false) {
|
||||
app.storage.loadEmails()
|
||||
if addr, ok := app.storage.emails[gc.GetString("jfId")]; ok && addr.Addr != "" {
|
||||
address = addr.Addr
|
||||
}
|
||||
addressOrID = gc.GetString("jfId")
|
||||
} else {
|
||||
address = app.config.Section("ui").Key("email").String()
|
||||
addressOrID = app.config.Section("ui").Key("email").String()
|
||||
}
|
||||
if _, ok := inv.Notify[address]; ok {
|
||||
if _, ok = inv.Notify[address]["notify-expiry"]; ok {
|
||||
invite.NotifyExpiry = inv.Notify[address]["notify-expiry"]
|
||||
if _, ok := inv.Notify[addressOrID]; ok {
|
||||
if _, ok = inv.Notify[addressOrID]["notify-expiry"]; ok {
|
||||
invite.NotifyExpiry = inv.Notify[addressOrID]["notify-expiry"]
|
||||
}
|
||||
if _, ok = inv.Notify[address]["notify-creation"]; ok {
|
||||
invite.NotifyCreation = inv.Notify[address]["notify-creation"]
|
||||
if _, ok = inv.Notify[addressOrID]["notify-creation"]; ok {
|
||||
invite.NotifyCreation = inv.Notify[addressOrID]["notify-creation"]
|
||||
}
|
||||
}
|
||||
}
|
||||
invites = append(invites, invite)
|
||||
}
|
||||
profiles := make([]string, len(app.storage.profiles))
|
||||
if len(app.storage.profiles) != 0 {
|
||||
profiles[0] = app.storage.defaultProfile
|
||||
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(app.storage.profiles) > 1 {
|
||||
for p := range app.storage.profiles {
|
||||
if p != app.storage.defaultProfile {
|
||||
profiles[i] = p
|
||||
i++
|
||||
}
|
||||
}
|
||||
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 := getInvitesDTO{
|
||||
@@ -330,15 +399,14 @@ func (app *appContext) SetProfile(gc *gin.Context) {
|
||||
gc.BindJSON(&req)
|
||||
app.debug.Printf("%s: Setting profile to \"%s\"", req.Invite, req.Profile)
|
||||
// "" means "Don't apply profile"
|
||||
if _, ok := app.storage.profiles[req.Profile]; !ok && req.Profile != "" {
|
||||
if _, ok := app.storage.GetProfileKey(req.Profile); !ok && req.Profile != "" {
|
||||
app.err.Printf("%s: Profile \"%s\" not found", req.Invite, req.Profile)
|
||||
respond(500, "Profile not found", gc)
|
||||
return
|
||||
}
|
||||
inv := app.storage.invites[req.Invite]
|
||||
inv, _ := app.storage.GetInvitesKey(req.Invite)
|
||||
inv.Profile = req.Profile
|
||||
app.storage.invites[req.Invite] = inv
|
||||
app.storage.storeInvites()
|
||||
app.storage.SetInvitesKey(req.Invite, inv)
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -357,9 +425,7 @@ func (app *appContext) SetNotify(gc *gin.Context) {
|
||||
changed := false
|
||||
for code, settings := range req {
|
||||
app.debug.Printf("%s: Notification settings change requested", code)
|
||||
app.storage.loadInvites()
|
||||
app.storage.loadEmails()
|
||||
invite, ok := app.storage.invites[code]
|
||||
invite, ok := app.storage.GetInvitesKey(code)
|
||||
if !ok {
|
||||
app.err.Printf("%s Notification setting change failed: Invalid code", code)
|
||||
respond(400, "Invalid invite code", gc)
|
||||
@@ -398,12 +464,9 @@ func (app *appContext) SetNotify(gc *gin.Context) {
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
app.storage.invites[code] = invite
|
||||
app.storage.SetInvitesKey(code, invite)
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
app.storage.storeInvites()
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Delete an invite.
|
||||
@@ -418,11 +481,20 @@ func (app *appContext) DeleteInvite(gc *gin.Context) {
|
||||
var req deleteInviteDTO
|
||||
gc.BindJSON(&req)
|
||||
app.debug.Printf("%s: Deletion requested", req.Code)
|
||||
var ok bool
|
||||
_, ok = app.storage.invites[req.Code]
|
||||
inv, ok := app.storage.GetInvitesKey(req.Code)
|
||||
if ok {
|
||||
delete(app.storage.invites, req.Code)
|
||||
app.storage.storeInvites()
|
||||
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("%s: Invite deleted", req.Code)
|
||||
respondBool(200, true, gc)
|
||||
return
|
||||
|
||||
100
api-jellyseerr.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// @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) {
|
||||
app.debug.Println("Jellyseerr users requested")
|
||||
users, err := app.js.GetUsers()
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to get users from Jellyseerr: %v", err)
|
||||
respond(500, "Couldn't get users", gc)
|
||||
return
|
||||
}
|
||||
app.debug.Printf("Jellyseerr users retrieved: %d", len(users))
|
||||
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("Couldn't get user from Jellyseerr: %v", 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("Couldn't get user's notification prefs from Jellyseerr: %v", 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)
|
||||
}
|
||||
427
api-messages.go
@@ -5,6 +5,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hrfee/jfa-go/jellyseerr"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
@@ -15,86 +17,73 @@ import (
|
||||
// @Router /config/emails [get]
|
||||
// @Security Bearer
|
||||
// @tags Configuration
|
||||
func (app *appContext) GetCustomEmails(gc *gin.Context) {
|
||||
func (app *appContext) GetCustomContent(gc *gin.Context) {
|
||||
lang := gc.Query("lang")
|
||||
if _, ok := app.storage.lang.Email[lang]; !ok {
|
||||
lang = app.storage.lang.chosenEmailLang
|
||||
}
|
||||
gc.JSON(200, emailListDTO{
|
||||
"UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.customEmails.UserCreated.Enabled},
|
||||
"InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.customEmails.InviteExpiry.Enabled},
|
||||
"PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.customEmails.PasswordReset.Enabled},
|
||||
"UserDeleted": {Name: app.storage.lang.Email[lang].UserDeleted["name"], Enabled: app.storage.customEmails.UserDeleted.Enabled},
|
||||
"UserDisabled": {Name: app.storage.lang.Email[lang].UserDisabled["name"], Enabled: app.storage.customEmails.UserDisabled.Enabled},
|
||||
"UserEnabled": {Name: app.storage.lang.Email[lang].UserEnabled["name"], Enabled: app.storage.customEmails.UserEnabled.Enabled},
|
||||
"InviteEmail": {Name: app.storage.lang.Email[lang].InviteEmail["name"], Enabled: app.storage.customEmails.InviteEmail.Enabled},
|
||||
"WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.customEmails.WelcomeEmail.Enabled},
|
||||
"EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.customEmails.EmailConfirmation.Enabled},
|
||||
"UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.customEmails.UserExpired.Enabled},
|
||||
})
|
||||
}
|
||||
|
||||
func (app *appContext) getCustomEmail(id string) *customEmail {
|
||||
switch id {
|
||||
case "Announcement":
|
||||
return &customEmail{}
|
||||
case "UserCreated":
|
||||
return &app.storage.customEmails.UserCreated
|
||||
case "InviteExpiry":
|
||||
return &app.storage.customEmails.InviteExpiry
|
||||
case "PasswordReset":
|
||||
return &app.storage.customEmails.PasswordReset
|
||||
case "UserDeleted":
|
||||
return &app.storage.customEmails.UserDeleted
|
||||
case "UserDisabled":
|
||||
return &app.storage.customEmails.UserDisabled
|
||||
case "UserEnabled":
|
||||
return &app.storage.customEmails.UserEnabled
|
||||
case "InviteEmail":
|
||||
return &app.storage.customEmails.InviteEmail
|
||||
case "WelcomeEmail":
|
||||
return &app.storage.customEmails.WelcomeEmail
|
||||
case "EmailConfirmation":
|
||||
return &app.storage.customEmails.EmailConfirmation
|
||||
case "UserExpired":
|
||||
return &app.storage.customEmails.UserExpired
|
||||
adminLang := lang
|
||||
if _, ok := app.storage.lang.Admin[lang]; !ok {
|
||||
adminLang = app.storage.lang.chosenAdminLang
|
||||
}
|
||||
return nil
|
||||
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 email.
|
||||
// @Summary Sets the corresponding custom content.
|
||||
// @Produce json
|
||||
// @Param customEmail body customEmail true "Content = email (in markdown)."
|
||||
// @Param CustomContent body CustomContent true "Content = email (in markdown)."
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Param id path string true "ID of email"
|
||||
// @Param id path string true "ID of content"
|
||||
// @Router /config/emails/{id} [post]
|
||||
// @Security Bearer
|
||||
// @tags Configuration
|
||||
func (app *appContext) SetCustomEmail(gc *gin.Context) {
|
||||
var req customEmail
|
||||
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
|
||||
}
|
||||
email := app.getCustomEmail(id)
|
||||
if email == nil {
|
||||
message, ok := app.storage.GetCustomContentKey(id)
|
||||
if !ok {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
email.Content = req.Content
|
||||
email.Enabled = true
|
||||
if app.storage.storeCustomEmails() != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
message.Content = req.Content
|
||||
message.Enabled = true
|
||||
app.storage.SetCustomContentKey(id, message)
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Enable/Disable custom email.
|
||||
// @Summary Enable/Disable custom content.
|
||||
// @Produce json
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 400 {object} boolResponse
|
||||
@@ -104,7 +93,7 @@ func (app *appContext) SetCustomEmail(gc *gin.Context) {
|
||||
// @Router /config/emails/{id}/state/{enable/disable} [post]
|
||||
// @Security Bearer
|
||||
// @tags Configuration
|
||||
func (app *appContext) SetCustomEmailState(gc *gin.Context) {
|
||||
func (app *appContext) SetCustomMessageState(gc *gin.Context) {
|
||||
id := gc.Param("id")
|
||||
s := gc.Param("state")
|
||||
enabled := false
|
||||
@@ -113,20 +102,17 @@ func (app *appContext) SetCustomEmailState(gc *gin.Context) {
|
||||
} else if s != "disable" {
|
||||
respondBool(400, false, gc)
|
||||
}
|
||||
email := app.getCustomEmail(id)
|
||||
if email == nil {
|
||||
message, ok := app.storage.GetCustomContentKey(id)
|
||||
if !ok {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
email.Enabled = enabled
|
||||
if app.storage.storeCustomEmails() != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
message.Enabled = enabled
|
||||
app.storage.SetCustomContentKey(id, message)
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Returns the custom email (generating it if not set) and list of used variables in it.
|
||||
// @Summary Returns the custom content/message (generating it if not set) and list of used variables in it.
|
||||
// @Produce json
|
||||
// @Success 200 {object} customEmailDTO
|
||||
// @Failure 400 {object} boolResponse
|
||||
@@ -135,7 +121,7 @@ func (app *appContext) SetCustomEmailState(gc *gin.Context) {
|
||||
// @Router /config/emails/{id} [get]
|
||||
// @Security Bearer
|
||||
// @tags Configuration
|
||||
func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) {
|
||||
func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
|
||||
lang := app.storage.lang.chosenEmailLang
|
||||
id := gc.Param("id")
|
||||
var content string
|
||||
@@ -146,20 +132,30 @@ func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) {
|
||||
var values map[string]interface{}
|
||||
username := app.storage.lang.Email[lang].Strings.get("username")
|
||||
emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress")
|
||||
email := app.getCustomEmail(id)
|
||||
if email == nil {
|
||||
app.err.Printf("Failed to get custom email with ID \"%s\"", id)
|
||||
customMessage, ok := app.storage.GetCustomContentKey(id)
|
||||
if !ok && id != "Announcement" {
|
||||
app.err.Printf("Failed to get custom message with ID \"%s\"", id)
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
if id == "WelcomeEmail" {
|
||||
conditionals = []string{"{yourAccountWillExpire}"}
|
||||
email.Conditionals = conditionals
|
||||
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 = email.Content
|
||||
|
||||
content = customMessage.Content
|
||||
noContent := content == ""
|
||||
if !noContent {
|
||||
variables = email.Variables
|
||||
variables = customMessage.Variables
|
||||
}
|
||||
switch id {
|
||||
case "Announcement":
|
||||
@@ -195,6 +191,11 @@ func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) {
|
||||
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)
|
||||
@@ -215,12 +216,14 @@ func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) {
|
||||
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" {
|
||||
if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" && id != "PostSignupCard" {
|
||||
content = msg.Text
|
||||
variables = make([]string, strings.Count(content, "{"))
|
||||
i := 0
|
||||
@@ -239,19 +242,39 @@ func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) {
|
||||
i++
|
||||
}
|
||||
}
|
||||
email.Variables = variables
|
||||
customMessage.Variables = variables
|
||||
}
|
||||
if variables == nil {
|
||||
variables = []string{}
|
||||
}
|
||||
if app.storage.storeCustomEmails() != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
mail, err := app.email.constructTemplate("", "<div class=\"preview-content\"></div>", app)
|
||||
if err != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
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})
|
||||
}
|
||||
@@ -285,18 +308,12 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
tokenIndex := -1
|
||||
for i, v := range app.telegram.verifiedTokens {
|
||||
if v.Token == req.Token {
|
||||
tokenIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if tokenIndex == -1 {
|
||||
tgToken, ok := app.telegram.TokenVerified(req.Token)
|
||||
app.telegram.DeleteVerifiedToken(req.Token)
|
||||
if !ok {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
tgToken := app.telegram.verifiedTokens[tokenIndex]
|
||||
tgUser := TelegramUser{
|
||||
ChatID: tgToken.ChatID,
|
||||
Username: tgToken.Username,
|
||||
@@ -305,17 +322,15 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
|
||||
if lang, ok := app.telegram.languages[tgToken.ChatID]; ok {
|
||||
tgUser.Lang = lang
|
||||
}
|
||||
if app.storage.telegram == nil {
|
||||
app.storage.telegram = map[string]TelegramUser{}
|
||||
}
|
||||
app.storage.telegram[req.ID] = tgUser
|
||||
err := app.storage.storeTelegramUsers()
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to store Telegram users: %v", err)
|
||||
} else {
|
||||
app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1], app.telegram.verifiedTokens[tokenIndex] = app.telegram.verifiedTokens[tokenIndex], app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1]
|
||||
app.telegram.verifiedTokens = app.telegram.verifiedTokens[:len(app.telegram.verifiedTokens)-1]
|
||||
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("Failed to sync contact methods with Jellyseerr: %v", err)
|
||||
}
|
||||
|
||||
linkExistingOmbiDiscordTelegram(app)
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
@@ -336,49 +351,41 @@ func (app *appContext) SetContactMethods(gc *gin.Context) {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
if tgUser, ok := app.storage.telegram[req.ID]; ok {
|
||||
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.telegram[req.ID] = tgUser
|
||||
if err := app.storage.storeTelegramUsers(); err != nil {
|
||||
respondBool(500, false, gc)
|
||||
app.err.Printf("Telegram: Failed to store users: %v", err)
|
||||
return
|
||||
}
|
||||
app.storage.SetTelegramKey(req.ID, tgUser)
|
||||
if change {
|
||||
msg := ""
|
||||
if !req.Telegram {
|
||||
msg = " not"
|
||||
}
|
||||
app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg)
|
||||
jsPrefs[jellyseerr.FieldTelegramEnabled] = req.Telegram
|
||||
}
|
||||
}
|
||||
if dcUser, ok := app.storage.discord[req.ID]; ok {
|
||||
if dcUser, ok := app.storage.GetDiscordKey(req.ID); ok {
|
||||
change := dcUser.Contact != req.Discord
|
||||
dcUser.Contact = req.Discord
|
||||
app.storage.discord[req.ID] = dcUser
|
||||
if err := app.storage.storeDiscordUsers(); err != nil {
|
||||
respondBool(500, false, gc)
|
||||
app.err.Printf("Discord: Failed to store users: %v", err)
|
||||
return
|
||||
}
|
||||
app.storage.SetDiscordKey(req.ID, dcUser)
|
||||
if change {
|
||||
msg := ""
|
||||
if !req.Discord {
|
||||
msg = " not"
|
||||
}
|
||||
app.debug.Printf("Discord: User \"%s\" will%s be notified through Discord.", dcUser.Username, msg)
|
||||
jsPrefs[jellyseerr.FieldDiscordEnabled] = req.Discord
|
||||
}
|
||||
}
|
||||
if mxUser, ok := app.storage.matrix[req.ID]; ok {
|
||||
if mxUser, ok := app.storage.GetMatrixKey(req.ID); ok {
|
||||
change := mxUser.Contact != req.Matrix
|
||||
mxUser.Contact = req.Matrix
|
||||
app.storage.matrix[req.ID] = mxUser
|
||||
if err := app.storage.storeMatrixUsers(); err != nil {
|
||||
respondBool(500, false, gc)
|
||||
app.err.Printf("Matrix: Failed to store users: %v", err)
|
||||
return
|
||||
}
|
||||
app.storage.SetMatrixKey(req.ID, mxUser)
|
||||
if change {
|
||||
msg := ""
|
||||
if !req.Matrix {
|
||||
@@ -387,21 +394,23 @@ func (app *appContext) SetContactMethods(gc *gin.Context) {
|
||||
app.debug.Printf("Matrix: User \"%s\" will%s be notified through Matrix.", mxUser.UserID, msg)
|
||||
}
|
||||
}
|
||||
if email, ok := app.storage.emails[req.ID]; ok {
|
||||
if email, ok := app.storage.GetEmailsKey(req.ID); ok {
|
||||
change := email.Contact != req.Email
|
||||
email.Contact = req.Email
|
||||
app.storage.emails[req.ID] = email
|
||||
if err := app.storage.storeEmails(); err != nil {
|
||||
respondBool(500, false, gc)
|
||||
app.err.Printf("Failed to store emails: %v", err)
|
||||
return
|
||||
}
|
||||
app.storage.SetEmailsKey(req.ID, email)
|
||||
if change {
|
||||
msg := ""
|
||||
if !req.Email {
|
||||
msg = " not"
|
||||
}
|
||||
app.debug.Printf("\"%s\" will%s be notified via Email.", email.Addr, msg)
|
||||
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("Failed to sync contact prefs with Jellyseerr: %v", err)
|
||||
}
|
||||
}
|
||||
respondBool(200, true, gc)
|
||||
@@ -416,19 +425,8 @@ func (app *appContext) SetContactMethods(gc *gin.Context) {
|
||||
// @tags Other
|
||||
func (app *appContext) TelegramVerified(gc *gin.Context) {
|
||||
pin := gc.Param("pin")
|
||||
tokenIndex := -1
|
||||
for i, v := range app.telegram.verifiedTokens {
|
||||
if v.Token == pin {
|
||||
tokenIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
// if tokenIndex != -1 {
|
||||
// length := len(app.telegram.verifiedTokens)
|
||||
// app.telegram.verifiedTokens[length-1], app.telegram.verifiedTokens[tokenIndex] = app.telegram.verifiedTokens[tokenIndex], app.telegram.verifiedTokens[length-1]
|
||||
// app.telegram.verifiedTokens = app.telegram.verifiedTokens[:length-1]
|
||||
// }
|
||||
respondBool(200, tokenIndex != -1, gc)
|
||||
_, 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.
|
||||
@@ -441,32 +439,18 @@ func (app *appContext) TelegramVerified(gc *gin.Context) {
|
||||
// @tags Other
|
||||
func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) {
|
||||
code := gc.Param("invCode")
|
||||
if _, ok := app.storage.invites[code]; !ok {
|
||||
if _, ok := app.storage.GetInvitesKey(code); !ok {
|
||||
respondBool(401, false, gc)
|
||||
return
|
||||
}
|
||||
pin := gc.Param("pin")
|
||||
tokenIndex := -1
|
||||
for i, v := range app.telegram.verifiedTokens {
|
||||
if v.Token == pin {
|
||||
tokenIndex = i
|
||||
break
|
||||
}
|
||||
token, ok := app.telegram.TokenVerified(pin)
|
||||
if ok && app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(token.Username) {
|
||||
app.discord.DeleteVerifiedUser(pin)
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
if app.config.Section("telegram").Key("require_unique").MustBool(false) {
|
||||
for _, u := range app.storage.telegram {
|
||||
if app.telegram.verifiedTokens[tokenIndex].Username == u.Username {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// if tokenIndex != -1 {
|
||||
// length := len(app.telegram.verifiedTokens)
|
||||
// app.telegram.verifiedTokens[length-1], app.telegram.verifiedTokens[tokenIndex] = app.telegram.verifiedTokens[tokenIndex], app.telegram.verifiedTokens[length-1]
|
||||
// app.telegram.verifiedTokens = app.telegram.verifiedTokens[:length-1]
|
||||
// }
|
||||
respondBool(200, tokenIndex != -1, gc)
|
||||
respondBool(200, ok, gc)
|
||||
}
|
||||
|
||||
// @Summary Returns true/false on whether or not a discord PIN was verified. Requires invite code.
|
||||
@@ -479,20 +463,16 @@ func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) {
|
||||
// @tags Other
|
||||
func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
|
||||
code := gc.Param("invCode")
|
||||
if _, ok := app.storage.invites[code]; !ok {
|
||||
if _, ok := app.storage.GetInvitesKey(code); !ok {
|
||||
respondBool(401, false, gc)
|
||||
return
|
||||
}
|
||||
pin := gc.Param("pin")
|
||||
_, ok := app.discord.verifiedTokens[pin]
|
||||
if app.config.Section("discord").Key("require_unique").MustBool(false) {
|
||||
for _, u := range app.storage.discord {
|
||||
if app.discord.verifiedTokens[pin].ID == u.ID {
|
||||
delete(app.discord.verifiedTokens, pin)
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
}
|
||||
user, ok := app.discord.UserVerified(pin)
|
||||
if ok && app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(user.ID) {
|
||||
delete(app.discord.verifiedTokens, pin)
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
respondBool(200, ok, gc)
|
||||
}
|
||||
@@ -512,7 +492,7 @@ func (app *appContext) DiscordServerInvite(gc *gin.Context) {
|
||||
return
|
||||
}
|
||||
code := gc.Param("invCode")
|
||||
if _, ok := app.storage.invites[code]; !ok {
|
||||
if _, ok := app.storage.GetInvitesKey(code); !ok {
|
||||
respondBool(401, false, gc)
|
||||
return
|
||||
}
|
||||
@@ -536,7 +516,7 @@ func (app *appContext) DiscordServerInvite(gc *gin.Context) {
|
||||
// @tags Other
|
||||
func (app *appContext) MatrixSendPIN(gc *gin.Context) {
|
||||
code := gc.Param("invCode")
|
||||
if _, ok := app.storage.invites[code]; !ok {
|
||||
if _, ok := app.storage.GetInvitesKey(code); !ok {
|
||||
respondBool(401, false, gc)
|
||||
return
|
||||
}
|
||||
@@ -547,7 +527,7 @@ func (app *appContext) MatrixSendPIN(gc *gin.Context) {
|
||||
return
|
||||
}
|
||||
if app.config.Section("matrix").Key("require_unique").MustBool(false) {
|
||||
for _, u := range app.storage.matrix {
|
||||
for _, u := range app.storage.GetMatrix() {
|
||||
if req.UserID == u.UserID {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
@@ -574,7 +554,7 @@ func (app *appContext) MatrixSendPIN(gc *gin.Context) {
|
||||
// @tags Other
|
||||
func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
|
||||
code := gc.Param("invCode")
|
||||
if _, ok := app.storage.invites[code]; !ok {
|
||||
if _, ok := app.storage.GetInvitesKey(code); !ok {
|
||||
app.debug.Println("Matrix: Invite code was invalid")
|
||||
respondBool(401, false, gc)
|
||||
return
|
||||
@@ -605,6 +585,7 @@ func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Param MatrixLoginDTO body MatrixLoginDTO true "Username & password."
|
||||
// @Router /matrix/login [post]
|
||||
// @Security Bearer
|
||||
// @tags Other
|
||||
func (app *appContext) MatrixLogin(gc *gin.Context) {
|
||||
var req MatrixLoginDTO
|
||||
@@ -640,12 +621,13 @@ func (app *appContext) MatrixLogin(gc *gin.Context) {
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Param MatrixConnectUserDTO body MatrixConnectUserDTO true "User's Jellyfin ID & Matrix user ID."
|
||||
// @Router /users/matrix [post]
|
||||
// @Security Bearer
|
||||
// @tags Other
|
||||
func (app *appContext) MatrixConnect(gc *gin.Context) {
|
||||
var req MatrixConnectUserDTO
|
||||
gc.BindJSON(&req)
|
||||
if app.storage.matrix == nil {
|
||||
app.storage.matrix = map[string]MatrixUser{}
|
||||
if app.storage.GetMatrix() == nil {
|
||||
app.storage.deprecatedMatrix = matrixStore{}
|
||||
}
|
||||
roomID, encrypted, err := app.matrix.CreateRoom(req.UserID)
|
||||
if err != nil {
|
||||
@@ -653,19 +635,14 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
app.storage.matrix[req.JellyfinID] = MatrixUser{
|
||||
app.storage.SetMatrixKey(req.JellyfinID, MatrixUser{
|
||||
UserID: req.UserID,
|
||||
RoomID: string(roomID),
|
||||
Lang: "en-us",
|
||||
Contact: true,
|
||||
Encrypted: encrypted,
|
||||
}
|
||||
})
|
||||
app.matrix.isEncrypted[roomID] = encrypted
|
||||
if err := app.storage.storeMatrixUsers(); err != nil {
|
||||
app.err.Printf("Failed to store Matrix users: %v", err)
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -676,6 +653,7 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Param username path string true "username to search."
|
||||
// @Router /users/discord/{username} [get]
|
||||
// @Security Bearer
|
||||
// @tags Other
|
||||
func (app *appContext) DiscordGetUsers(gc *gin.Context) {
|
||||
name := gc.Param("username")
|
||||
@@ -702,6 +680,7 @@ func (app *appContext) DiscordGetUsers(gc *gin.Context) {
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Param DiscordConnectUserDTO body DiscordConnectUserDTO true "User's Jellyfin ID & Discord ID."
|
||||
// @Router /users/discord [post]
|
||||
// @Security Bearer
|
||||
// @tags Other
|
||||
func (app *appContext) DiscordConnect(gc *gin.Context) {
|
||||
var req DiscordConnectUserDTO
|
||||
@@ -715,12 +694,25 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
app.storage.discord[req.JellyfinID] = user
|
||||
if err := app.storage.storeDiscordUsers(); err != nil {
|
||||
app.err.Printf("Failed to store Discord users: %v", err)
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
|
||||
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("Failed to sync contact methods with Jellyseerr: %v", 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)
|
||||
}
|
||||
@@ -730,6 +722,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
|
||||
// @Router /users/discord [delete]
|
||||
// @Security Bearer
|
||||
// @Tags Users
|
||||
func (app *appContext) UnlinkDiscord(gc *gin.Context) {
|
||||
var req forUserDTO
|
||||
@@ -739,8 +732,25 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
|
||||
respond(400, "User not found", gc)
|
||||
return
|
||||
} */
|
||||
delete(app.storage.discord, req.ID)
|
||||
app.storage.storeDiscordUsers()
|
||||
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("Failed to sync contact methods with Jellyseerr: %v", 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)
|
||||
}
|
||||
|
||||
@@ -749,6 +759,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
|
||||
// @Router /users/telegram [delete]
|
||||
// @Security Bearer
|
||||
// @Tags Users
|
||||
func (app *appContext) UnlinkTelegram(gc *gin.Context) {
|
||||
var req forUserDTO
|
||||
@@ -758,8 +769,24 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
|
||||
respond(400, "User not found", gc)
|
||||
return
|
||||
} */
|
||||
delete(app.storage.telegram, req.ID)
|
||||
app.storage.storeTelegramUsers()
|
||||
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("Failed to sync contact methods with Jellyseerr: %v", 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)
|
||||
}
|
||||
|
||||
@@ -768,6 +795,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
|
||||
// @Router /users/matrix [delete]
|
||||
// @Security Bearer
|
||||
// @Tags Users
|
||||
func (app *appContext) UnlinkMatrix(gc *gin.Context) {
|
||||
var req forUserDTO
|
||||
@@ -777,7 +805,16 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) {
|
||||
respond(400, "User not found", gc)
|
||||
return
|
||||
} */
|
||||
delete(app.storage.matrix, req.ID)
|
||||
app.storage.storeMatrixUsers()
|
||||
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)
|
||||
}
|
||||
|
||||
69
api-ombi.go
@@ -2,8 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
)
|
||||
|
||||
func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, error) {
|
||||
@@ -17,7 +19,7 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er
|
||||
}
|
||||
username := jfUser.Name
|
||||
email := ""
|
||||
if e, ok := app.storage.emails[jfID]; ok {
|
||||
if e, ok := app.storage.GetEmailsKey(jfID); ok {
|
||||
email = e.Addr
|
||||
}
|
||||
for _, ombiUser := range ombiUsers {
|
||||
@@ -29,7 +31,31 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er
|
||||
return ombiUser, code, err
|
||||
}
|
||||
}
|
||||
return nil, 400, fmt.Errorf("Couldn't find user")
|
||||
return nil, 400, fmt.Errorf("couldn't find user")
|
||||
}
|
||||
|
||||
// Returns a user with the given name who has been imported from Jellyfin/Emby by Ombi
|
||||
func (app *appContext) getOmbiImportedUser(name string) (map[string]interface{}, int, error) {
|
||||
// Ombi User Types: 3/4 = Emby, 5 = Jellyfin
|
||||
ombiUsers, code, err := app.ombi.GetUsers()
|
||||
if err != nil || code != 200 {
|
||||
return nil, code, 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, code, err
|
||||
}
|
||||
}
|
||||
return nil, 400, fmt.Errorf("couldn't find user")
|
||||
}
|
||||
|
||||
// @Summary Get a list of Ombi users.
|
||||
@@ -70,8 +96,9 @@ func (app *appContext) OmbiUsers(gc *gin.Context) {
|
||||
func (app *appContext) SetOmbiProfile(gc *gin.Context) {
|
||||
var req ombiUser
|
||||
gc.BindJSON(&req)
|
||||
profileName := gc.Param("profile")
|
||||
profile, ok := app.storage.profiles[profileName]
|
||||
escapedProfileName := gc.Param("profile")
|
||||
profileName, _ := url.QueryUnescape(escapedProfileName)
|
||||
profile, ok := app.storage.GetProfileKey(profileName)
|
||||
if !ok {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
@@ -83,12 +110,7 @@ func (app *appContext) SetOmbiProfile(gc *gin.Context) {
|
||||
return
|
||||
}
|
||||
profile.Ombi = template
|
||||
app.storage.profiles[profileName] = profile
|
||||
if err := app.storage.storeProfiles(); err != nil {
|
||||
respond(500, "Failed to store profile", gc)
|
||||
app.err.Printf("Failed to store profiles: %v", err)
|
||||
return
|
||||
}
|
||||
app.storage.SetProfileKey(profileName, profile)
|
||||
respondBool(204, true, gc)
|
||||
}
|
||||
|
||||
@@ -102,18 +124,29 @@ func (app *appContext) SetOmbiProfile(gc *gin.Context) {
|
||||
// @Security Bearer
|
||||
// @tags Ombi
|
||||
func (app *appContext) DeleteOmbiProfile(gc *gin.Context) {
|
||||
profileName := gc.Param("profile")
|
||||
profile, ok := app.storage.profiles[profileName]
|
||||
escapedProfileName := gc.Param("profile")
|
||||
profileName, _ := url.QueryUnescape(escapedProfileName)
|
||||
profile, ok := app.storage.GetProfileKey(profileName)
|
||||
if !ok {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
profile.Ombi = nil
|
||||
app.storage.profiles[profileName] = profile
|
||||
if err := app.storage.storeProfiles(); err != nil {
|
||||
respond(500, "Failed to store profile", gc)
|
||||
app.err.Printf("Failed to store profiles: %v", err)
|
||||
return
|
||||
}
|
||||
app.storage.SetProfileKey(profileName, profile)
|
||||
respondBool(204, true, gc)
|
||||
}
|
||||
|
||||
func (app *appContext) applyOmbiProfile(user map[string]interface{}, profile map[string]interface{}) (status int, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
status, err = app.ombi.ModifyUser(user)
|
||||
return
|
||||
}
|
||||
|
||||
140
api-profiles.go
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
// @Summary Get a list of profiles
|
||||
@@ -13,19 +14,29 @@ import (
|
||||
// @Security Bearer
|
||||
// @tags Profiles & Settings
|
||||
func (app *appContext) GetProfiles(gc *gin.Context) {
|
||||
app.storage.loadProfiles()
|
||||
app.debug.Println("Profiles requested")
|
||||
out := getProfilesDTO{
|
||||
DefaultProfile: app.storage.defaultProfile,
|
||||
DefaultProfile: app.storage.GetDefaultProfile().Name,
|
||||
Profiles: map[string]profileDTO{},
|
||||
}
|
||||
for name, p := range app.storage.profiles {
|
||||
out.Profiles[name] = profileDTO{
|
||||
Admin: p.Admin,
|
||||
LibraryAccess: p.LibraryAccess,
|
||||
FromUser: p.FromUser,
|
||||
Ombi: p.Ombi != nil,
|
||||
referralsEnabled := app.config.Section("user_page").Key("referrals").MustBool(false)
|
||||
baseInv := Invite{}
|
||||
for _, p := range app.storage.GetProfiles() {
|
||||
pdto := profileDTO{
|
||||
Admin: p.Admin,
|
||||
LibraryAccess: p.LibraryAccess,
|
||||
FromUser: p.FromUser,
|
||||
Ombi: p.Ombi != nil,
|
||||
Jellyseerr: p.Jellyseerr.Enabled,
|
||||
ReferralsEnabled: false,
|
||||
}
|
||||
if referralsEnabled {
|
||||
err := app.storage.db.Get(p.ReferralTemplateKey, &baseInv)
|
||||
if p.ReferralTemplateKey != "" && err == nil {
|
||||
pdto.ReferralsEnabled = true
|
||||
}
|
||||
}
|
||||
out.Profiles[p.Name] = pdto
|
||||
}
|
||||
gc.JSON(200, out)
|
||||
}
|
||||
@@ -42,20 +53,20 @@ func (app *appContext) SetDefaultProfile(gc *gin.Context) {
|
||||
req := profileChangeDTO{}
|
||||
gc.BindJSON(&req)
|
||||
app.info.Printf("Setting default profile to \"%s\"", req.Name)
|
||||
if _, ok := app.storage.profiles[req.Name]; !ok {
|
||||
if _, ok := app.storage.GetProfileKey(req.Name); !ok {
|
||||
app.err.Printf("Profile not found: \"%s\"", req.Name)
|
||||
respond(500, "Profile not found", gc)
|
||||
return
|
||||
}
|
||||
for name, profile := range app.storage.profiles {
|
||||
if name == req.Name {
|
||||
profile.Admin = true
|
||||
app.storage.profiles[name] = profile
|
||||
app.storage.db.ForEach(&badgerhold.Query{}, func(profile *Profile) error {
|
||||
if profile.Name == req.Name {
|
||||
profile.Default = true
|
||||
} else {
|
||||
profile.Admin = false
|
||||
profile.Default = false
|
||||
}
|
||||
}
|
||||
app.storage.defaultProfile = req.Name
|
||||
app.storage.SetProfileKey(profile.Name, *profile)
|
||||
return nil
|
||||
})
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -79,8 +90,9 @@ func (app *appContext) CreateProfile(gc *gin.Context) {
|
||||
return
|
||||
}
|
||||
profile := Profile{
|
||||
FromUser: user.Name,
|
||||
Policy: user.Policy,
|
||||
FromUser: user.Name,
|
||||
Policy: user.Policy,
|
||||
Homescreen: req.Homescreen,
|
||||
}
|
||||
app.debug.Printf("Creating profile from user \"%s\"", user.Name)
|
||||
if req.Homescreen {
|
||||
@@ -92,10 +104,11 @@ func (app *appContext) CreateProfile(gc *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
app.storage.loadProfiles()
|
||||
app.storage.profiles[req.Name] = profile
|
||||
app.storage.storeProfiles()
|
||||
app.storage.loadProfiles()
|
||||
app.storage.SetProfileKey(req.Name, profile)
|
||||
// Refresh discord bots, profile list
|
||||
if discordEnabled {
|
||||
app.discord.UpdateCommands()
|
||||
}
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@@ -110,12 +123,81 @@ func (app *appContext) DeleteProfile(gc *gin.Context) {
|
||||
req := profileChangeDTO{}
|
||||
gc.BindJSON(&req)
|
||||
name := req.Name
|
||||
if _, ok := app.storage.profiles[name]; ok {
|
||||
if app.storage.defaultProfile == name {
|
||||
app.storage.defaultProfile = ""
|
||||
}
|
||||
delete(app.storage.profiles, name)
|
||||
}
|
||||
app.storage.storeProfiles()
|
||||
app.storage.DeleteProfileKey(name)
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Enable referrals for a profile, sourced from the given invite by its code.
|
||||
// @Produce json
|
||||
// @Param profile path string true "name of profile to enable referrals for."
|
||||
// @Param invite path string true "invite code to create referral template from."
|
||||
// @Param useExpiry path string true "with-expiry or none."
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 400 {object} stringResponse
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @Router /profiles/referral/{profile}/{invite}/{useExpiry} [post]
|
||||
// @Security Bearer
|
||||
// @tags Profiles & Settings
|
||||
func (app *appContext) EnableReferralForProfile(gc *gin.Context) {
|
||||
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("\"%s\": Failed to enable referrals: invite not found", profileName)
|
||||
return
|
||||
}
|
||||
profile, ok := app.storage.GetProfileKey(profileName)
|
||||
if !ok {
|
||||
respond(400, "Invalid profile", gc)
|
||||
app.err.Printf("\"%s\": Failed to enable referrals: profile not found", 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 (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/hrfee/jfa-go/jellyseerr"
|
||||
"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, status, err := app.jf.UserByID(resp.Id, false)
|
||||
if status != 200 || err != nil {
|
||||
app.err.Printf("Failed to get Jellyfin user (%d): %+v\n", status, err)
|
||||
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 {
|
||||
app.debug.Printf("Couldn't get cookies: %s", err)
|
||||
respond(500, "Couldn't fetch cookies", 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() {
|
||||
gcHTML(gc, 404, "404.html", gin.H{
|
||||
"cssClass": app.cssClass,
|
||||
"cssVersion": cssVersion,
|
||||
"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("Failed to parse key: %s", err)
|
||||
fail()
|
||||
// respond(500, "unknownError", gc)
|
||||
return
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
app.err.Printf("Failed to parse key: %s", err)
|
||||
fail()
|
||||
// respond(500, "unknownError", gc)
|
||||
return
|
||||
}
|
||||
expiry := time.Unix(int64(claims["exp"].(float64)), 0)
|
||||
if !(ok && token.Valid && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) {
|
||||
app.err.Printf("Invalid key")
|
||||
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, "/my/account")
|
||||
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.Println("Email list modified")
|
||||
gc.Redirect(http.StatusSeeOther, "/my/account")
|
||||
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)
|
||||
app.debug.Println("Email modification requested")
|
||||
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("Failed to generate confirmation token: %v", err)
|
||||
respond(500, "errorUnknown", gc)
|
||||
return
|
||||
}
|
||||
|
||||
if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) {
|
||||
user, status, err := app.jf.UserByID(id, false)
|
||||
name := ""
|
||||
if status == 200 && err == nil {
|
||||
name = user.Name
|
||||
}
|
||||
app.debug.Printf("%s: Email confirmation required", id)
|
||||
respond(401, "confirmEmail", gc)
|
||||
msg, err := app.email.constructConfirmation("", name, key, app, false)
|
||||
if err != nil {
|
||||
app.err.Printf("%s: Failed to construct confirmation email: %v", name, err)
|
||||
} else if err := app.email.send(msg, req.Email); err != nil {
|
||||
app.err.Printf("%s: Failed to send user confirmation email: %v", name, err)
|
||||
} else {
|
||||
app.info.Printf("%s: Sent user confirmation email to \"%s\"", name, 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.inviteChannelName == "" {
|
||||
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.DeleteVerifiedUser(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("Failed to sync contact methods with Jellyseerr: %v", 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("Failed to sync contact methods with Jellyseerr: %v", 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.Println("Matrix: PIN not found")
|
||||
respondBool(200, false, gc)
|
||||
return
|
||||
}
|
||||
if user.User.UserID != userID {
|
||||
app.debug.Println("Matrix: User ID of PIN didn't match")
|
||||
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("Failed to sync contact methods with Jellyseerr: %v", 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("Failed to sync contact methods with Jellyseerr: %v", 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 == "" {
|
||||
app.debug.Println("Ignoring empty request for PWR")
|
||||
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("Ignoring PWR request: User not found")
|
||||
|
||||
for range timerWait {
|
||||
respondBool(204, true, gc)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
pwr, err = app.GenInternalReset(jfUser.ID)
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to get user from Jellyfin: %v", 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("Failed to construct password reset message for \"%s\": %v", 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("Failed to send password reset message to \"%s\": %v", address, err)
|
||||
} else {
|
||||
app.info.Printf("Sent password reset message to \"%s\"", address)
|
||||
}
|
||||
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 {
|
||||
app.debug.Printf("%s: Change password failed: Invalid password", gc.GetString("jfId"))
|
||||
gc.JSON(400, validation)
|
||||
return
|
||||
}
|
||||
}
|
||||
user, status, err := app.jf.UserByID(gc.GetString("jfId"), false)
|
||||
if status != 200 || err != nil {
|
||||
app.err.Printf("Failed to change password: couldn't find user (%d): %+v", status, err)
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
// Authenticate as user to confirm old password.
|
||||
user, status, err = app.authJf.Authenticate(user.Name, req.Old)
|
||||
if status != 200 || err != nil {
|
||||
respondBool(401, false, gc)
|
||||
return
|
||||
}
|
||||
status, err = app.jf.SetPassword(gc.GetString("jfId"), req.Old, req.New)
|
||||
if (status != 200 && status != 204) || err != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityChangePassword,
|
||||
UserID: user.ID,
|
||||
SourceType: ActivityUser,
|
||||
Source: user.ID,
|
||||
Time: time.Now(),
|
||||
}, gc, true)
|
||||
|
||||
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
||||
func() {
|
||||
ombiUser, status, err := app.getOmbiUser(gc.GetString("jfId"))
|
||||
if status != 200 || err != nil {
|
||||
app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", user.Name, status, err)
|
||||
return
|
||||
}
|
||||
ombiUser["password"] = req.New
|
||||
status, err = app.ombi.ModifyUser(ombiUser)
|
||||
if status != 200 || err != nil {
|
||||
app.err.Printf("Failed to set password for ombi user \"%s\" (%d): %v", ombiUser["userName"], status, err)
|
||||
return
|
||||
}
|
||||
app.debug.Printf("Reset password for ombi user \"%s\"", 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("Couldn't get cookies: %s", 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("Ignoring referral request, couldn't find template.")
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
834
api-users.go
36
api.go
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/itchyny/timefmt-go"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
@@ -113,6 +114,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
|
||||
var req ResetPasswordDTO
|
||||
gc.BindJSON(&req)
|
||||
validation := app.validator.validate(req.Password)
|
||||
captcha := app.config.Section("captcha").Key("enabled").MustBool(false)
|
||||
valid := true
|
||||
for _, val := range validation {
|
||||
if !val {
|
||||
@@ -120,12 +122,18 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
|
||||
}
|
||||
}
|
||||
if !valid || req.PIN == "" {
|
||||
// 200 bcs idk what i did in js
|
||||
app.info.Printf("%s: Password reset failed: Invalid password", req.PIN)
|
||||
gc.JSON(400, validation)
|
||||
return
|
||||
}
|
||||
isInternal := false
|
||||
|
||||
if captcha && !app.verifyCaptcha(req.PIN, req.PIN, req.CaptchaText, true) {
|
||||
app.info.Printf("%s: PWR Failed: Captcha Incorrect", req.PIN)
|
||||
respond(400, "errorCaptcha", gc)
|
||||
return
|
||||
}
|
||||
|
||||
var userID, username string
|
||||
if reset, ok := app.internalPWRs[req.PIN]; ok {
|
||||
isInternal = true
|
||||
@@ -137,12 +145,14 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
|
||||
}
|
||||
userID = reset.ID
|
||||
username = reset.Username
|
||||
|
||||
status, err := app.jf.ResetPasswordAdmin(userID)
|
||||
if !(status == 200 || status == 204) || err != nil {
|
||||
app.err.Printf("Password Reset failed (%d): %v", status, err)
|
||||
respondBool(status, false, gc)
|
||||
return
|
||||
}
|
||||
delete(app.internalPWRs, req.PIN)
|
||||
} else {
|
||||
resp, status, err := app.jf.ResetPassword(req.PIN)
|
||||
if status != 200 || err != nil || !resp.Success {
|
||||
@@ -156,6 +166,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
|
||||
}
|
||||
username = resp.UsersReset[0]
|
||||
}
|
||||
|
||||
var user mediabrowser.User
|
||||
var status int
|
||||
var err error
|
||||
@@ -169,6 +180,15 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityResetPassword,
|
||||
UserID: user.ID,
|
||||
SourceType: ActivityUser,
|
||||
Source: user.ID,
|
||||
Time: time.Now(),
|
||||
}, gc, true)
|
||||
|
||||
prevPassword := req.PIN
|
||||
if isInternal {
|
||||
prevPassword = ""
|
||||
@@ -181,7 +201,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
|
||||
}
|
||||
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
||||
// Silently fail for changing ombi passwords
|
||||
if status != 200 || err != nil {
|
||||
if (status != 200 && status != 204) || err != nil {
|
||||
app.err.Printf("Failed to get user \"%s\" from jellyfin/emby (%d): %v", username, status, err)
|
||||
respondBool(200, true, gc)
|
||||
return
|
||||
@@ -214,7 +234,7 @@ func (app *appContext) GetConfig(gc *gin.Context) {
|
||||
app.info.Println("Config requested")
|
||||
resp := app.configBase
|
||||
// Load language options
|
||||
formOptions := app.storage.lang.Form.getOptions()
|
||||
formOptions := app.storage.lang.User.getOptions()
|
||||
fl := resp.Sections["ui"].Settings["language-form"]
|
||||
fl.Options = formOptions
|
||||
fl.Value = app.config.Section("ui").Key("language-form").MustString("en-us")
|
||||
@@ -268,7 +288,7 @@ func (app *appContext) GetConfig(gc *gin.Context) {
|
||||
val := app.config.Section(sectName).Key(settingName)
|
||||
s := resp.Sections[sectName].Settings[settingName]
|
||||
switch setting.Type {
|
||||
case "text", "email", "select", "password":
|
||||
case "text", "email", "select", "password", "note":
|
||||
s.Value = val.MustString("")
|
||||
case "number":
|
||||
s.Value = val.MustInt(0)
|
||||
@@ -452,8 +472,8 @@ func (app *appContext) GetLanguages(gc *gin.Context) {
|
||||
page := gc.Param("page")
|
||||
resp := langDTO{}
|
||||
switch page {
|
||||
case "form":
|
||||
for key, lang := range app.storage.lang.Form {
|
||||
case "form", "user":
|
||||
for key, lang := range app.storage.lang.User {
|
||||
resp[key] = lang.Meta.Name
|
||||
}
|
||||
case "admin":
|
||||
@@ -494,8 +514,8 @@ func (app *appContext) ServeLang(gc *gin.Context) {
|
||||
if page == "admin" {
|
||||
gc.JSON(200, app.storage.lang.Admin[lang])
|
||||
return
|
||||
} else if page == "form" {
|
||||
gc.JSON(200, app.storage.lang.Form[lang])
|
||||
} else if page == "form" || page == "user" {
|
||||
gc.JSON(200, app.storage.lang.User[lang])
|
||||
return
|
||||
}
|
||||
respondBool(400, false, gc)
|
||||
|
||||
4
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" {
|
||||
|
||||
186
auth.go
@@ -9,21 +9,47 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"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
|
||||
}
|
||||
|
||||
// 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 +57,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,8 +67,9 @@ 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")
|
||||
@@ -55,23 +82,48 @@ func (app *appContext) authenticate(gc *gin.Context) {
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
claims, ok = token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
app.debug.Println("Invalid JWT")
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
expiryUnix := int64(claims["exp"].(float64))
|
||||
if err != nil {
|
||||
app.debug.Printf("Auth denied: %s", err)
|
||||
respond(401, "Unauthorized", gc)
|
||||
ok = false
|
||||
return
|
||||
}
|
||||
expiry := time.Unix(expiryUnix, 0)
|
||||
if !(ok && token.Valid && claims["type"].(string) == "bearer" && expiry.After(time.Now())) {
|
||||
app.debug.Printf("Auth denied: Invalid token")
|
||||
// app.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.debug.Printf("Auth denied: Token was not for admin access")
|
||||
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
|
||||
@@ -84,6 +136,7 @@ func (app *appContext) authenticate(gc *gin.Context) {
|
||||
}
|
||||
gc.Set("jfId", jfID)
|
||||
gc.Set("userId", userID)
|
||||
gc.Set("userMode", false)
|
||||
app.debug.Println("Auth succeeded")
|
||||
gc.Next()
|
||||
}
|
||||
@@ -99,6 +152,44 @@ 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, "Auth denied: blank username/password")
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context, userpage bool) (user mediabrowser.User, ok bool) {
|
||||
ok = false
|
||||
user, status, err := app.authJf.Authenticate(username, password)
|
||||
if status != 200 || err != nil {
|
||||
if status == 401 || status == 400 {
|
||||
app.logIpInfo(gc, userpage, "Auth denied: Invalid username/password (Jellyfin)")
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
if status == 403 {
|
||||
app.logIpInfo(gc, userpage, "Auth denied: Jellyfin account disabled")
|
||||
respond(403, "yourAccountWasDisabled", gc)
|
||||
return
|
||||
}
|
||||
app.err.Printf("Auth failed: Couldn't authenticate with Jellyfin (%d/%s)", status, 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,19 +199,15 @@ 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, "Token requested (login attempt)")
|
||||
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
|
||||
@@ -128,32 +215,25 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
|
||||
}
|
||||
}
|
||||
if !app.jellyfinLogin && !match {
|
||||
app.info.Println("Auth denied: Invalid username/password")
|
||||
app.logIpInfo(gc, false, "Auth denied: Invalid username/password")
|
||||
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("allow_all").MustBool(false) {
|
||||
accountsAdmin := false
|
||||
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
|
||||
if emailStore, ok := app.storage.emails[jfID]; ok {
|
||||
if emailStore, ok := app.storage.GetEmailsKey(jfID); ok {
|
||||
accountsAdmin = emailStore.Admin
|
||||
}
|
||||
accountsAdmin = accountsAdmin || (adminOnly && user.Policy.IsAdministrator)
|
||||
if !accountsAdmin {
|
||||
app.debug.Printf("Auth denied: Users \"%s\" isn't admin", creds[0])
|
||||
app.debug.Printf("Auth denied: Users \"%s\" isn't admin", username)
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
@@ -163,28 +243,23 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
|
||||
newUser := User{
|
||||
UserID: userID,
|
||||
}
|
||||
app.debug.Printf("Token generated for user \"%s\"", creds[0])
|
||||
app.users = append(app.users, newUser)
|
||||
app.debug.Printf("Token generated for user \"%s\"", 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)
|
||||
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()
|
||||
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
|
||||
gc.JSON(200, getTokenDTO{token})
|
||||
}
|
||||
|
||||
// @Summary Grabs an API token using a refresh token from cookies.
|
||||
// @Produce json
|
||||
// @Success 200 {object} getTokenDTO
|
||||
// @Failure 401 {object} stringResponse
|
||||
// @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")
|
||||
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.debug.Printf("getTokenRefresh denied: Couldn't get token: %s", err)
|
||||
respond(400, "Couldn't get token", gc)
|
||||
@@ -203,27 +278,46 @@ func (app *appContext) getTokenRefresh(gc *gin.Context) {
|
||||
respond(400, "Invalid token", gc)
|
||||
return
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
claims, ok = token.Claims.(jwt.MapClaims)
|
||||
expiryUnix := int64(claims["exp"].(float64))
|
||||
if err != nil {
|
||||
app.debug.Printf("getTokenRefresh: Invalid token expiry: %s", err)
|
||||
respond(401, "Invalid token", gc)
|
||||
ok = false
|
||||
return
|
||||
}
|
||||
expiry := time.Unix(expiryUnix, 0)
|
||||
if !(ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now())) {
|
||||
app.debug.Printf("getTokenRefresh: Invalid token: %s", err)
|
||||
app.debug.Printf("getTokenRefresh: Invalid token: %+v", err)
|
||||
respond(401, "Invalid token", gc)
|
||||
ok = false
|
||||
return
|
||||
}
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
// @Summary Grabs an API token using a refresh token from cookies.
|
||||
// @Produce json
|
||||
// @Success 200 {object} getTokenDTO
|
||||
// @Failure 401 {object} stringResponse
|
||||
// @Router /token/refresh [get]
|
||||
// @tags Auth
|
||||
func (app *appContext) getTokenRefresh(gc *gin.Context) {
|
||||
app.logIpInfo(gc, false, "Token requested (refresh token)")
|
||||
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)
|
||||
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()
|
||||
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
|
||||
gc.JSON(200, getTokenDTO{jwt})
|
||||
}
|
||||
|
||||
173
backups.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
BACKUP_PREFIX = "jfa-go-db-"
|
||||
BACKUP_UPLOAD_PREFIX = "upload-"
|
||||
BACKUP_DATEFMT = "2006-01-02T15-04-05"
|
||||
BACKUP_SUFFIX = ".bak"
|
||||
)
|
||||
|
||||
type BackupList struct {
|
||||
files []os.DirEntry
|
||||
dates []time.Time
|
||||
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.dates[i], bl.dates[j] = bl.dates[j], bl.dates[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.dates[i].IsZero() {
|
||||
return false
|
||||
}
|
||||
if bl.dates[j].IsZero() {
|
||||
return true
|
||||
}
|
||||
// Sort by oldest first
|
||||
return bl.dates[j].After(bl.dates[i])
|
||||
}
|
||||
|
||||
// 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("Failed to create backup directory \"%s\": %v\n", path, err)
|
||||
return nil
|
||||
}
|
||||
items, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to read backup directory \"%s\": %v\n", path, err)
|
||||
return nil
|
||||
}
|
||||
backups := &BackupList{}
|
||||
backups.files = items
|
||||
backups.dates = make([]time.Time, len(items))
|
||||
backups.count = 0
|
||||
for i, item := range items {
|
||||
if item.IsDir() || !(strings.HasSuffix(item.Name(), BACKUP_SUFFIX)) {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(item.Name(), BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX), BACKUP_SUFFIX))
|
||||
if err != nil {
|
||||
app.debug.Printf("Failed to parse backup filename \"%s\": %v\n", item.Name(), err)
|
||||
continue
|
||||
}
|
||||
backups.dates[i] = t
|
||||
backups.count++
|
||||
}
|
||||
return backups
|
||||
}
|
||||
|
||||
func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) {
|
||||
toKeep := app.config.Section("backups").Key("keep_n_backups").MustInt(20)
|
||||
fname := BACKUP_PREFIX + time.Now().Local().Format(BACKUP_DATEFMT) + BACKUP_SUFFIX
|
||||
path := app.config.Section("backups").Key("path").String()
|
||||
backups := app.getBackups()
|
||||
if backups == nil {
|
||||
return
|
||||
}
|
||||
toDelete := backups.count + 1 - toKeep
|
||||
// fmt.Printf("toDelete: %d, backCount: %d, keep: %d, length: %d\n", toDelete, backups.count, toKeep, len(backups.files))
|
||||
if toDelete > 0 && toDelete <= backups.count {
|
||||
sort.Sort(backups)
|
||||
for _, item := range backups.files[:toDelete] {
|
||||
fullpath := filepath.Join(path, item.Name())
|
||||
app.debug.Printf("Deleting old backup \"%s\"\n", item.Name())
|
||||
err := os.Remove(fullpath)
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to delete old backup \"%s\": %v\n", fullpath, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
fullpath := filepath.Join(path, fname)
|
||||
f, err := os.Create(fullpath)
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to open backup file \"%s\": %v\n", fullpath, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = app.storage.db.Badger().Backup(f, 0)
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to create backup: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fstat, err := f.Stat()
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to get info on new backup: %v\n", err)
|
||||
return
|
||||
}
|
||||
fileDetails.Size = fileSize(fstat.Size())
|
||||
fileDetails.Name = fname
|
||||
fileDetails.Path = fullpath
|
||||
// fmt.Printf("Created backup %+v\n", fileDetails)
|
||||
return
|
||||
}
|
||||
|
||||
func (app *appContext) loadPendingBackup() {
|
||||
if LOADBAK == "" {
|
||||
return
|
||||
}
|
||||
oldPath := filepath.Join(app.dataPath, "db-"+string(time.Now().Unix())+"-pre-"+filepath.Base(LOADBAK))
|
||||
app.info.Printf("Moving existing database to \"%s\"\n", oldPath)
|
||||
err := os.Rename(app.storage.db_path, oldPath)
|
||||
if err != nil {
|
||||
app.err.Fatalf("Failed to move existing database: %v\n", err)
|
||||
}
|
||||
|
||||
app.ConnectDB()
|
||||
defer app.storage.db.Close()
|
||||
|
||||
f, err := os.Open(LOADBAK)
|
||||
if err != nil {
|
||||
app.err.Fatalf("Failed to open backup file \"%s\": %v\n", LOADBAK, err)
|
||||
}
|
||||
err = app.storage.db.Badger().Load(f, 256)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
app.err.Fatalf("Failed to restore backup file \"%s\": %v\n", LOADBAK, err)
|
||||
}
|
||||
app.info.Printf("Restored backup \"%s\".", 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.debug.Println("Backups: Creating backup")
|
||||
app.makeBackup()
|
||||
},
|
||||
)
|
||||
return d
|
||||
}
|
||||
57
config.go
@@ -8,6 +8,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/hrfee/jfa-go/easyproxy"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
@@ -46,7 +47,7 @@ func (app *appContext) loadConfig() error {
|
||||
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"} {
|
||||
@@ -75,6 +76,10 @@ 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("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, "/"), "!"))
|
||||
@@ -100,6 +105,9 @@ 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("matrix", "topic", "Jellyfin notifications")
|
||||
app.MustSetValue("matrix", "show_on_reg", "true")
|
||||
|
||||
@@ -107,16 +115,37 @@ 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.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))
|
||||
|
||||
LOGIP = app.config.Section("advanced").Key("log_ips").MustBool(false)
|
||||
LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false)
|
||||
|
||||
// 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)
|
||||
|
||||
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 {
|
||||
fmt.Println("SETALLTRUE")
|
||||
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)
|
||||
discordEnabled = app.config.Section("discord").Key("enabled").MustBool(false)
|
||||
@@ -135,6 +164,22 @@ 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("Failed to initialize Proxy: %v\n", err)
|
||||
}
|
||||
app.proxyEnabled = true
|
||||
}
|
||||
|
||||
app.MustSetValue("updates", "enabled", "true")
|
||||
releaseChannel := app.config.Section("updates").Key("channel").String()
|
||||
if app.config.Section("updates").Key("enabled").MustBool(false) {
|
||||
@@ -147,6 +192,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" {
|
||||
@@ -157,9 +205,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 != "" {
|
||||
@@ -169,11 +214,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")
|
||||
|
||||
@@ -173,22 +173,22 @@
|
||||
"description": "Enable this to use Jellyfin users instead of the below username and pw."
|
||||
},
|
||||
"admin_only": {
|
||||
"name": "Allow admin users only",
|
||||
"name": "Allow admin users only on \"Admin\" pages",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"depends_true": "jellyfin_login",
|
||||
"type": "bool",
|
||||
"value": true,
|
||||
"description": "Allows only admin users on Jellyfin to access the admin page."
|
||||
"description": "Allows only admin users on Jellyfin to access the admin page. Doesn't apply to the \"My Accounts\" page."
|
||||
},
|
||||
"allow_all": {
|
||||
"name": "Allow all users to login",
|
||||
"name": "Allow all users to login to \"Admin\" pages",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"depends_true": "jellyfin_login",
|
||||
"type": "bool",
|
||||
"value": false,
|
||||
"description": "Allow all Jellyfin users to access jfa-go. Not recommended, add individual users in the Accounts tab instead."
|
||||
"description": "Allow all Jellyfin users to access jfa-go. Not recommended, add individual users in the Accounts tab instead. Doesn't apply to the \"My Accounts\" page."
|
||||
},
|
||||
"username": {
|
||||
"name": "Web Username",
|
||||
@@ -247,7 +247,7 @@
|
||||
"requires_restart": false,
|
||||
"type": "text",
|
||||
"value": "Your account has been created. Click below to continue to Jellyfin.",
|
||||
"description": "Displayed when a user creates an account"
|
||||
"description": "Displayed when a user creates an account. Use the \"post-signup card\" in the Message editor for more control."
|
||||
},
|
||||
"url_base": {
|
||||
"name": "Reverse Proxy subfolder",
|
||||
@@ -273,7 +273,19 @@
|
||||
"type": "bool",
|
||||
"value": false,
|
||||
"advanced": true,
|
||||
"description": "Navigate directly to the above URL instead of needing the user to click \"Continue\"."
|
||||
"description": "Navigate directly to the above URL instead of needing the user to click \"Continue\". Overrides the post-signup card."
|
||||
},
|
||||
"login_appearance": {
|
||||
"name": "Login screen appearance",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"type": "select",
|
||||
"options": [
|
||||
["clear", "Transparent"],
|
||||
["opaque", "Opaque"]
|
||||
],
|
||||
"value": "clear",
|
||||
"description": "Appearance of the Admin login screen."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -281,9 +293,33 @@
|
||||
"order": [],
|
||||
"meta": {
|
||||
"name": "Advanced",
|
||||
"description": "Advanced settings."
|
||||
"description": "Advanced settings.",
|
||||
"advanced": true
|
||||
},
|
||||
"settings": {
|
||||
"log_ips": {
|
||||
"name": "Log IPs accessing Admin Page",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"value": false,
|
||||
"description": "Log IP addresses of admins and admin page requests in console and in activities. See notice below on legality."
|
||||
},
|
||||
"log_ips_users": {
|
||||
"name": "Log IPs accessing User Page",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"value": false,
|
||||
"description": "Log IP addresses of users in console and in activities. See notice below on legality."
|
||||
},
|
||||
"ip_note": {
|
||||
"name": "Logging IPs:",
|
||||
"type": "note",
|
||||
"value": "",
|
||||
"required": "false",
|
||||
"description": "Logging IP addresses through jfa-go may violate GDPR or other privacy regulations, as IPs are linked to account information. Enable at your own risk."
|
||||
},
|
||||
"tls": {
|
||||
"name": "TLS/HTTP2",
|
||||
"required": false,
|
||||
@@ -318,6 +354,212 @@
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Path to .key file. See jfa-go wiki for more info."
|
||||
},
|
||||
"auth_retry_count": {
|
||||
"name": "Initial auth retry count",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "number",
|
||||
"value": 6,
|
||||
"description": "Number of times to retry initial connection to Jellyfin before failing."
|
||||
},
|
||||
"auth_retry_gap": {
|
||||
"name": "Initial auth retry gap (seconds)",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "number",
|
||||
"value": 10,
|
||||
"description": "Duration in seconds to wait between connection retries."
|
||||
},
|
||||
"proxy": {
|
||||
"name": "Use Proxy",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"value": false,
|
||||
"description": "Whether or not to use a HTTP/SOCKS5 Proxy."
|
||||
},
|
||||
"proxy_protocol": {
|
||||
"name": "Proxy Protocol",
|
||||
"depends_true": "proxy",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "select",
|
||||
"options": [
|
||||
["http", "HTTP"],
|
||||
["socks", "SOCKS5"]
|
||||
],
|
||||
"value": "http",
|
||||
"description": "Protocol to use for proxy connection."
|
||||
},
|
||||
"proxy_address": {
|
||||
"name": "Proxy Address",
|
||||
"depends_true": "proxy",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Proxy address, including port."
|
||||
},
|
||||
"proxy_user": {
|
||||
"name": "Proxy Username",
|
||||
"depends_true": "proxy",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Leave blank for no Authentication."
|
||||
},
|
||||
"proxy_password": {
|
||||
"name": "Proxy Password",
|
||||
"depends_true": "proxy",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "password",
|
||||
"value": "",
|
||||
"description": "Leave blank for no Authentication."
|
||||
},
|
||||
"debug_log_emails": {
|
||||
"name": "Debug Storage Logging: Emails",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "select",
|
||||
"options": [
|
||||
["none", "None"],
|
||||
["all", "All Writes"],
|
||||
["deletion", "Deletion Only*"]
|
||||
],
|
||||
"value": "none",
|
||||
"description": "Extra debug logging for writes to the database. *: Deletion also includes blanking out major fields, e.g. an email address."
|
||||
},
|
||||
"debug_log_discord": {
|
||||
"name": "Debug Storage Logging: Discord",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "select",
|
||||
"options": [
|
||||
["none", "None"],
|
||||
["all", "All Writes"],
|
||||
["deletion", "Deletion Only*"]
|
||||
],
|
||||
"value": "none",
|
||||
"description": "Extra debug logging for writes to the database. *: Deletion also includes blanking out major fields, e.g. an email address."
|
||||
},
|
||||
"debug_log_telegram": {
|
||||
"name": "Debug Storage Logging: Telegram",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "select",
|
||||
"options": [
|
||||
["none", "None"],
|
||||
["all", "All Writes"],
|
||||
["deletion", "Deletion Only*"]
|
||||
],
|
||||
"value": "none",
|
||||
"description": "Extra debug logging for writes to the database. *: Deletion also includes blanking out major fields, e.g. an email address."
|
||||
},
|
||||
"debug_log_matrix": {
|
||||
"name": "Debug Storage Logging: Matrix",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "select",
|
||||
"options": [
|
||||
["none", "None"],
|
||||
["all", "All Writes"],
|
||||
["deletion", "Deletion Only*"]
|
||||
],
|
||||
"value": "none",
|
||||
"description": "Extra debug logging for writes to the database. *: Deletion also includes blanking out major fields, e.g. an email address."
|
||||
},
|
||||
"debug_log_invites": {
|
||||
"name": "Debug Storage Logging: Invites",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "select",
|
||||
"options": [
|
||||
["none", "None"],
|
||||
["all", "All Writes"],
|
||||
["deletion", "Deletion Only*"]
|
||||
],
|
||||
"value": "none",
|
||||
"description": "Extra debug logging for writes to the database. *: Deletion also includes blanking out major fields, e.g. an email address."
|
||||
},
|
||||
"debug_log_announcements": {
|
||||
"name": "Debug Storage Logging: Announcements",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "select",
|
||||
"options": [
|
||||
["none", "None"],
|
||||
["all", "All Writes"],
|
||||
["deletion", "Deletion Only*"]
|
||||
],
|
||||
"value": "none",
|
||||
"description": "Extra debug logging for writes to the database. *: Deletion also includes blanking out major fields, e.g. an email address."
|
||||
},
|
||||
"debug_log_expiries": {
|
||||
"name": "Debug Storage Logging: User Expiries",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "select",
|
||||
"options": [
|
||||
["none", "None"],
|
||||
["all", "All Writes"],
|
||||
["deletion", "Deletion Only*"]
|
||||
],
|
||||
"value": "none",
|
||||
"description": "Extra debug logging for writes to the database. *: Deletion also includes blanking out major fields, e.g. an email address."
|
||||
},
|
||||
"debug_log_profiles": {
|
||||
"name": "Debug Storage Logging: Profiles",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "select",
|
||||
"options": [
|
||||
["none", "None"],
|
||||
["all", "All Writes"],
|
||||
["deletion", "Deletion Only*"]
|
||||
],
|
||||
"value": "none",
|
||||
"description": "Extra debug logging for writes to the database. *: Deletion also includes blanking out major fields, e.g. an email address."
|
||||
},
|
||||
"debug_log_custom_content": {
|
||||
"name": "Debug Storage Logging: Custom Message Content",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "select",
|
||||
"options": [
|
||||
["none", "None"],
|
||||
["all", "All Writes"],
|
||||
["deletion", "Deletion Only*"]
|
||||
],
|
||||
"value": "none",
|
||||
"description": "Extra debug logging for writes to the database. *: Deletion also includes blanking out major fields, e.g. an email address."
|
||||
}
|
||||
}
|
||||
},
|
||||
"activity_log": {
|
||||
"order": [],
|
||||
"meta": {
|
||||
"name": "Activity Log",
|
||||
"description": "Settings for data retention of the activity log."
|
||||
},
|
||||
"settings": {
|
||||
"keep_n_records": {
|
||||
"name": "Number of records to keep",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "number",
|
||||
"value": 1000,
|
||||
"description": "How many of the most recent activities to keep. Set to 0 to disable."
|
||||
},
|
||||
"delete_after_days": {
|
||||
"name": "Delete activities older than (days):",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "number",
|
||||
"value": 90,
|
||||
"description": "If an activity was created this many days ago, it will be deleted. Set to 0 to disable."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -335,6 +577,137 @@
|
||||
"type": "bool",
|
||||
"value": false,
|
||||
"description": "Enable a CAPTCHA on the account creation form."
|
||||
},
|
||||
"recaptcha": {
|
||||
"name": "Use Google reCAPTCHA",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"depends_true": "enabled",
|
||||
"value": false,
|
||||
"description": "More reliable, but requires some setup. See jfa-go wiki for more info."
|
||||
},
|
||||
"recaptcha_site_key": {
|
||||
"name": "reCAPTCHA Site Key",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"depends_true": "recaptcha",
|
||||
"value": "",
|
||||
"description": "Site Key, see jfa-go wiki for how to acquire one."
|
||||
},
|
||||
"recaptcha_secret_key": {
|
||||
"name": "reCAPTCHA Secret Key",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"depends_true": "recaptcha",
|
||||
"value": "",
|
||||
"description": "Secret Key, see jfa-go wiki for how to acquire one."
|
||||
},
|
||||
"recaptcha_hostname": {
|
||||
"name": "Hostname",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"depends_true": "recaptcha",
|
||||
"value": "",
|
||||
"description": "Public host-name of jfa-go, e.g. \"site.com\". Don't include any subpaths."
|
||||
}
|
||||
}
|
||||
},
|
||||
"user_page": {
|
||||
"order": [],
|
||||
"meta": {
|
||||
"name": "User Page/\"My Account\"",
|
||||
"description": "The User Page (My Account) allows users to access and modify info directly, such as changing/adding contact methods, seeing their expiry date, sending referrals or changing their password. Password resets can also be initiated from here, given a contact method or username. Access control settings set in \"General\" do not apply to this page, nor does \"Access jfa-go\" in the Accounts tab.",
|
||||
"depends_true": "ui|jellyfin_login"
|
||||
},
|
||||
"settings": {
|
||||
"enabled": {
|
||||
"name": "Enabled",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"value": true
|
||||
},
|
||||
"jellyfin_login_note": {
|
||||
"name": "Note:",
|
||||
"type": "note",
|
||||
"value": "",
|
||||
"depends_true": "enabled",
|
||||
"required": "false",
|
||||
"description": "Jellyfin Login must be enabled to use this feature, and password resets with a link must be enabled for self-service.",
|
||||
"style": "critical"
|
||||
},
|
||||
"edit_note": {
|
||||
"name": "Message Cards:",
|
||||
"type": "note",
|
||||
"value": "",
|
||||
"depends_true": "enabled",
|
||||
"required": "false",
|
||||
"description": "Click the edit icon next to the \"User Page\" Setting to add custom Markdown messages that will be shown to the user. Note message cards are not private, little effort is required for anyone to view them."
|
||||
},
|
||||
"show_link": {
|
||||
"name": "Show Link on Admin Login page",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "enabled",
|
||||
"type": "bool",
|
||||
"value": true,
|
||||
"description": "Whether or not to show a link to the \"My Account\" page on the admin login screen, to direct lost users."
|
||||
},
|
||||
"referrals": {
|
||||
"name": "User Referrals",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"depends_true": "enabled",
|
||||
"type": "bool",
|
||||
"value": true,
|
||||
"description": "Users are given their own \"invite\" to send to others."
|
||||
},
|
||||
"referrals_note": {
|
||||
"name": "Using Referrals:",
|
||||
"type": "note",
|
||||
"value": "",
|
||||
"depends_true": "referrals",
|
||||
"required": "false",
|
||||
"description": "Create an invite with your desired settings, then either assign it to a user in the accounts tab, or to a profile in settings."
|
||||
},
|
||||
"allow_pwr_username": {
|
||||
"name": "Allow PWR with username",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"depends_true": "enabled",
|
||||
"type": "bool",
|
||||
"value": true,
|
||||
"description": "Allow users to start a Password Reset by inputting their username."
|
||||
},
|
||||
"allow_pwr_email": {
|
||||
"name": "Allow PWR with email address",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"depends_true": "enabled",
|
||||
"type": "bool",
|
||||
"value": true,
|
||||
"description": "Allow users to start a Password Reset by inputting their email address."
|
||||
},
|
||||
"allow_pwr_contact_method": {
|
||||
"name": "Allow PWR with Discord/Telegram/Matrix",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"depends_true": "enabled",
|
||||
"type": "bool",
|
||||
"value": true,
|
||||
"description": "Allow users to start a Password Reset by inputting their Discord/Telegram/Matrix username/id."
|
||||
},
|
||||
"pwr_note": {
|
||||
"name": "PWR Methods:",
|
||||
"type": "note",
|
||||
"depends_true": "enabled",
|
||||
"value": "",
|
||||
"required": "false",
|
||||
"description": "Select at least one PWR initiation method. If none are selected, all will be enabled."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -430,6 +803,14 @@
|
||||
"type": "text",
|
||||
"value": "Need help? contact me.",
|
||||
"description": "Message displayed at bottom of emails."
|
||||
},
|
||||
"edit_note": {
|
||||
"name": "Customize Messages:",
|
||||
"type": "note",
|
||||
"value": "",
|
||||
"depends_true": "enabled",
|
||||
"required": "false",
|
||||
"description": "Click the edit icon next to the \"Messages/Notifications\" Setting to customize the messages sent to users with Markdown."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -622,6 +1003,22 @@
|
||||
"type": "bool",
|
||||
"value": true,
|
||||
"description": "Warning, disabling this makes you much more vulnerable to man-in-the-middle attacks"
|
||||
},
|
||||
"auth_type": {
|
||||
"name": "Authentication type",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"advanced": false,
|
||||
"type": "select",
|
||||
"options": [
|
||||
["0", "Plain"],
|
||||
["1", "Login"],
|
||||
["2", "CRAM-MD5"],
|
||||
["3", "None"],
|
||||
["4", "Auto"]
|
||||
],
|
||||
"value": 4,
|
||||
"description": "SMTP authentication method"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -918,6 +1315,14 @@
|
||||
"value": true,
|
||||
"description": "Enable to store provided email addresses, monitor Jellyfin directory for pw-resets, and send reset pins"
|
||||
},
|
||||
"pwr_note": {
|
||||
"name": "Setup:",
|
||||
"type": "note",
|
||||
"value": "",
|
||||
"depends_true": "enabled",
|
||||
"required": "false",
|
||||
"description": "There are multiple ways password resets can be set up. See the <a href=\"https://wiki.jfa-go.com/docs/pwr/\" target=\"_blank\">wiki page</a> for more information."
|
||||
},
|
||||
"watch_directory": {
|
||||
"name": "Jellyfin directory",
|
||||
"required": false,
|
||||
@@ -1175,6 +1580,108 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"jellyseerr": {
|
||||
"order": [],
|
||||
"meta": {
|
||||
"name": "Jellyseerr Integration",
|
||||
"description": "Connect to Jellyseerr to automatically trigger the import of users on account creation, and to automatically link contact methods (email, discord and telegram). A template must be added to a User Profile for accounts to be created."
|
||||
},
|
||||
"settings": {
|
||||
"enabled": {
|
||||
"name": "Enabled",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"value": false,
|
||||
"description": "Enable the Jellyseerr integration."
|
||||
},
|
||||
"usertype_note": {
|
||||
"name": "Password Changes:",
|
||||
"type": "note",
|
||||
"value": "",
|
||||
"depends_true": "enabled",
|
||||
"required": "false",
|
||||
"description": "Ensure existing users on Jellyseerr are \"Jellyfin User\"s not \"Local User\"s, as password changes are not synced with Jellyseerr."
|
||||
},
|
||||
"server": {
|
||||
"name": "URL",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "localhost:5000",
|
||||
"depends_true": "enabled",
|
||||
"description": "Jellyseerr server URL."
|
||||
},
|
||||
"api_key": {
|
||||
"name": "API Key",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"depends_true": "enabled",
|
||||
"description": "API Key. Get this from the first tab in Jellyseerr's settings."
|
||||
},
|
||||
"import_existing": {
|
||||
"name": "Import existing users to Jellyseerr",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"value": false,
|
||||
"depends_true": "enabled",
|
||||
"description": "Existing users (and those created outside jfa-go) will have their contact info imported to Jellyseerr."
|
||||
},
|
||||
"constraints_note": {
|
||||
"name": "Unique Emails:",
|
||||
"type": "note",
|
||||
"value": "",
|
||||
"depends_true": "import_existing",
|
||||
"required": "false",
|
||||
"description": "Jellyseerr requires email addresses to be unique. If this is not the case, you may see errors in jfa-go's logs. You can require unique addresses in Settings > Email."
|
||||
}
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
"order": [],
|
||||
"meta": {
|
||||
"name": "Backups",
|
||||
"description": "Settings for database backups. Press the \"Backups\" button above to create, download and restore backups."
|
||||
},
|
||||
"settings": {
|
||||
"enabled": {
|
||||
"name": "Scheduled Backups",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"value": false,
|
||||
"description": "Enable to generate database backups on a schedule."
|
||||
},
|
||||
"path": {
|
||||
"name": "Backup Path",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Path to directory to store backups in. defaults to <data_directory>/backups."
|
||||
},
|
||||
"every_n_minutes": {
|
||||
"name": "Backup frequency (Minutes)",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"depends_true": "enabled",
|
||||
"type": "number",
|
||||
"value": 1440,
|
||||
"description": "Backup after this many minutes has passed since the last. Resets every restart."
|
||||
},
|
||||
"keep_n_backups": {
|
||||
"name": "Number of backups to keep",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "number",
|
||||
"value": 20,
|
||||
"description": "Number of most recent backups to keep. Once this is hit, the oldest backup will be deleted before doing a new one."
|
||||
}
|
||||
}
|
||||
},
|
||||
"welcome_email": {
|
||||
"order": [],
|
||||
"meta": {
|
||||
@@ -1266,7 +1773,7 @@
|
||||
"order": [],
|
||||
"meta": {
|
||||
"name": "User Expiry",
|
||||
"description": "When set on an invite, users will be deleted or disabled a specified amount of time after they create their account."
|
||||
"description": "When set on an invite, users will be deleted or disabled a specified amount of time after they create their account. Expiries can also be set and extended for invididual users, optionally with a message why."
|
||||
},
|
||||
"settings": {
|
||||
"behaviour": {
|
||||
@@ -1318,6 +1825,35 @@
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Path to custom email in plain text"
|
||||
},
|
||||
"adjustment_subject": {
|
||||
"name": "Adjustment: email subject",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "messages|enabled",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Subject of adjustment emails, sent optionally when setting/extending an expiry."
|
||||
},
|
||||
"adjustment_email_html": {
|
||||
"name": "Adjustment: Custom email (HTML)",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"advanced": true,
|
||||
"depends_true": "messages|enabled",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Path to custom email html"
|
||||
},
|
||||
"adjustment_email_text": {
|
||||
"name": "Adjustment: Custom email (plaintext)",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"advanced": true,
|
||||
"depends_true": "messages|enabled",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Path to custom email in plain text"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1499,6 +2035,14 @@
|
||||
"value": "",
|
||||
"description": "JSON file generated by program in settings, different from email_html/email_text. See wiki for more info."
|
||||
},
|
||||
"custom_user_page_content": {
|
||||
"name": "Custom user page content",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "JSON file generated by program in settings, containing user page messages. See wiki for more info."
|
||||
},
|
||||
"telegram_users": {
|
||||
"name": "Telegram users",
|
||||
"required": false,
|
||||
|
||||
179
css/base.css
@@ -3,6 +3,7 @@
|
||||
@import "./dark.css";
|
||||
@import "./tooltip.css";
|
||||
@import "./loader.css";
|
||||
@import "./fonts.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@@ -13,6 +14,10 @@
|
||||
--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;
|
||||
}
|
||||
|
||||
.light {
|
||||
@@ -24,11 +29,11 @@
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background-color: #101010;
|
||||
background-color: var(--bg-dark);
|
||||
}
|
||||
|
||||
html:not(.dark) body {
|
||||
background-color: #fff;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
|
||||
.dark select, .dark option, .dark input {
|
||||
@@ -101,48 +106,6 @@ div.card:contains(section.banner.footer) {
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.al {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ar {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ac {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.w-100 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.h-100 {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.align-top {
|
||||
align-items: top;
|
||||
}
|
||||
|
||||
.flex-expand {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-row-group {
|
||||
display: block;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -167,23 +130,7 @@ span.sm:not(.heading) {
|
||||
margin: .25rem;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flex-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.flex-form {
|
||||
flex: 1;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Who knows for half of these to be honest */
|
||||
@media screen and (max-width: 400px) {
|
||||
.row {
|
||||
flex-direction: column;
|
||||
@@ -214,69 +161,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 */
|
||||
}
|
||||
@@ -295,10 +179,6 @@ sup.\~critical, .text-critical {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex-auto {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.center {
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -307,14 +187,6 @@ sup.\~critical, .text-critical {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.no-lp {
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.focused {
|
||||
display: block;
|
||||
}
|
||||
@@ -411,7 +283,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;
|
||||
@@ -485,6 +366,15 @@ 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;
|
||||
@@ -560,3 +450,20 @@ div.card:contains(section.banner.footer) {
|
||||
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;
|
||||
}
|
||||
|
||||
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,6 +10,24 @@
|
||||
background-color: rgba(0,0,0,40%);
|
||||
}
|
||||
|
||||
.wall {
|
||||
position: fixed;
|
||||
z-index: 11;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
html.dark .wall {
|
||||
background-color: var(--bg-dark);
|
||||
}
|
||||
|
||||
html:not(.dark) .wall {
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
float: right;
|
||||
color: #aaa;
|
||||
|
||||
@@ -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,23 @@
|
||||
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.darker .content {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.tooltip.right .content {
|
||||
left: 120%;
|
||||
}
|
||||
@@ -31,6 +43,10 @@
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tooltip:hover .content {
|
||||
.tooltip:hover .content,
|
||||
.tooltip:focus .content,
|
||||
.tooltip:focus-within .content
|
||||
{
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
211
daemon.go
@@ -1,160 +1,157 @@
|
||||
package main
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/dgraph-io/badger/v3"
|
||||
"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("Housekeeping: removing unused email addresses")
|
||||
users, status, err := app.jf.GetUsers(false)
|
||||
if status != 200 || err != nil || len(users) == 0 {
|
||||
app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err)
|
||||
return
|
||||
}
|
||||
// Rebuild email storage to from existing users to reduce time complexity
|
||||
emails := map[string]EmailAddress{}
|
||||
for _, user := range users {
|
||||
if email, ok := app.storage.emails[user.ID]; ok {
|
||||
emails[user.ID] = email
|
||||
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
|
||||
}
|
||||
}
|
||||
app.storage.emails = emails
|
||||
app.storage.storeEmails()
|
||||
}
|
||||
|
||||
// clearDiscord does the same as clearEmails, but for Discord Users.
|
||||
func (app *appContext) clearDiscord() {
|
||||
app.debug.Println("Housekeeping: removing unused Discord IDs")
|
||||
users, status, err := app.jf.GetUsers(false)
|
||||
if status != 200 || err != nil || len(users) == 0 {
|
||||
app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err)
|
||||
return
|
||||
}
|
||||
// Rebuild discord storage to from existing users to reduce time complexity
|
||||
dcUsers := map[string]DiscordUser{}
|
||||
for _, user := range users {
|
||||
if dcUser, ok := app.storage.discord[user.ID]; ok {
|
||||
dcUsers[user.ID] = dcUser
|
||||
discordUsers := app.storage.GetDiscord()
|
||||
for _, discordUser := range discordUsers {
|
||||
_, _, 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:
|
||||
app.storage.DeleteDiscordKey(discordUser.JellyfinID)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
app.storage.discord = dcUsers
|
||||
app.storage.storeDiscordUsers()
|
||||
}
|
||||
|
||||
// clearMatrix does the same as clearEmails, but for Matrix Users.
|
||||
func (app *appContext) clearMatrix() {
|
||||
app.debug.Println("Housekeeping: removing unused Matrix IDs")
|
||||
users, status, err := app.jf.GetUsers(false)
|
||||
if status != 200 || err != nil || len(users) == 0 {
|
||||
app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err)
|
||||
return
|
||||
}
|
||||
// Rebuild matrix storage to from existing users to reduce time complexity
|
||||
mxUsers := map[string]MatrixUser{}
|
||||
for _, user := range users {
|
||||
if mxUser, ok := app.storage.matrix[user.ID]; ok {
|
||||
mxUsers[user.ID] = mxUser
|
||||
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
|
||||
}
|
||||
}
|
||||
app.storage.matrix = mxUsers
|
||||
app.storage.storeMatrixUsers()
|
||||
}
|
||||
|
||||
// clearTelegram does the same as clearEmails, but for Telegram Users.
|
||||
func (app *appContext) clearTelegram() {
|
||||
app.debug.Println("Housekeeping: removing unused Telegram IDs")
|
||||
users, status, err := app.jf.GetUsers(false)
|
||||
if status != 200 || err != nil || len(users) == 0 {
|
||||
app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err)
|
||||
return
|
||||
}
|
||||
// Rebuild telegram storage to from existing users to reduce time complexity
|
||||
tgUsers := map[string]TelegramUser{}
|
||||
for _, user := range users {
|
||||
if tgUser, ok := app.storage.telegram[user.ID]; ok {
|
||||
tgUsers[user.ID] = tgUser
|
||||
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
|
||||
}
|
||||
}
|
||||
app.storage.telegram = tgUsers
|
||||
app.storage.storeTelegramUsers()
|
||||
}
|
||||
|
||||
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
|
||||
|
||||
type housekeepingDaemon struct {
|
||||
Stopped bool
|
||||
ShutdownChannel chan string
|
||||
Interval time.Duration
|
||||
period time.Duration
|
||||
jobs []func(app *appContext)
|
||||
app *appContext
|
||||
}
|
||||
|
||||
func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemon {
|
||||
daemon := housekeepingDaemon{
|
||||
Stopped: false,
|
||||
ShutdownChannel: make(chan string),
|
||||
Interval: interval,
|
||||
period: interval,
|
||||
app: app,
|
||||
func (app *appContext) clearPWRCaptchas() {
|
||||
app.debug.Println("Housekeeping: Clearing old PWR Captchas")
|
||||
captchas := map[string]Captcha{}
|
||||
for k, capt := range app.pwrCaptchas {
|
||||
if capt.Generated.Add(CAPTCHA_VALIDITY * time.Second).After(time.Now()) {
|
||||
captchas[k] = capt
|
||||
}
|
||||
}
|
||||
daemon.jobs = []func(app *appContext){func(app *appContext) {
|
||||
app.debug.Println("Housekeeping: Checking for expired invites")
|
||||
app.checkInvites()
|
||||
}}
|
||||
app.pwrCaptchas = captchas
|
||||
}
|
||||
|
||||
func (app *appContext) clearActivities() {
|
||||
app.debug.Println("Housekeeping: Cleaning up Activity log...")
|
||||
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("Activities: Delete txn was too big, doing it manually.")
|
||||
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("Housekeeping: Checking for expired invites")
|
||||
app.checkInvites()
|
||||
},
|
||||
func(app *appContext) { app.clearActivities() },
|
||||
)
|
||||
|
||||
d.Name("Housekeeping daemon")
|
||||
|
||||
clearEmail := app.config.Section("email").Key("require_unique").MustBool(false)
|
||||
clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false)
|
||||
clearTelegram := app.config.Section("telegram").Key("require_unique").MustBool(false)
|
||||
clearMatrix := app.config.Section("matrix").Key("require_unique").MustBool(false)
|
||||
clearPWR := app.config.Section("captcha").Key("enabled").MustBool(false) && !app.config.Section("captcha").Key("recaptcha").MustBool(false)
|
||||
|
||||
if clearEmail || clearDiscord || clearTelegram || clearMatrix {
|
||||
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.jf.CacheExpiry = time.Now() })
|
||||
d.appendJobs(func(app *appContext) { app.jf.CacheExpiry = time.Now() })
|
||||
}
|
||||
|
||||
if clearEmail {
|
||||
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearEmails() })
|
||||
d.appendJobs(func(app *appContext) { app.clearEmails() })
|
||||
}
|
||||
if clearDiscord {
|
||||
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearDiscord() })
|
||||
d.appendJobs(func(app *appContext) { app.clearDiscord() })
|
||||
}
|
||||
if clearTelegram {
|
||||
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearTelegram() })
|
||||
d.appendJobs(func(app *appContext) { app.clearTelegram() })
|
||||
}
|
||||
if clearMatrix {
|
||||
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearMatrix() })
|
||||
d.appendJobs(func(app *appContext) { app.clearMatrix() })
|
||||
}
|
||||
if clearPWR {
|
||||
d.appendJobs(func(app *appContext) { app.clearPWRCaptchas() })
|
||||
}
|
||||
|
||||
return &daemon
|
||||
}
|
||||
|
||||
func (rt *housekeepingDaemon) run() {
|
||||
rt.app.info.Println("Invite daemon started")
|
||||
for {
|
||||
select {
|
||||
case <-rt.ShutdownChannel:
|
||||
rt.ShutdownChannel <- "Down"
|
||||
return
|
||||
case <-time.After(rt.period):
|
||||
break
|
||||
}
|
||||
started := time.Now()
|
||||
rt.app.storage.loadInvites()
|
||||
|
||||
for _, job := range rt.jobs {
|
||||
job(rt.app)
|
||||
}
|
||||
|
||||
finished := time.Now()
|
||||
duration := finished.Sub(started)
|
||||
rt.period = rt.Interval - duration
|
||||
}
|
||||
}
|
||||
|
||||
func (rt *housekeepingDaemon) Shutdown() {
|
||||
rt.Stopped = true
|
||||
rt.ShutdownChannel <- "Down"
|
||||
<-rt.ShutdownChannel
|
||||
close(rt.ShutdownChannel)
|
||||
return d
|
||||
}
|
||||
|
||||
302
discord.go
@@ -3,8 +3,10 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dg "github.com/bwmarrin/discordgo"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
type DiscordDaemon struct {
|
||||
@@ -12,8 +14,8 @@ type DiscordDaemon struct {
|
||||
ShutdownChannel chan string
|
||||
bot *dg.Session
|
||||
username string
|
||||
tokens []string
|
||||
verifiedTokens map[string]DiscordUser // Map of tokens to discord users.
|
||||
tokens map[string]VerifToken // Map of pins to tokens.
|
||||
verifiedTokens map[string]DiscordUser // Map of token pins to discord users.
|
||||
channelID, channelName, inviteChannelID, inviteChannelName string
|
||||
guildID string
|
||||
serverChannelName, serverName string
|
||||
@@ -22,6 +24,7 @@ type DiscordDaemon struct {
|
||||
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) {
|
||||
@@ -37,7 +40,7 @@ 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,
|
||||
@@ -48,7 +51,8 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
|
||||
dd.commandHandlers[app.config.Section("discord").Key("start_command").MustString("start")] = dd.cmdStart
|
||||
dd.commandHandlers["lang"] = dd.cmdLang
|
||||
dd.commandHandlers["pin"] = dd.cmdPIN
|
||||
for _, user := range app.storage.discord {
|
||||
dd.commandHandlers["inv"] = dd.cmdInvite
|
||||
for _, user := range app.storage.GetDiscord() {
|
||||
dd.users[user.ID] = user
|
||||
}
|
||||
|
||||
@@ -58,7 +62,15 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -117,6 +129,7 @@ func (d *DiscordDaemon) run() {
|
||||
d.inviteChannelName = invChannel
|
||||
}
|
||||
}
|
||||
err = d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start"))
|
||||
defer d.deregisterCommands()
|
||||
defer d.bot.Close()
|
||||
|
||||
@@ -297,7 +310,7 @@ func (d *DiscordDaemon) Shutdown() {
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) registerCommands() {
|
||||
commands := []*dg.ApplicationCommand{
|
||||
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.",
|
||||
@@ -327,25 +340,73 @@ func (d *DiscordDaemon) registerCommands() {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "inv",
|
||||
Description: "Send an invite to a discord user (admin only).",
|
||||
Options: []*dg.ApplicationCommandOption{
|
||||
{
|
||||
Type: dg.ApplicationCommandOptionUser,
|
||||
Name: "user",
|
||||
Description: "User to Invite.",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: dg.ApplicationCommandOptionInteger,
|
||||
Name: "expiry",
|
||||
Description: "Time in minutes before expiration.",
|
||||
Required: false,
|
||||
},
|
||||
/* Label should be automatically set to something like "Discord invite for @username"
|
||||
{
|
||||
Type: dg.ApplicationCommandOptionString,
|
||||
Name: "label",
|
||||
Description: "Label given to this invite (shown on the Admin page)",
|
||||
Required: false,
|
||||
}, */
|
||||
{
|
||||
Type: dg.ApplicationCommandOptionString,
|
||||
Name: "user_label",
|
||||
Description: "Label given to users created with this invite.",
|
||||
Required: false,
|
||||
},
|
||||
{
|
||||
Type: dg.ApplicationCommandOptionString,
|
||||
Name: "profile",
|
||||
Description: "Profile to apply to the created user.",
|
||||
Required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
commands[1].Options[0].Choices = make([]*dg.ApplicationCommandOptionChoice, len(d.app.storage.lang.Telegram))
|
||||
d.commandDescriptions[1].Options[0].Choices = make([]*dg.ApplicationCommandOptionChoice, len(d.app.storage.lang.Telegram))
|
||||
i := 0
|
||||
for code := range d.app.storage.lang.Telegram {
|
||||
commands[1].Options[0].Choices[i] = &dg.ApplicationCommandOptionChoice{
|
||||
d.app.debug.Printf("Discord: registering lang choice \"%s\":\"%s\"\n", 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("Discord: registering profile choice \"%s\"", profile.Name)
|
||||
d.commandDescriptions[3].Options[3].Choices[i] = &dg.ApplicationCommandOptionChoice{
|
||||
Name: profile.Name,
|
||||
Value: profile.Name,
|
||||
}
|
||||
}
|
||||
|
||||
// d.deregisterCommands()
|
||||
|
||||
d.commandIDs = make([]string, len(commands))
|
||||
d.commandIDs = make([]string, len(d.commandDescriptions))
|
||||
// cCommands, err := d.bot.ApplicationCommandBulkOverwrite(d.bot.State.User.ID, d.guildID, commands)
|
||||
// if err != nil {
|
||||
// d.app.err.Printf("Discord: Cannot create commands: %v", err)
|
||||
// }
|
||||
for i, cmd := range commands {
|
||||
for i, cmd := range d.commandDescriptions {
|
||||
command, err := d.bot.ApplicationCommandCreate(d.bot.State.User.ID, d.guildID, cmd)
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Cannot create command \"%s\": %v", cmd.Name, err)
|
||||
@@ -363,12 +424,32 @@ func (d *DiscordDaemon) deregisterCommands() {
|
||||
return
|
||||
}
|
||||
for _, cmd := range existingCommands {
|
||||
if err := d.bot.ApplicationCommandDelete(d.bot.State.User.ID, "", cmd.ID); err != nil {
|
||||
d.app.err.Printf("Failed to deregister command: %v", err)
|
||||
if err := d.bot.ApplicationCommandDelete(d.bot.State.User.ID, d.guildID, cmd.ID); err != nil {
|
||||
d.app.err.Printf("Discord: Failed to deregister command: %v", 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("Discord: registering profile choice \"%s\"", 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("Discord: Failed to update profile list: %v\n", err)
|
||||
} else {
|
||||
d.commandIDs[3] = cmd.ID
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) commandHandler(s *dg.Session, i *dg.InteractionCreate) {
|
||||
if h, ok := d.commandHandlers[i.ApplicationCommandData().Name]; ok {
|
||||
if i.GuildID != "" && d.channelName != "" {
|
||||
@@ -429,14 +510,8 @@ func (d *DiscordDaemon) cmdStart(s *dg.Session, i *dg.InteractionCreate, lang st
|
||||
|
||||
func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang string) {
|
||||
pin := i.ApplicationCommandData().Options[0].StringValue()
|
||||
tokenIndex := -1
|
||||
for i, token := range d.tokens {
|
||||
if pin == token {
|
||||
tokenIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if tokenIndex == -1 {
|
||||
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,
|
||||
@@ -448,6 +523,7 @@ func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang stri
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", i.Interaction.Member.User.Username, err)
|
||||
}
|
||||
delete(d.tokens, pin)
|
||||
return
|
||||
}
|
||||
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
|
||||
@@ -461,23 +537,21 @@ func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang stri
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", i.Interaction.Member.User.Username, err)
|
||||
}
|
||||
d.verifiedTokens[pin] = d.users[i.Interaction.Member.User.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]
|
||||
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, u := range d.app.storage.discord {
|
||||
for _, u := range d.app.storage.GetDiscord() {
|
||||
if u.ID == i.Interaction.Member.User.ID {
|
||||
u.Lang = code
|
||||
lang = code
|
||||
d.app.storage.discord[jfID] = u
|
||||
if err := d.app.storage.storeDiscordUsers(); err != nil {
|
||||
d.app.err.Printf("Failed to store Discord users: %v", err)
|
||||
}
|
||||
d.app.storage.SetDiscordKey(u.JellyfinID, u)
|
||||
user = u
|
||||
break
|
||||
}
|
||||
@@ -498,6 +572,124 @@ func (d *DiscordDaemon) cmdLang(s *dg.Session, i *dg.InteractionCreate, lang str
|
||||
}
|
||||
}
|
||||
|
||||
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 create private channel with \"%s\": %v", i.Interaction.Member.User.Username, err)
|
||||
return
|
||||
}
|
||||
requester := d.MustGetUser(channel.ID, i.Interaction.Member.User.ID, i.Interaction.Member.User.Discriminator, i.Interaction.Member.User.Username)
|
||||
d.users[i.Interaction.Member.User.ID] = requester
|
||||
recipient := i.ApplicationCommandData().Options[0].UserValue(s)
|
||||
// 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
|
||||
//}
|
||||
// Check whether requestor is linked to the admin account
|
||||
requesterEmail, ok := d.app.storage.GetEmailsKey(requester.JellyfinID)
|
||||
if !ok {
|
||||
d.app.err.Printf("Failed to verify admin")
|
||||
}
|
||||
if !requesterEmail.Admin {
|
||||
d.app.err.Printf("User is not admin")
|
||||
//add response message
|
||||
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("Discord: %s", 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) {
|
||||
d.app.debug.Printf("%s: Sending invite message", invite.Code)
|
||||
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("Failed to send to %s", RenderDiscordUsername(recipient))
|
||||
d.app.err.Printf("%s: Failed to construct invite message: %v", invite.Code, err)
|
||||
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("Discord: Failed to send message to \"%s\": %v", RenderDiscordUsername(requester), err)
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
err = d.app.discord.SendDM(msg, recipient.ID)
|
||||
if err != nil {
|
||||
invite.SendTo = fmt.Sprintf("Failed to send to %s", RenderDiscordUsername(recipient))
|
||||
d.app.err.Printf("%s: %s: %v", invite.Code, invite.SendTo, err)
|
||||
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("Discord: Failed to send message to \"%s\": %v", RenderDiscordUsername(requester), err)
|
||||
}
|
||||
} else {
|
||||
d.app.info.Printf("%s: Sent invite email to \"%s\"", 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("Discord: Failed to send message to \"%s\": %v", RenderDiscordUsername(requester), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//if profile != "" {
|
||||
d.app.storage.SetInvitesKey(invite.Code, invite)
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) messageHandler(s *dg.Session, m *dg.MessageCreate) {
|
||||
if m.GuildID != "" && d.channelName != "" {
|
||||
if d.channelID == "" {
|
||||
@@ -580,13 +772,10 @@ func (d *DiscordDaemon) msgLang(s *dg.Session, m *dg.MessageCreate, sects []stri
|
||||
}
|
||||
if _, ok := d.app.storage.lang.Telegram[sects[1]]; ok {
|
||||
var user DiscordUser
|
||||
for jfID, u := range d.app.storage.discord {
|
||||
for _, u := range d.app.storage.GetDiscord() {
|
||||
if u.ID == m.Author.ID {
|
||||
u.Lang = sects[1]
|
||||
d.app.storage.discord[jfID] = u
|
||||
if err := d.app.storage.storeDiscordUsers(); err != nil {
|
||||
d.app.err.Printf("Failed to store Discord users: %v", err)
|
||||
}
|
||||
d.app.storage.SetDiscordKey(u.JellyfinID, u)
|
||||
user = u
|
||||
break
|
||||
}
|
||||
@@ -610,14 +799,8 @@ func (d *DiscordDaemon) msgPIN(s *dg.Session, m *dg.MessageCreate, sects []strin
|
||||
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 {
|
||||
user, ok := d.tokens[sects[0]]
|
||||
if !ok || time.Now().After(user.Expiry) {
|
||||
_, err := s.ChannelMessageSend(
|
||||
m.ChannelID,
|
||||
d.app.storage.lang.Telegram[lang].Strings.get("invalidPIN"),
|
||||
@@ -625,6 +808,7 @@ func (d *DiscordDaemon) msgPIN(s *dg.Session, m *dg.MessageCreate, sects []strin
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
|
||||
}
|
||||
delete(d.tokens, sects[0])
|
||||
return
|
||||
}
|
||||
_, err := s.ChannelMessageSend(
|
||||
@@ -634,9 +818,10 @@ func (d *DiscordDaemon) msgPIN(s *dg.Session, m *dg.MessageCreate, sects []strin
|
||||
if err != nil {
|
||||
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
|
||||
}
|
||||
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]
|
||||
dcUser := d.users[m.Author.ID]
|
||||
dcUser.JellyfinID = user.JellyfinID
|
||||
d.verifiedTokens[sects[0]] = dcUser
|
||||
delete(d.tokens, sects[0])
|
||||
}
|
||||
|
||||
func (d *DiscordDaemon) SendDM(message *Message, userID ...string) error {
|
||||
@@ -690,3 +875,32 @@ 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) (user DiscordUser, ok bool) {
|
||||
user, ok = d.verifiedTokens[pin]
|
||||
// delete(d.verifiedTokens, pin)
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// DeleteVerifiedUser removes the token with the given PIN.
|
||||
func (d *DiscordDaemon) DeleteVerifiedUser(pin string) {
|
||||
delete(d.verifiedTokens, pin)
|
||||
}
|
||||
|
||||
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.20
|
||||
|
||||
require golang.org/x/net v0.15.0
|
||||
|
||||
require github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b // indirect
|
||||
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.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
316
email.go
@@ -10,6 +10,7 @@ import (
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -18,12 +19,15 @@ import (
|
||||
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/html"
|
||||
"github.com/hrfee/jfa-go/easyproxy"
|
||||
"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 {
|
||||
@@ -84,7 +88,12 @@ func NewEmailer(app *appContext) *Emailer {
|
||||
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, 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), authType, proxyConf)
|
||||
if err != nil {
|
||||
app.err.Printf("Error while initiating SMTP mailer: %v", err)
|
||||
}
|
||||
@@ -110,7 +119,7 @@ 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, sslTLS bool, certPath string, helloHostname string, validateCertificate bool, authType sMail.AuthType, proxy *easyproxy.ProxyConfig) (err error) {
|
||||
sender := &SMTP{}
|
||||
sender.Client = sMail.NewSMTPClient()
|
||||
if sslTLS {
|
||||
@@ -119,7 +128,7 @@ func (emailer *Emailer) NewSMTP(server string, port int, username, password stri
|
||||
sender.Client.Encryption = sMail.EncryptionSTARTTLS
|
||||
}
|
||||
if username != "" || password != "" {
|
||||
sender.Client.Authentication = sMail.AuthLogin
|
||||
sender.Client.Authentication = authType
|
||||
sender.Client.Username = username
|
||||
sender.Client.Password = password
|
||||
}
|
||||
@@ -128,12 +137,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 +166,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
|
||||
}
|
||||
@@ -304,10 +320,17 @@ 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"
|
||||
if code == "" { // Personal email change
|
||||
if strings.HasSuffix(inviteLink, "/invite") {
|
||||
inviteLink = strings.TrimSuffix(inviteLink, "/invite")
|
||||
}
|
||||
inviteLink = fmt.Sprintf("%s/my/confirm/%s", inviteLink, url.PathEscape(key))
|
||||
} else { // Invite email confirmation
|
||||
if !strings.HasSuffix(inviteLink, "/invite") {
|
||||
inviteLink += "/invite"
|
||||
}
|
||||
inviteLink = fmt.Sprintf("%s/%s?key=%s", inviteLink, 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
|
||||
@@ -321,10 +344,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,
|
||||
)
|
||||
@@ -345,7 +369,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
|
||||
@@ -404,10 +428,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,
|
||||
)
|
||||
@@ -443,10 +468,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,
|
||||
)
|
||||
@@ -497,10 +523,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,
|
||||
)
|
||||
@@ -514,18 +541,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()
|
||||
@@ -582,10 +597,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,
|
||||
)
|
||||
@@ -623,10 +639,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,
|
||||
)
|
||||
@@ -664,10 +681,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,
|
||||
)
|
||||
@@ -705,10 +723,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,
|
||||
)
|
||||
@@ -722,6 +741,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"),
|
||||
@@ -760,7 +845,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)
|
||||
@@ -770,11 +856,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)
|
||||
@@ -805,10 +891,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,
|
||||
)
|
||||
@@ -827,49 +914,124 @@ 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 {
|
||||
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 status int
|
||||
var err error = nil
|
||||
if matchUsername {
|
||||
user, status, err = app.jf.UserByName(address, false)
|
||||
if status == 200 && 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, status, err = app.jf.UserByID(emailUser.JellyfinID, false)
|
||||
if status == 200 && 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, status, err = app.jf.UserByID(dcUser.JellyfinID, false)
|
||||
if status == 200 && 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, status, err = app.jf.UserByID(telegramUser.JellyfinID, false)
|
||||
if status == 200 && 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, status, err = app.jf.UserByID(matrixUser.JellyfinID, false)
|
||||
if status == 200 && 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
|
||||
}
|
||||
|
||||
65
genericdaemon.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import "time"
|
||||
|
||||
// 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("%s started", 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)
|
||||
}
|
||||
57
go.mod
@@ -14,8 +14,13 @@ 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.27.1
|
||||
github.com/dgraph-io/badger/v3 v3.2103.5
|
||||
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a
|
||||
github.com/fatih/color v1.15.0
|
||||
github.com/fsnotify/fsnotify v1.6.0
|
||||
@@ -26,29 +31,35 @@ require (
|
||||
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||
github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a
|
||||
github.com/hrfee/jfa-go/common v0.0.0-20230421170108-d800b97f69b6
|
||||
github.com/hrfee/jfa-go/docs v0.0.0-20230421170108-d800b97f69b6
|
||||
github.com/hrfee/jfa-go/linecache v0.0.0-20230421170108-d800b97f69b6
|
||||
github.com/hrfee/jfa-go/logger v0.0.0-20230421170108-d800b97f69b6
|
||||
github.com/hrfee/jfa-go/ombi v0.0.0-20230421170108-d800b97f69b6
|
||||
github.com/hrfee/mediabrowser v0.3.8
|
||||
github.com/hrfee/jfa-go/common v0.0.0-20240728190513-dabef831d769
|
||||
github.com/hrfee/jfa-go/docs v0.0.0-20230626224816-f72960635dc3
|
||||
github.com/hrfee/jfa-go/easyproxy v0.0.0-00010101000000-000000000000
|
||||
github.com/hrfee/jfa-go/linecache v0.0.0-20230626224816-f72960635dc3
|
||||
github.com/hrfee/jfa-go/logger v0.0.0-20230626224816-f72960635dc3
|
||||
github.com/hrfee/jfa-go/ombi v0.0.0-20230626224816-f72960635dc3
|
||||
github.com/hrfee/mediabrowser v0.3.13
|
||||
github.com/itchyny/timefmt-go v0.1.5
|
||||
github.com/lithammer/shortuuid/v3 v3.0.7
|
||||
github.com/mailgun/mailgun-go/v4 v4.9.0
|
||||
github.com/mailgun/mailgun-go/v4 v4.9.1
|
||||
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.2
|
||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible
|
||||
github.com/xhit/go-simple-mail/v2 v2.13.0
|
||||
github.com/xhit/go-simple-mail/v2 v2.16.0
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
maunium.net/go/mautrix v0.15.2
|
||||
maunium.net/go/mautrix v0.15.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/bytedance/sonic v1.9.2 // indirect
|
||||
github.com/cespare/xxhash v1.1.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/dgraph-io/ristretto v0.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect
|
||||
github.com/getlantern/errors v1.0.3 // indirect
|
||||
@@ -69,14 +80,23 @@ require (
|
||||
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.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/golang/glog v1.1.1 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/flatbuffers v23.5.26+incompatible // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/gorilla/mux v1.8.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/hrfee/jfa-go/jellyseerr v0.0.0-00010101000000-000000000000 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.16.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
@@ -95,6 +115,7 @@ require (
|
||||
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.opentelemetry.io/otel v1.16.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.16.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.16.0 // indirect
|
||||
@@ -102,14 +123,14 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.24.0 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.9.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect
|
||||
golang.org/x/image v0.7.0 // indirect
|
||||
golang.org/x/net v0.10.0 // indirect
|
||||
golang.org/x/sys v0.8.0 // indirect
|
||||
golang.org/x/text v0.9.0 // indirect
|
||||
golang.org/x/tools v0.9.3 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
golang.org/x/crypto v0.13.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect
|
||||
golang.org/x/image v0.8.0 // indirect
|
||||
golang.org/x/net v0.15.0 // indirect
|
||||
golang.org/x/sys v0.12.0 // indirect
|
||||
golang.org/x/text v0.13.0 // indirect
|
||||
golang.org/x/tools v0.10.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
maunium.net/go/maulogger/v2 v2.4.1 // indirect
|
||||
)
|
||||
|
||||
225
go.sum
@@ -1,29 +1,60 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY=
|
||||
github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/bytedance/sonic v1.9.2 h1:GDaNjuWSGu09guE9Oql0MSTNhNCLlWwO8y/xM5BzcbM=
|
||||
github.com/bytedance/sonic v1.9.2/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgraph-io/badger/v3 v3.2103.1/go.mod h1:dULbq6ehJ5K0cGW/1TQ9iSfUk0gbSiToDWmWmTsJ53E=
|
||||
github.com/dgraph-io/badger/v3 v3.2103.5 h1:ylPa6qzbjYRQMU6jokoj4wzcaweHylt//CH0AKt0akg=
|
||||
github.com/dgraph-io/badger/v3 v3.2103.5/go.mod h1:4MPiseMeDQ3FNCYwRbbcBOGJLf5jsE0PPFzRiKjtcdw=
|
||||
github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug=
|
||||
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
|
||||
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/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/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/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=
|
||||
@@ -33,6 +64,7 @@ github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+ne
|
||||
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
|
||||
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
|
||||
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
|
||||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
@@ -131,20 +163,58 @@ github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGF
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/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 v0.0.0-20210429001901-424d2337a529/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/glog v1.1.1 h1:jxpi2eWoU84wbX9iIEyAeeoac3FLuifZpY9tcNUD9kw=
|
||||
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.2/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.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/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a h1:AWZzzFrqyjYlRloN6edwTLTUbKxf5flLXNuTBDm3Ews=
|
||||
github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/flatbuffers v1.12.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/flatbuffers v2.0.0+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg=
|
||||
github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/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=
|
||||
@@ -153,8 +223,10 @@ github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB7
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hrfee/mediabrowser v0.3.8 h1:y0iBCb6jE3QKcsiCJSYva2fFPHRn4UA+sGRzoPuJ/Dk=
|
||||
github.com/hrfee/mediabrowser v0.3.8/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hrfee/mediabrowser v0.3.13 h1:NgQNbq+JWwsP68BdWXL/rwbpfE/oO5LJ5KVkE+aNbX8=
|
||||
github.com/hrfee/mediabrowser v0.3.13/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
|
||||
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
@@ -165,6 +237,12 @@ github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u
|
||||
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/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.13.1/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
|
||||
github.com/klauspost/compress v1.16.6 h1:91SKEy4K37vkp255cJ8QesJhjyRO0hn9i9G0GoUwLsk=
|
||||
github.com/klauspost/compress v1.16.6/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
@@ -185,8 +263,11 @@ github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJV
|
||||
github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
|
||||
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||
github.com/mailgun/mailgun-go/v4 v4.9.0 h1:wRbxvVQ5QObFewLxc1uVvipA16D8gxeiO+cBOca51Iw=
|
||||
github.com/mailgun/mailgun-go/v4 v4.9.0/go.mod h1:FJlF9rI5cQT+mrwujtJjPMbIVy3Ebor9bKTVsJ0QU40=
|
||||
github.com/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/mailgun-go/v4 v4.9.1 h1:D/jhJXYod4RqRsNOOSrjrtAcMEnz8mPYJmeA5cueHKY=
|
||||
github.com/mailgun/mailgun-go/v4 v4.9.1/go.mod h1:FJlF9rI5cQT+mrwujtJjPMbIVy3Ebor9bKTVsJ0QU40=
|
||||
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=
|
||||
@@ -205,7 +286,9 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
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=
|
||||
@@ -216,6 +299,7 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
|
||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
@@ -225,6 +309,7 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/robert-nix/ansihtml v1.0.1 h1:VTiyQ6/+AxSJoSSLsMecnkh8i0ZqOEdiRl/odOc64fc=
|
||||
github.com/robert-nix/ansihtml v1.0.1/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yDXcNeHHKZINE=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
@@ -233,10 +318,20 @@ github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6po
|
||||
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc=
|
||||
github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
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=
|
||||
@@ -252,8 +347,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
@@ -276,6 +371,10 @@ 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/v3 v3.0.0-20210909134927-2b6764d68c1e h1:zWSVsQaifg0cVH9VvR+cMguV7exK6U+SoW8YD1cZpR4=
|
||||
github.com/timshannon/badgerhold/v3 v3.0.0-20210909134927-2b6764d68c1e/go.mod h1:/Seq5xGNo8jLhSbDX3jdbeZrp4yFIpQ6/7n4TjziEWs=
|
||||
github.com/timshannon/badgerhold/v4 v4.0.2 h1:83OLY/NFnEaMnHEPd84bYtkLipVkjTsMbzQRYbk47g4=
|
||||
github.com/timshannon/badgerhold/v4 v4.0.2/go.mod h1:rh6RyXLQFsvrvcKondPQQFZnNovpRzu+gS0FlLxYuHY=
|
||||
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM=
|
||||
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
@@ -285,6 +384,7 @@ github.com/ugorji/go v1.1.5-pre/go.mod h1:FwP/aQVg39TXzItUBMwnWp9T9gPQnXw4Poh4/o
|
||||
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
|
||||
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
|
||||
github.com/ugorji/go/codec v0.0.0-20181022190402-e5e69e061d4f/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/ugorji/go/codec v1.1.5-pre/go.mod h1:tULtS6Gy1AE1yCENaw4Vb//HLH5njI2tfCQDUqRd8fI=
|
||||
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
||||
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
|
||||
@@ -294,10 +394,17 @@ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijb
|
||||
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
|
||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
|
||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE=
|
||||
github.com/xhit/go-simple-mail/v2 v2.13.0 h1:OANWU9jHZrVfBkNkvLf8Ww0fexwpQVF/v/5f96fFTLI=
|
||||
github.com/xhit/go-simple-mail/v2 v2.13.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
|
||||
github.com/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.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
|
||||
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.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s=
|
||||
go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4=
|
||||
@@ -320,26 +427,37 @@ go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=
|
||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
||||
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
|
||||
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
|
||||
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
|
||||
golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw=
|
||||
golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg=
|
||||
golang.org/x/image v0.8.0 h1:agUcRXV/+w6L9ryntYYsF2x9fQTMd4T8fiiYXAVW6Jg=
|
||||
golang.org/x/image v0.8.0/go.mod h1:PwLxp3opCYg4WR2WO9P0L6ESnsD6bLTWcw8zanLMVFM=
|
||||
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.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.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
|
||||
golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
@@ -347,29 +465,45 @@ golang.org/x/net v0.0.0-20190611141213-3f473d35a33a/go.mod h1:z5CRVTTTmAJ677TzLL
|
||||
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
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/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/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-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-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -379,11 +513,12 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.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 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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=
|
||||
@@ -393,30 +528,60 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190606050223-4d9ae51c2468/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190611222205-d73e1c7e250b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.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.9.3 h1:Gn1I8+64MsuTb/HpH+LmQtNas23LhUVr3rYZ0eKuaMM=
|
||||
golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
|
||||
golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg=
|
||||
golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
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-20190902080502-41f04d3bba15/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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -435,8 +600,10 @@ gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
|
||||
maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
|
||||
maunium.net/go/mautrix v0.15.2 h1:fUiVajeoOR92uJoSShHbCvh7uG6lDY4ZO4Mvt90LbjU=
|
||||
maunium.net/go/mautrix v0.15.2/go.mod h1:h4NwfKqE4YxGTLSgn/gawKzXAb2sF4qx8agL6QEFtGg=
|
||||
maunium.net/go/mautrix v0.15.3 h1:C9BHSUM0gYbuZmAtopuLjIcH5XHLb/ZjTEz7nN+0jN0=
|
||||
maunium.net/go/mautrix v0.15.3/go.mod h1:zLrQqdxJlLkurRCozTc9CL6FySkgZlO/kpCYxBILSLE=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
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 }}
|
||||
620
html/admin.html
@@ -10,6 +10,7 @@
|
||||
window.discordEnabled = {{ .discordEnabled }};
|
||||
window.matrixEnabled = {{ .matrixEnabled }};
|
||||
window.ombiEnabled = {{ .ombiEnabled }};
|
||||
window.jellyseerrEnabled = {{ .jellyseerrEnabled }};
|
||||
window.usernameEnabled = {{ .username }};
|
||||
window.langFile = JSON.parse({{ .language }});
|
||||
window.linkResetEnabled = {{ .linkResetEnabled }};
|
||||
@@ -17,28 +18,25 @@
|
||||
window.jellyfinLogin = {{ .jellyfinLogin }};
|
||||
window.jfAdminOnly = {{ .jfAdminOnly }};
|
||||
window.jfAllowAll = {{ .jfAllowAll }};
|
||||
window.referralsEnabled = {{ .referralsEnabled }};
|
||||
window.loginAppearance = "{{ .loginAppearance }}";
|
||||
</script>
|
||||
<title>Admin - jfa-go</title>
|
||||
{{ template "header.html" . }}
|
||||
</head>
|
||||
<body class="max-w-full overflow-x-hidden section">
|
||||
<div id="modal-login" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-login" 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>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
{{ template "login-modal.html" . }}
|
||||
<div id="modal-add-user" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-add-user" href="">
|
||||
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-add-user" href="">
|
||||
<span class="heading">{{ .strings.newUser }} <span class="modal-close">×</span></span>
|
||||
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.username }}" id="add-user-user">
|
||||
<input type="email" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.emailAddress }}">
|
||||
<input type="password" class="field input ~neutral @high mb-4" placeholder="{{ .strings.password }}" id="add-user-password">
|
||||
<label class="label supra">{{ .strings.profile }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<select id="add-user-profile">
|
||||
</select>
|
||||
</div>
|
||||
<label>
|
||||
<input type="submit" class="unfocused">
|
||||
<span class="button ~urge @low full-width center supra submit">{{ .strings.create }}</span>
|
||||
@@ -46,31 +44,33 @@
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-about" class="modal">
|
||||
<div class="relative mx-auto my-[10%] w-4/5 lg:w-1/3 content card">
|
||||
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 content card">
|
||||
<img src="{{ .urlBase }}/banner.svg" class="banner header" alt="jfa-go banner">
|
||||
<span class="heading"><span class="modal-close">×</span></span>
|
||||
<p>{{ .strings.version }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .version }}</span></p>
|
||||
<p>{{ .strings.commitNoun }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .commit }}</span></p>
|
||||
<div class="row col flex">
|
||||
<a class="button ~neutral mr-2 mt-4 mb-4 lang-link" href="https://github.com/hrfee/jfa-go"><i class="ri-github-line mr-2"></i>github</a>
|
||||
<a class="button ~urge mt-4 mb-4 mr-2 lang-link" href="https://wiki.jfa-go.com">wiki/docs</a>
|
||||
<a class="button ~positive mt-4 mb-4 mr-2 lang-link" href="https://weblate.jfa-go.com">translation</a>
|
||||
<div class="dropdown mr-2" tabindex="0">
|
||||
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~info mt-4 mb-4 dropdown-button lang-link">
|
||||
<p>{{ .strings.buildTime }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .buildTime }}</span></p>
|
||||
<p>{{ .strings.builtBy }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .builtBy }}</span></p>
|
||||
<div class="flex flex-row flex-wrap gap-2 my-2">
|
||||
<a class="button ~neutral lang-link" href="https://github.com/hrfee/jfa-go"><i class="ri-github-line mr-2"></i>github</a>
|
||||
<a class="button ~urge lang-link" href="https://wiki.jfa-go.com">wiki/docs</a>
|
||||
<a class="button ~positive lang-link" href="https://weblate.jfa-go.com">translation</a>
|
||||
<div class="dropdown" tabindex="0">
|
||||
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~info dropdown-button lang-link">
|
||||
<i class="ri-hand-heart-line mr-2"></i>
|
||||
donate
|
||||
<span class="ml-2 chev"></span>
|
||||
</a>
|
||||
<div class="dropdown-display">
|
||||
<div class="card ~neutral @low">
|
||||
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~neutral mb-2 w-100 lang-link">GitHub</a>
|
||||
<a href="https://ko-fi.com/hrfee" target="_blank" class="button ~neutral mb-2 w-100 lang-link">Ko-fi</a>
|
||||
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~neutral mb-2 w-full lang-link">GitHub</a>
|
||||
<a href="https://ko-fi.com/hrfee" target="_blank" class="button ~neutral mb-2 w-full lang-link">Ko-fi</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a class="button ~urge mt-4 mb-4 @low discord lang-link" href="https://discord.com/invite/MrtvuQmyhP" target="_blank"><i class="ri-discord-line mr-2"></i>discord</a>
|
||||
<a class="button ~urge @low discord lang-link" href="https://discord.com/invite/MrtvuQmyhP" target="_blank"><i class="ri-discord-line mr-2"></i>discord</a>
|
||||
</div>
|
||||
<p><a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE">Available under the MIT License.</a></p>
|
||||
<p><a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE">Available under the MIT License. Font "Hanken Grotesk" available under SIL OFL 1.1 License.</a></p>
|
||||
<pre class="font-mono bg-inherit">{{ .license }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,28 +81,74 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-modify-user" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-modify-user" href="">
|
||||
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-modify-user" href="">
|
||||
<span class="heading"><span id="header-modify-user"></span> <span class="modal-close">×</span></span>
|
||||
<p class="content my-4">{{ .strings.modifySettingsDescription }}</p>
|
||||
<div class="flex-row mb-4">
|
||||
<label class="flex-row-group mr-2">
|
||||
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-profile" checked>
|
||||
<div class="flex flex-col gap-4 my-2">
|
||||
<div class="flex flex-row gap-2">
|
||||
<label class="grow">
|
||||
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-profile" checked>
|
||||
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
|
||||
</label>
|
||||
<label class="grow">
|
||||
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-user">
|
||||
<span class="button ~neutral @low supra full-width center">{{ .strings.user }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="select ~neutral @low">
|
||||
<select id="modify-user-profiles"></select>
|
||||
</div>
|
||||
<div class="select ~neutral @low unfocused">
|
||||
<select id="modify-user-users"></select>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="modify-user-configuration" checked>
|
||||
<span>{{ .strings.applyConfigurationAndPolicy }}</span>
|
||||
</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="modify-user-homescreen" checked>
|
||||
<span>{{ .strings.applyHomescreenLayout }}</span>
|
||||
</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="modify-user-ombi" checked>
|
||||
<span>{{ .strings.applyOmbi }}</span>
|
||||
</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="modify-user-jellyseerr" checked>
|
||||
<span>{{ .strings.applyJellyseerr }}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="submit" class="unfocused">
|
||||
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{ if .referralsEnabled }}
|
||||
<div id="modal-enable-referrals-user" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-enable-referrals-user" href="">
|
||||
<span class="heading"><span id="header-enable-referrals-user"></span> <span class="modal-close">×</span></span>
|
||||
<p class="content my-4">{{ .strings.enableReferralsDescription }}</p>
|
||||
<div class="flex flex-row mb-4">
|
||||
<label class="grow mr-2">
|
||||
<input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-profile" checked>
|
||||
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
|
||||
</label>
|
||||
<label class="flex-row-group ml-2">
|
||||
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-user">
|
||||
<span class="button ~neutral @low supra full-width center">{{ .strings.user }}</span>
|
||||
<label class="grow ml-2">
|
||||
<input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-invite">
|
||||
<span class="button ~neutral @low supra full-width center">{{ .strings.invite }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="select ~neutral @low mb-4">
|
||||
<select id="modify-user-profiles"></select>
|
||||
<select id="enable-referrals-user-profiles"></select>
|
||||
</div>
|
||||
<div class="select ~neutral @low mb-4 unfocused">
|
||||
<select id="modify-user-users"></select>
|
||||
<select id="enable-referrals-user-invites"></select>
|
||||
</div>
|
||||
<label class="switch mb-4">
|
||||
<input type="checkbox" id="modify-user-homescreen" checked>
|
||||
<span>{{ .strings.applyHomescreenLayout }}</span>
|
||||
<input type="checkbox" id="enable-referrals-user-expiry">
|
||||
<span>{{ .strings.useInviteExpiry }}</span>
|
||||
<span class="flex flex-row support mt-2">{{ .strings.useInviteExpiryNote }}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="submit" class="unfocused">
|
||||
@@ -110,8 +156,28 @@
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-enable-referrals-profile" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-enable-referrals-profile" href="">
|
||||
<span class="heading"><span id="header-enable-referrals-profile">{{ .strings.enableReferrals }}</span> <span class="modal-close">×</span></span>
|
||||
<p class="content my-4">{{ .strings.enableReferralsProfileDescription }}</p>
|
||||
<label class="supra" for="enable-referrals-profile-invites">{{ .strings.invite }}</label>
|
||||
<div class="select ~neutral @low mb-4 mt-2">
|
||||
<select id="enable-referrals-profile-invites"></select>
|
||||
</div>
|
||||
<label class="switch mb-4">
|
||||
<input type="checkbox" id="enable-referrals-profile-expiry">
|
||||
<span>{{ .strings.useInviteExpiry }}</span>
|
||||
<span class="flex flex-row support mt-2">{{ .strings.useInviteExpiryNote }}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="submit" class="unfocused">
|
||||
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div id="modal-delete-user" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-delete-user" href="">
|
||||
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-delete-user" href="">
|
||||
<span class="heading"><span id="header-delete-user"></span> <span class="modal-close">×</span></span>
|
||||
<div class="content mt-8">
|
||||
<label class="switch mb-4">
|
||||
@@ -127,42 +193,52 @@
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-extend-expiry" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-extend-expiry" href="">
|
||||
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-extend-expiry" href="">
|
||||
<span class="heading"><span id="header-extend-expiry"></span> <span class="modal-close">×</span></span>
|
||||
<div class="content mt-8">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label class="label supra" for="extend-expiry-months">{{ .strings.inviteMonths }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<select id="extend-expiry-months">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="label supra" for="extend-expiry-days">{{ .strings.inviteDays }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<select id="extend-expiry-days">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
<aside class="aside sm ~urge dark:~d_info mb-2 @low row unfocused" id="extend-expiry-date"></aside>
|
||||
<div>
|
||||
<span class="text-xl supra row py-1">{{ .strings.setExpiry }}</span>
|
||||
<div class="row">
|
||||
<input type="text" id="extend-expiry-text" class="input ~neutral @low mb-2 mt-4" placeholder="{{ .strings.enterExpiry }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label class="label supra" for="extend-expiry-hours">{{ .strings.inviteHours }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<select id="extend-expiry-hours">
|
||||
<option>0</option>
|
||||
</select>
|
||||
<div id="extend-expiry-field-inputs">
|
||||
<span class="text-xl supra row py-1">{{ .strings.extendExpiry }}</span>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label class="label supra" for="extend-expiry-months">{{ .strings.inviteMonths }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<select id="extend-expiry-months">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="label supra" for="extend-expiry-days">{{ .strings.inviteDays }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<select id="extend-expiry-days">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="label supra" for="extend-expiry-minutes">{{ .strings.inviteMinutes }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<select id="extend-expiry-minutes">
|
||||
<option>0</option>
|
||||
</select>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label class="label supra" for="extend-expiry-hours">{{ .strings.inviteHours }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<select id="extend-expiry-hours">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="label supra" for="extend-expiry-minutes">{{ .strings.inviteMinutes }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<select id="extend-expiry-minutes">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,7 +257,7 @@
|
||||
<div id="modal-announce" class="modal">
|
||||
<form class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card" id="form-announce" href="">
|
||||
<span class="heading"><span id="header-announce"></span> <span class="modal-close">×</span></span>
|
||||
<div class="row">
|
||||
<div class="flex flex-col md:flex-row">
|
||||
<div class="col card ~neutral @low">
|
||||
<div id="announce-details">
|
||||
<span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span>
|
||||
@@ -198,7 +274,7 @@
|
||||
<input type="text" class="input ~neutral @low mb-2 mt-4">
|
||||
<p class="support">{{ .strings.templateEnterName }}</p>
|
||||
</label>
|
||||
<div class="row flex-expand">
|
||||
<div class="flex flex-row justify-between">
|
||||
<label>
|
||||
<input type="submit" class="unfocused">
|
||||
<span class="button ~urge @low center supra submit">{{ .strings.send }}</span>
|
||||
@@ -214,10 +290,10 @@
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-customize" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
||||
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3">
|
||||
<span class="heading">{{ .strings.customizeMessages }} <span class="modal-close">×</span></span>
|
||||
<p class="content my-4">{{ .strings.customizeMessagesDescription }}</p>
|
||||
<div class="table-responsive">
|
||||
<div class="">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -236,6 +312,7 @@
|
||||
<span class="heading"><span id="header-editor"></span> <span class="modal-close">×</span></span>
|
||||
<div class="row">
|
||||
<div class="col card ~neutral @low">
|
||||
<aside class="aside sm ~urge dark:~d_info mb-2 @low" id="aside-editor"></aside>
|
||||
<span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span>
|
||||
<div id="editor-variables" class="mt-4"></div>
|
||||
<span class="label supra" for="editor-conditionals" id="label-editor-conditionals">{{ .strings.conditionals }}</span>
|
||||
@@ -258,7 +335,7 @@
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-restart" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~critical @low">
|
||||
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~critical @low">
|
||||
<span class="heading">{{ .strings.settingsRestartRequired }} <span class="modal-close">×</span></span>
|
||||
<p class="content my-4">{{ .strings.settingsRestartRequiredDescription }}</p>
|
||||
<div class="float-right">
|
||||
@@ -267,21 +344,62 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-backups" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-2/3">
|
||||
<span class="heading">{{ .strings.backups }} <span class="modal-close">×</span></span>
|
||||
<div class="content my-4">
|
||||
{{ .strings.backupsDescription }}
|
||||
<ul>
|
||||
<li>{{ .strings.backupsCopy }}</li>
|
||||
<li>{{ .strings.backupsFormatNote }}</li>
|
||||
<li><a target="_blank" href="https://wiki.jfa-go.com/docs/backups/">{{ .strings.wikiPage }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap my-2">
|
||||
<button class="button ~info @low mr-2 mb-2" id="settings-backups-backup">{{ .strings.backupNow }}</button>
|
||||
<button class="button ~neutral @low mr-2 mb-2" id="settings-backups-upload">{{ .strings.backupUpload }}</button>
|
||||
<input id="backups-file" name="backups-file" type="file" hidden>
|
||||
<button class="button ~neutral @low mr-2 mb-2" id="settings-backups-sort-direction">{{ .strings.sortDirection }}</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto text-xs md:text-sm">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ .strings.name }}</th>
|
||||
<th>{{ .strings.date }}</th>
|
||||
<th class="table-inline justify-center">{{ .strings.backupDownloadRestore }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="backups-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-backed-up" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low">
|
||||
<span class="heading">{{ .strings.backupCreated }} <span class="modal-close">×</span></span>
|
||||
<p class="content my-4" id="settings-backed-up-location"></p>
|
||||
<p class="content my-4">{{ .strings.backupCanDownload }}</p>
|
||||
<div>
|
||||
<button class="button flex w-full ~info @low mb-2"><span class="flex items-center" id="settings-backed-up-download">{{ .strings.download }}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-refresh" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
|
||||
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low">
|
||||
<span class="heading">{{ .strings.settingsApplied }}</span>
|
||||
<p class="content">{{ .strings.settingsRefreshPage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-send-pwr" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
|
||||
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low">
|
||||
<span class="heading">{{ .strings.sendPWR }}</span>
|
||||
<p class="content my-2" id="send-pwr-note"></p>
|
||||
<span class="button ~urge @low mt-2" id="send-pwr-link">{{ .strings.copy }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-ombi-profile" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-ombi-defaults" href="">
|
||||
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-ombi-defaults" href="">
|
||||
<span class="heading">{{ .strings.ombiProfile }} <span class="modal-close">×</span></span>
|
||||
<p class="content my-4">{{ .strings.ombiUserDefaultsDescription }}</p>
|
||||
<div class="select ~neutral @low mb-4">
|
||||
@@ -293,8 +411,21 @@
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-jellyseerr-profile" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-jellyseerr-defaults" href="">
|
||||
<span class="heading">{{ .strings.jellyseerrProfile }} <span class="modal-close">×</span></span>
|
||||
<p class="content my-4">{{ .strings.jellyseerrUserDefaultsDescription }}</p>
|
||||
<div class="select ~neutral @low mb-4">
|
||||
<select></select>
|
||||
</div>
|
||||
<label>
|
||||
<input type="submit" class="unfocused">
|
||||
<span class="button ~urge @low full-width center supra submit">{{ .strings.submit }}</span>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-user-profiles" class="modal">
|
||||
<div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
|
||||
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-2/3 content card">
|
||||
<span class="heading">{{ .strings.userProfiles }} <span class="modal-close">×</span></span>
|
||||
<p class="content my-4">{{ .strings.userProfilesDescription }}</p>
|
||||
<div class="table-responsive">
|
||||
@@ -306,6 +437,12 @@
|
||||
{{ if .ombiEnabled }}
|
||||
<th>Ombi</th>
|
||||
{{ end }}
|
||||
{{ if .jellyseerrEnabled }}
|
||||
<th>Jellyseerr</th>
|
||||
{{ end }}
|
||||
{{ if .referralsEnabled }}
|
||||
<th>{{ .strings.referrals }}</th>
|
||||
{{ end }}
|
||||
<th>{{ .strings.from }}</th>
|
||||
<th>{{ .strings.userProfilesLibraries }}</th>
|
||||
<th><span class="button ~neutral @high" id="button-profile-create">{{ .strings.create }}</span></th>
|
||||
@@ -317,7 +454,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-add-profile" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-add-profile" href="">
|
||||
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-add-profile" href="">
|
||||
<span class="heading">{{ .strings.addProfile }} <span class="modal-close">×</span></span>
|
||||
<p class="content my-4">{{ .strings.addProfileDescription }}</p>
|
||||
<label>
|
||||
@@ -340,7 +477,7 @@
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-update" class="modal">
|
||||
<div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
|
||||
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 content card">
|
||||
<span class="heading">{{ .strings.updates }} <span class="modal-close">×</span></span>
|
||||
<p class="content">
|
||||
<h2 class="mt-2">
|
||||
@@ -356,7 +493,7 @@
|
||||
</div>
|
||||
{{ if .telegramEnabled }}
|
||||
<div id="modal-telegram" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
||||
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3">
|
||||
<span class="heading mb-4">{{ .strings.linkTelegram }}</span>
|
||||
<p class="content mb-4">{{ .strings.sendPIN }}</p>
|
||||
<h1 class="ac" id="telegram-pin"></h1>
|
||||
@@ -374,7 +511,7 @@
|
||||
{{ end }}
|
||||
{{ if .discordEnabled }}
|
||||
<div id="modal-discord" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
||||
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3">
|
||||
<span class="heading mb-4"><span id="discord-header"></span><span class="modal-close">×</span></span>
|
||||
<p class="content mb-4" id="discord-description"></p>
|
||||
<div class="row">
|
||||
@@ -385,7 +522,7 @@
|
||||
</div>
|
||||
{{ end }}
|
||||
<div id="modal-matrix" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-matrix" href="">
|
||||
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-matrix" href="">
|
||||
<span class="heading">{{ .strings.linkMatrix }}</span>
|
||||
<p class="content my-4">{{ .strings.linkMatrixDescription }}</p>
|
||||
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.matrixHomeServer }}" id="matrix-homeserver">
|
||||
@@ -398,8 +535,8 @@
|
||||
</form>
|
||||
</div>
|
||||
<div id="notification-box"></div>
|
||||
<div class="top-4 left-4 absolute">
|
||||
<span class="dropdown" tabindex="0" id="lang-dropdown">
|
||||
<div class="top-4 left-4 absolute flex flex-row gap-2">
|
||||
<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>
|
||||
@@ -420,12 +557,18 @@
|
||||
</span>
|
||||
<span class="button ~warning" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
|
||||
</div>
|
||||
{{ if .userPageEnabled }}
|
||||
<div class="top-4 right-4 absolute">
|
||||
<a class="button ~info" href="{{ .urlBase }}/my/account"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="page-container">
|
||||
<div class="mb-4">
|
||||
<header class="flex flex-wrap items-center justify-between">
|
||||
<div>
|
||||
<header>
|
||||
<div class="flex flex-row overflow-x-scroll items-center">
|
||||
<span id="button-tab-invites" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.invites }}</span>
|
||||
<span id="button-tab-accounts" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.accounts }}</span>
|
||||
<span id="button-tab-activity" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.activity }}</span>
|
||||
<span id="button-tab-settings" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.settings }}</span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -436,55 +579,55 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-invites">
|
||||
<div class="card @low invites dark:~d_neutral mb-4">
|
||||
<div class="card @low invites dark:~d_neutral mb-4 overflow-visible">
|
||||
<span class="heading">{{ .strings.invites }}</span>
|
||||
<div id="invites"></div>
|
||||
<div id="invites" class="mt-2"></div>
|
||||
</div>
|
||||
<div class="card @low dark:~d_neutral">
|
||||
<span class="heading">{{ .strings.create }}</span>
|
||||
<div class="flex flex-col md:flex-row gap-3" id="create-inv">
|
||||
<div class="card ~neutral @low col">
|
||||
<div class="row mb-2">
|
||||
<label class="col mr-2">
|
||||
<div class="flex flex-col md:flex-row gap-3 mt-2" id="create-inv">
|
||||
<div class="card ~neutral @low flex flex-col gap-2 grow">
|
||||
<div class="flex flex-row gap-2">
|
||||
<label class="w-1/2">
|
||||
<input type="radio" name="duration" class="unfocused" id="radio-inv-duration" checked>
|
||||
<span class="button ~neutral @high supra full-width center">{{ .strings.inviteDuration }}</span>
|
||||
</label>
|
||||
<label class="col ml-2">
|
||||
<label class="w-1/2">
|
||||
<input type="radio" name="duration" class="unfocused" id="radio-user-expiry">
|
||||
<span class="button ~neutral @low supra full-width center">{{ .strings.userExpiry }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="inv-duration">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div id="inv-duration" class="flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="grow flex flex-col gap-4">
|
||||
<label class="label supra" for="create-months">{{ .strings.inviteMonths }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<div class="select ~neutral @low">
|
||||
<select id="create-months">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="grow flex flex-col gap-4">
|
||||
<label class="label supra" for="create-days">{{ .strings.inviteDays }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<div class="select ~neutral @low">
|
||||
<select id="create-days">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="grow flex flex-col gap-4">
|
||||
<label class="label supra" for="create-hours">{{ .strings.inviteHours }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<div class="select ~neutral @low">
|
||||
<select id="create-hours">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="grow flex flex-col gap-4">
|
||||
<label class="label supra" for="create-minutes">{{ .strings.inviteMinutes }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<div class="select ~neutral @low">
|
||||
<select id="create-minutes">
|
||||
<option>0</option>
|
||||
</select>
|
||||
@@ -492,44 +635,46 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="user-expiry" class="unfocused">
|
||||
<p class="support mb-2">{{ .strings.userExpiryDescription }}</p>
|
||||
<div class="mb-2">
|
||||
<label for="create-user-expiry-enabled" class="button ~neutral @low">
|
||||
<input type="checkbox" id="create-user-expiry-enabled" aria-label="User duration enabled">
|
||||
<span class="ml-2">{{ .strings.enabled }} </span>
|
||||
</label>
|
||||
<div id="user-expiry" class="unfocused flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-2">
|
||||
<p class="support">{{ .strings.userExpiryDescription }}</p>
|
||||
<div>
|
||||
<label for="create-user-expiry-enabled" class="button ~neutral @low">
|
||||
<input type="checkbox" id="create-user-expiry-enabled" aria-label="User duration enabled">
|
||||
<span class="ml-2">{{ .strings.enabled }} </span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="grow flex flex-col gap-4">
|
||||
<label class="label supra" for="user-months">{{ .strings.inviteMonths }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<div class="select ~neutral @low">
|
||||
<select id="user-months">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="grow flex flex-col gap-4">
|
||||
<label class="label supra" for="user-days">{{ .strings.inviteDays }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<div class="select ~neutral @low">
|
||||
<select id="user-days">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="grow flex flex-col gap-4">
|
||||
<label class="label supra" for="user-hours">{{ .strings.inviteHours }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<div class="select ~neutral @low">
|
||||
<select id="user-hours">
|
||||
<option>0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="grow flex flex-col gap-4">
|
||||
<label class="label supra" for="user-minutes">{{ .strings.inviteMinutes }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<div class="select ~neutral @low">
|
||||
<select id="user-minutes">
|
||||
<option>0</option>
|
||||
</select>
|
||||
@@ -537,72 +682,91 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="flex flex-col gap-4">
|
||||
<label class="label supra" for="create-label"> {{ .strings.label }}</label>
|
||||
<input type="text" id="create-label" class="input ~neutral @low mb-2 mt-4">
|
||||
<input type="text" id="create-label" class="input ~neutral @low">
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="label supra" for="create-user-label"> {{ .strings.userLabel }}</label>
|
||||
<p class="support">{{ .strings.userLabelDescription }}</p>
|
||||
</div>
|
||||
<input type="text" id="create-user-label" class="input ~neutral @low">
|
||||
</div>
|
||||
</div>
|
||||
<div class="card ~neutral @low col">
|
||||
<label class="label supra" for="create-uses">{{ .strings.inviteNumberOfUses }}</label>
|
||||
<div class="flex-expand mb-2 mt-4">
|
||||
<input type="number" min="0" id="create-uses" class="input ~neutral @low mr-2" value=1>
|
||||
<label for="create-inf-uses" class="button ~neutral @low" title="Set uses to infinite">
|
||||
<span>∞</span>
|
||||
<input type="checkbox" class="unfocused" id="create-inf-uses" aria-label="Set uses to infinite">
|
||||
</label>
|
||||
</div>
|
||||
<p class="support unfocused my-2" id="create-inf-uses-warning"><span class="badge ~critical">{{ .strings.warning }}</span> {{ .strings.inviteInfiniteUsesWarning }}</p>
|
||||
<label class="label supra">{{ .strings.profile }}</label>
|
||||
<div class="select ~neutral @low mb-2 mt-4">
|
||||
<select id="create-profile">
|
||||
</select>
|
||||
</div>
|
||||
<div id="create-send-to-container">
|
||||
<label class="label supra">{{ .strings.inviteSendToEmail }}</label>
|
||||
<div class="flex-expand mb-2 mt-4">
|
||||
{{ if .discordEnabled }}
|
||||
<input type="text" id="create-send-to" class="input ~neutral @low mr-2" placeholder="example@example.com | user#1234">
|
||||
<span id="create-send-to-search" class="button ~neutral @low mr-2">
|
||||
<i class="icon ri-search-2-line" title="{{ .strings.search }}"></i>
|
||||
</span>
|
||||
{{ else }}
|
||||
<input type="email" id="create-send-to" class="input ~neutral @low mr-2" placeholder="example@example.com">
|
||||
{{ end }}
|
||||
<label for="create-send-to-enabled" class="button ~neutral @low">
|
||||
<input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled">
|
||||
</label>
|
||||
<div class="card ~neutral @low flex flex-col justify-between gap-2 grow">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-4">
|
||||
<label class="label supra" for="create-uses">{{ .strings.inviteNumberOfUses }}</label>
|
||||
<div class="flex flex-row gap-2">
|
||||
<input type="number" min="0" id="create-uses" class="input ~neutral @low" value=1>
|
||||
<label for="create-inf-uses" class="button ~neutral @low" title="Set uses to infinite">
|
||||
<span>∞</span>
|
||||
<input type="checkbox" class="unfocused" id="create-inf-uses" aria-label="Set uses to infinite">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="support unfocused" id="create-inf-uses-warning"><span class="badge ~critical">{{ .strings.warning }}</span> {{ .strings.inviteInfiniteUsesWarning }}</p>
|
||||
<div class="flex flex-col gap-4">
|
||||
<label class="label supra">{{ .strings.profile }}</label>
|
||||
<div class="select ~neutral @low">
|
||||
<select id="create-profile">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="create-send-to-container" class="flex flex-col gap-4">
|
||||
<label class="label supra">{{ .strings.inviteSendToEmail }}</label>
|
||||
<div class="flex flex-row gap-2">
|
||||
{{ if .discordEnabled }}
|
||||
<input type="text" id="create-send-to" class="input ~neutral @low" placeholder="example@example.com | user#1234">
|
||||
<span id="create-send-to-search" class="button ~neutral @low">
|
||||
<i class="icon ri-search-2-line" title="{{ .strings.search }}"></i>
|
||||
</span>
|
||||
{{ else }}
|
||||
<input type="email" id="create-send-to" class="input ~neutral @low" placeholder="example@example.com">
|
||||
{{ end }}
|
||||
<label for="create-send-to-enabled" class="button ~neutral @low">
|
||||
<input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="button ~urge @low supra full-width center lg" id="create-submit">{{ .strings.create }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low supra full-width center lg" id="create-submit">{{ .strings.create }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-accounts" class="unfocused">
|
||||
<div class="card @low dark:~d_neutral accounts mb-4 overflow-visible">
|
||||
<div class="flex-expand align-middle">
|
||||
<span class="text-3xl font-bold mr-4">{{ .strings.accounts }}</span>
|
||||
<div id="accounts-filter-dropdown" class="dropdown z-10" tabindex="0">
|
||||
<span class="h-100 button ~neutral @low center" id="accounts-filter-button">{{ .strings.filters }}</span>
|
||||
<div class="dropdown-display">
|
||||
<div class="card ~neutral @low mt-2" id="accounts-filter-list">
|
||||
<p class="supra pb-2">{{ .strings.filters }}</p>
|
||||
</div>
|
||||
<div id="accounts-filter-dropdown" class="dropdown manual z-10 w-full">
|
||||
<div class="flex flex-col md:flex-row align-middle gap-2">
|
||||
<div class="flex flex-row align-middle justify-between md:justify-normal">
|
||||
<span class="text-3xl font-bold mr-4">{{ .strings.accounts }}</span>
|
||||
<span class="dropdown-manual-toggle"><button class="h-full button ~neutral @low center" id="accounts-filter-button" tabindex="0">{{ .strings.filters }}</button></span>
|
||||
</div>
|
||||
<div class="flex flex-row align-middle w-full">
|
||||
<input type="search" class="field ~neutral @low input search mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
|
||||
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown-display max-w-full">
|
||||
<div class="card ~neutral @low mt-2 overflow-x-scroll" id="accounts-filter-list">
|
||||
<p class="supra pb-2">{{ .strings.filters }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<input type="search" class="field ~neutral @low input search ml-2 mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
|
||||
<span class="button ~neutral @low center -ml-8" id="accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
|
||||
</div>
|
||||
<div class="supra py-1 sm hidden" id="accounts-search-options-header">{{ .strings.searchOptions }}</div>
|
||||
<div class="row -mx-2">
|
||||
<button type="button" class="button ~neutral @low center m-2 hidden"><span id="accounts-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
|
||||
<div class="row -mx-2 mb-2">
|
||||
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="accounts-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
|
||||
<span id="accounts-filter-area"></span>
|
||||
</div>
|
||||
<div class="supra py-1 sm">{{ .strings.actions }}</div>
|
||||
<div class="row -mx-2">
|
||||
<span class="col button ~neutral @low center max-w-[20%]" id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
|
||||
<div id="accounts-announce-dropdown" class="col dropdown pb-0i max-w-[20%]" tabindex="0">
|
||||
<span class="w-100 button ~info @low center" id="accounts-announce">{{ .strings.announce }}</span>
|
||||
<div class="supra pt-1 pb-2 sm">{{ .strings.actions }}</div>
|
||||
<div class="flex flex-row flex-wrap gap-3 mb-4">
|
||||
<span class="button ~neutral @low center " id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
|
||||
<div id="accounts-announce-dropdown" class="dropdown pb-0i " tabindex="0">
|
||||
<span class="w-full button ~info @low center items-baseline" id="accounts-announce">{{ .strings.announce }}</span>
|
||||
<div class="dropdown-display">
|
||||
<div class="card ~neutral @low">
|
||||
<span class="supra sm">{{ .strings.templates }}</span>
|
||||
@@ -610,18 +774,29 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="col button ~urge @low center max-w-[20%]" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
|
||||
<span class="col button ~warning @low center max-w-[20%]" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
|
||||
<div id="accounts-disable-enable-dropdown" class="col dropdown manual pb-0i max-w-[20%]" tabindex="0">
|
||||
<span class="w-100 button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span>
|
||||
<span class="button ~urge @low center " id="accounts-modify-user">{{ .strings.modifySettings }}</span>
|
||||
{{ if .referralsEnabled }}
|
||||
<span class="button ~urge @low center " id="accounts-enable-referrals">{{ .strings.enableReferrals }}</span>
|
||||
{{ end }}
|
||||
<div id="accounts-expiry-dropdown" class="dropdown pb-0i " tabindex="0">
|
||||
<span class="w-full button ~positive @low center items-baseline" id="accounts-expiry-dropdown-button">{{ .strings.expiry }} <i class="ri-arrow-down-s-line ml-2"></i></span>
|
||||
<div class="dropdown-display">
|
||||
<div class="card ~neutral @low">
|
||||
<span class="button ~warning full-width @low center" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
|
||||
<span class="button ~critical full-width @low center mt-2" id="accounts-remove-expiry">{{ .strings.removeExpiry }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="accounts-disable-enable-dropdown" class="dropdown manual pb-0i " tabindex="0">
|
||||
<span class="w-full button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span>
|
||||
<div class="dropdown-display">
|
||||
<div class="card ~neutral @low">
|
||||
<span class="button ~urge full-width accounts-announce-template-button" id="accounts-enable-expiry">{{ .strings.setExpiry }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="col button ~info @low center unfocused max-w-[20%]" id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
|
||||
<span class="col button ~critical @low center max-w-[20%]" id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
|
||||
<span class="button ~info @low center unfocused " id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
|
||||
<span class="button ~critical @low center " id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
|
||||
</div>
|
||||
<div class="card @low accounts-header table-responsive mt-2">
|
||||
<table class="table text-base leading-4">
|
||||
@@ -642,38 +817,121 @@
|
||||
{{ if .discordEnabled }}
|
||||
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-discord">Discord</th>
|
||||
{{ end }}
|
||||
{{ if .referralsEnabled }}
|
||||
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-referrals">{{ .strings.referrals }}</th>
|
||||
{{ end }}
|
||||
<th class="grid gap-4 place-items-stretch accounts-header-expiry">{{ .strings.expiry }}</th>
|
||||
<th class="grid gap-4 place-items-stretch accounts-header-last-active">{{ .strings.lastActiveTime }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="accounts-list"></tbody>
|
||||
</table>
|
||||
<div class="unfocused h-[100%] my-3" id="accounts-not-found">
|
||||
<div class="flex flex-col h-[100%] justify-center items-center">
|
||||
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span>
|
||||
<button class="button ~neutral @low accounts-search-clear">
|
||||
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-activity" class="unfocused">
|
||||
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible">
|
||||
<div id="activity-filter-dropdown" class="dropdown manual z-10 w-full" tabindex="0">
|
||||
<div class="flex flex-col md:flex-row align-middle gap-2">
|
||||
<div class="flex flex-row align-middle justify-between md:justify-normal">
|
||||
<span class="text-3xl font-bold mr-4">{{ .strings.activity }}</span>
|
||||
<div class="flex flex-row align-middle">
|
||||
<span class="dropdown-manual-toggle"><button class="h-full button ~neutral @low center" id="activity-filter-button">{{ .strings.filters }}</button></span>
|
||||
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row align-middle w-full">
|
||||
<input type="search" class="field ~neutral @low input search mr-2" id="activity-search" placeholder="{{ .strings.search }}">
|
||||
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
|
||||
<button class="button ~info @low ml-2" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown-display max-w-full">
|
||||
<div class="card ~neutral @low mt-2 overflow-x-scroll" id="activity-filter-list">
|
||||
<p class="supra pb-2">{{ .strings.filters }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row justify-between pt-3 pb-2">
|
||||
<div class="supra sm hidden" id="activity-search-options-header">{{ .strings.searchOptions }}</div>
|
||||
<div class="supra sm flex flex-row gap-2">
|
||||
<span id="activity-total-records"></span>
|
||||
<span id="activity-loaded-records"></span>
|
||||
<span id="activity-shown-records"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row -mx-2 mb-2">
|
||||
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="activity-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
|
||||
<span id="activity-filter-area"></span>
|
||||
</div>
|
||||
<div class="my-2">
|
||||
<div id="activity-card-list"></div>
|
||||
<div id="activity-loader"></div>
|
||||
<div class="unfocused h-[100%] my-3" id="activity-not-found">
|
||||
<div class="flex flex-col h-[100%] justify-center items-center">
|
||||
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span>
|
||||
<span class="text-xl font-medium italic mb-3 unfocused" id="activity-keep-searching-description">{{ .strings.keepSearchingDescription }}</span>
|
||||
<div class="flex flex-row">
|
||||
<button class="button ~neutral @low activity-search-clear">
|
||||
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
|
||||
</button>
|
||||
<button class="button ~neutral @low unfocused" id="activity-keep-searching">{{ .strings.keepSearching }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<button class="button m-2 ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button>
|
||||
<button class="button m-2 ~neutral @low" id="activity-load-all">{{ .strings.loadAll }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-settings" class="unfocused">
|
||||
<div class="card @low dark:~d_neutral settings overflow">
|
||||
<div class="flex-expand">
|
||||
<div class="flex-row">
|
||||
<div class="flex flex-col md:flex-row align-middle gap-2">
|
||||
<div class="flex flex-row align-middle justify-between md:justify-normal">
|
||||
<span class="heading">{{ .strings.settings }}</span>
|
||||
<label for="settings-advanced-enabled" class="button ~neutral @low ml-2 my-2">
|
||||
<label for="settings-advanced-enabled" class="button ~neutral @low ml-2">
|
||||
<input type="checkbox" id="settings-advanced-enabled" aria-label="Advanced settings enabled">
|
||||
<span class="ml-2">{{ .strings.advancedSettings }} </span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<span class="button ~info @low my-1" id="settings-logs">{{ .strings.logs }}</span>
|
||||
<span class="button ~neutral @low my-1" id="settings-restart">{{ .strings.settingsRestart }}</span>
|
||||
<span class="button ~urge @low unfocused my-1" id="settings-save">{{ .strings.settingsSave }}</span>
|
||||
<div class="flex flex-row justify-start md:justify-end gap-2 w-full">
|
||||
<span class="button ~neutral @low" id="settings-logs">{{ .strings.logs }}</span>
|
||||
<span class="button ~info @low" id="settings-backups">{{ .strings.backups }}</span>
|
||||
<span class="button ~neutral @low" id="settings-restart">{{ .strings.settingsRestart }}</span>
|
||||
<span class="button ~urge @low unfocused" id="settings-save">{{ .strings.settingsSave }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row gap-3">
|
||||
<div class="card @low dark:~d_neutral col" id="settings-sidebar">
|
||||
<div class="flex flex-row justify-between">
|
||||
<input type="search" class="field ~neutral @low input settings-section-button justify-between mb-2" id="settings-search" placeholder="{{ .strings.search }}">
|
||||
<button class="button ~neutral @low center -ml-10 rounded-s-none mb-2 settings-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></button>
|
||||
</div>
|
||||
<aside class="aside sm ~urge dark:~d_info mb-2 @low" id="settings-message">Note: <span class="badge ~critical">*</span> indicates a required field, <span class="badge ~info dark:~d_warning">R</span> indicates changes require a restart.</aside>
|
||||
<span class="button ~neutral @low settings-section-button justify-between mb-2" id="setting-about"><span class="flex">{{ .strings.aboutProgram }} <i class="ri-information-line ml-2"></i></span></span>
|
||||
<span class="button ~neutral @low settings-section-button justify-between mb-2" id="setting-profiles"><span class="flex">{{ .strings.userProfiles }} <i class="ri-user-line ml-2"></i></span></span>
|
||||
</div>
|
||||
<div class="card ~neutral @low col overflow" id="settings-panel"></div>
|
||||
<div class="card ~neutral @low col overflow" id="settings-panel">
|
||||
<div class="settings-section unfocused h-[100%]" id="settings-not-found">
|
||||
<div class="flex flex-col h-[100%] justify-center items-center">
|
||||
<span class="text-2xl font-medium italic mb-2">{{ .strings.noResultsFound }}</span>
|
||||
<span class="mb-2 px-12 text-center">{{ .strings.settingsMaybeUnderAdvanced }}</span>
|
||||
<button class="button ~neutral @low settings-search-clear">
|
||||
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="{{ .cssClass }}">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="css/{{ .cssVersion }}bundle.css">
|
||||
<link rel="stylesheet" type="text/css" href="{{ .urlBase }}/css/{{ .cssVersion }}bundle.css">
|
||||
{{ template "header.html" . }}
|
||||
<title>{{ .strings.successHeader }} - jfa-go</title>
|
||||
</head>
|
||||
|
||||
@@ -27,10 +27,34 @@
|
||||
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 }}
|
||||
|
||||
@@ -14,11 +14,19 @@
|
||||
</head>
|
||||
<body class="max-w-full overflow-x-hidden section">
|
||||
<div id="modal-success" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
||||
<span class="heading mb-4">{{ if .passwordReset }}{{ .strings.passwordReset }}{{ else }}{{ .strings.successHeader }}{{ end }}</span>
|
||||
<p class="content mb-4">{{ if .passwordReset }}{{ .strings.youCanLoginPassword }}{{ else }}{{ .successMessage }}{{ end }}</p>
|
||||
<a class="button ~urge @low full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a>
|
||||
</div>
|
||||
{{ if .customSuccessCard }}
|
||||
<div class="card @low dark:~d_neutral content break-words relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
||||
{{ .customSuccessCardContent }}
|
||||
<a class="button ~urge @low full-width center supra submit my-2" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
||||
<span class="heading mb-4">{{ if .passwordReset }}{{ .strings.passwordReset }}{{ else }}{{ .strings.successHeader }}{{ end }}</span>
|
||||
<p class="content mb-4">{{ if .passwordReset }}{{ .strings.youCanLoginPassword }}{{ else }}{{ .successMessage }}{{ end }}</p>
|
||||
{{ if .userPageEnabled }}<p class="content mb-4" id="modal-success-user-page-area" my-account-term="{{ .strings.myAccount }}">{{ .strings.userPageSuccessMessage }}</p>{{ end }}
|
||||
<a class="button ~urge @low full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div id="modal-confirmation" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
||||
@@ -26,53 +34,7 @@
|
||||
<p class="content mb-4">{{ .strings.confirmationRequiredMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ 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">{{ .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="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">{{ .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="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
||||
<span class="heading mb-4">{{ .strings.linkMatrix }}</span>
|
||||
<p class="content mb-4"> {{ .strings.matrixEnterUser }}</p>
|
||||
<input type="text" class="input ~neutral @high" placeholder="@user:riot.im" id="matrix-userid">
|
||||
<div class="subheading link-center mt-4">
|
||||
<span class="shield ~info mr-4">
|
||||
<span class="icon">
|
||||
<i class="ri-chat-3-line"></i>
|
||||
</span>
|
||||
</span>
|
||||
{{ .matrixUser }}
|
||||
</div>
|
||||
<span class="button ~info @low full-width center mt-4" id="matrix-send">{{ .strings.submit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ template "account-linking.html" . }}
|
||||
<div class="top-4 left-4 absolute">
|
||||
<span class="dropdown" tabindex="0" id="lang-dropdown">
|
||||
<span class="button ~urge dropdown-button">
|
||||
@@ -88,7 +50,7 @@
|
||||
<div id="notification-box"></div>
|
||||
<div class="page-container">
|
||||
<div class="card dark:~d_neutral @low">
|
||||
<div class="flex flex-col md:flex-row gap-3 baseline">
|
||||
<div class="flex flex-col md:flex-row gap-3 items-baseline mb-2">
|
||||
<span class="heading mr-5">
|
||||
{{ if .passwordReset }}
|
||||
{{ .strings.passwordReset }}
|
||||
@@ -98,9 +60,9 @@
|
||||
</span>
|
||||
<span class="subheading">
|
||||
{{ if .passwordReset }}
|
||||
{{ .strings.enterYourPassword }}
|
||||
{{ .strings.enterYourPassword }}
|
||||
{{ else }}
|
||||
{{ .helpMessage }}
|
||||
{{ .helpMessage }}
|
||||
{{ end }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -130,21 +92,21 @@
|
||||
{{ if or (.telegramEnabled) (or .discordEnabled .matrixEnabled) }}
|
||||
<div id="contact-via" class="unfocused">
|
||||
<label class="row switch pb-4 unfocused">
|
||||
<input type="radio" name="contact-via" value="email" id="contact-via-email" class="mr-2"><span>Contact through Email</span>
|
||||
<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 unfocused">
|
||||
<input type="radio" name="contact-via" value="telegram" id="contact-via-telegram" class="mr-2"><span>Contact through Telegram</span>
|
||||
<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 unfocused">
|
||||
<input type="radio" name="contact-via" value="discord" id="contact-via-discord" class="mr-2"><span>Contact through Discord</span>
|
||||
<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 unfocused">
|
||||
<input type="radio" name="contact-via" value="matrix" id="contact-via-matrix" class="mr-2"><span>Contact through Matrix</span>
|
||||
<input type="checkbox" name="contact-via" value="matrix" id="contact-via-matrix" class="mr-2"><span>Contact through Matrix</span>
|
||||
</label>
|
||||
{{ end }}
|
||||
</div>
|
||||
@@ -167,9 +129,12 @@
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<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 }}">
|
||||
@@ -180,13 +145,15 @@
|
||||
</div>
|
||||
{{ if .captcha }}
|
||||
<div class="card ~neutral @low mb-4">
|
||||
<span class="label supra mb-2">CAPTCHA <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></span>
|
||||
<div id="captcha-img" class="mt-2 mb-2"></div>
|
||||
<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,7 +1,8 @@
|
||||
<meta charset="utf-8">
|
||||
<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">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="{{ .cssClass }}">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="css/{{ .cssVersion }}bundle.css">
|
||||
<link rel="stylesheet" type="text/css" href="{{ .urlBase }}/css/{{ .cssVersion }}bundle.css">
|
||||
{{ template "header.html" . }}
|
||||
<title>Invalid Code - jfa-go</title>
|
||||
</head>
|
||||
|
||||
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="{{ .urlBase }}/my/account"><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>
|
||||
@@ -35,7 +35,7 @@
|
||||
<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>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<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">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="support">{{ .lang.StartPage.httpsNotice }}</span>
|
||||
<span class="button ~urge @low next">{{ .lang.StartPage.start }}</span>
|
||||
</section>
|
||||
@@ -59,7 +59,7 @@
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
</section>
|
||||
@@ -122,9 +122,35 @@
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<span class="heading">{{ .lang.Proxy.title }}</span>
|
||||
<p class="content my-2" id="proxy-description">{{ .lang.Proxy.description }}</p>
|
||||
<label class="row switch pb-4">
|
||||
<input type="checkbox" class="mr-2" id="advanced-proxy"><span>{{ .lang.Strings.enabled }}</span>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span>{{ .lang.Proxy.protocol }}</span>
|
||||
<div class="select ~neutral @low mt-4 mb-2">
|
||||
<select id="advanced-proxy_protocol">
|
||||
<option value="http">HTTP</option>
|
||||
<option value="socks">SOCKS5</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Proxy.address }}</span>
|
||||
<input type="text" class="input ~neutral @low mt-4 mb-2" id="advanced-proxy_address">
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Strings.username }}</span>
|
||||
<input type="text" class="input ~neutral @low mt-4 mb-2" id="advanced-proxy_user">
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="mt-4">{{ .lang.Strings.password }}</span>
|
||||
<input type="text" class="input ~neutral @low mt-4 mb-2" id="advanced-proxy_password">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
</section>
|
||||
@@ -146,6 +172,7 @@
|
||||
<label class="row switch pb-4">
|
||||
<input type="radio" class="mr-2" name="ui-jellyfin_login" value="false"><span>{{ .lang.Login.authorizeManual }}</span>
|
||||
</label>
|
||||
<p class="support pb-4 pl-4 mt-1">{{ .lang.Login.authorizeManualUserPageNotice }}</p>
|
||||
</div>
|
||||
<div id="login-manual">
|
||||
<label class="label">
|
||||
@@ -162,7 +189,7 @@
|
||||
<span class="support mb-2 mt-1">{{ .lang.Login.emailNotice }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
</section>
|
||||
@@ -208,7 +235,7 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low" id="jellyfin-test-connection">{{ .lang.JellyfinEmby.testConnection }}</span>
|
||||
@@ -231,7 +258,22 @@
|
||||
<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">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<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.UserPage.title }}</span>
|
||||
<p class="content my-2">{{ .lang.UserPage.description }}</p>
|
||||
<p class="content my-2">{{ .lang.UserPage.customizeMessages }}</p>
|
||||
<label class="row switch pb-4">
|
||||
<input type="checkbox" class="mr-2" id="userpage-enabled"><span>{{ .lang.Strings.enabled }}</span>
|
||||
</label>
|
||||
<p class="support mb-1 mt-1">{{ .lang.UserPage.requiredSettings }}</p>
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
@@ -328,7 +370,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="section ~neutral banner footer flex-expand middle">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
@@ -350,7 +392,7 @@
|
||||
<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">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
@@ -371,7 +413,7 @@
|
||||
<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">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
@@ -391,7 +433,7 @@
|
||||
</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>
|
||||
<p class="support mb-2 mt-1">{{ .lang.PasswordResets.resetLinksNotice }} {{ .lang.PasswordResets.resetLinksRequiredForUserPage }}</p>
|
||||
</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" class="mr-2" id="password_resets-set_password"><span>{{ .lang.PasswordResets.setPassword }}</span>
|
||||
@@ -408,7 +450,7 @@
|
||||
<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">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
@@ -441,7 +483,7 @@
|
||||
<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">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
@@ -471,7 +513,7 @@
|
||||
<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">
|
||||
<section class="section ~neutral banner footer flex flex-row justify-between middle">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<div>
|
||||
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
|
||||
|
||||
179
html/user.html
Normal file
@@ -0,0 +1,179 @@
|
||||
<html lang="en" class="light">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="{{ .urlBase }}/css/{{ .cssVersion }}bundle.css">
|
||||
<script>
|
||||
window.URLBase = "{{ .urlBase }}";
|
||||
window.notificationsEnabled = {{ .notifications }};
|
||||
window.ombiEnabled = {{ .ombiEnabled }};
|
||||
window.langFile = JSON.parse({{ .language }});
|
||||
window.pwrEnabled = {{ .pwrEnabled }};
|
||||
window.linkResetEnabled = {{ .linkResetEnabled }};
|
||||
window.language = "{{ .langName }}";
|
||||
window.telegramEnabled = {{ .telegramEnabled }};
|
||||
window.telegramRequired = {{ .telegramRequired }};
|
||||
window.telegramUsername = {{ .telegramUsername }};
|
||||
window.telegramURL = {{ .telegramURL }};
|
||||
window.emailEnabled = {{ .emailEnabled }};
|
||||
window.emailRequired = {{ .emailRequired }};
|
||||
window.discordEnabled = {{ .discordEnabled }};
|
||||
window.discordRequired = {{ .discordRequired }};
|
||||
window.discordServerName = "{{ .discordServerName }}";
|
||||
window.discordInviteLink = {{ .discordInviteLink }};
|
||||
window.discordSendPINMessage = "{{ .discordSendPINMessage }}";
|
||||
window.matrixEnabled = {{ .matrixEnabled }};
|
||||
window.matrixRequired = {{ .matrixRequired }};
|
||||
window.matrixUserID = "{{ .matrixUser }}";
|
||||
window.validationStrings = JSON.parse({{ .validationStrings }});
|
||||
window.referralsEnabled = {{ .referralsEnabled }};
|
||||
</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="top-4 left-4 absolute">
|
||||
<span class="dropdown" tabindex="0" id="lang-dropdown">
|
||||
<span class="button ~urge dropdown-button">
|
||||
<i class="ri-global-line"></i>
|
||||
<span class="ml-2 chev"></span>
|
||||
</span>
|
||||
<div class="dropdown-display">
|
||||
<div class="card ~neutral @low">
|
||||
<label class="switch pb-4">
|
||||
<input type="radio" name="lang-time" id="lang-12h">
|
||||
<span>{{ .strings.time12h }}</span>
|
||||
</label>
|
||||
<label class="switch pb-4">
|
||||
<input type="radio" name="lang-time" id="lang-24h">
|
||||
<span>{{ .strings.time24h }}</span>
|
||||
</label>
|
||||
<div id="lang-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<span class="button ~warning" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
|
||||
<span class="button ~critical @low mb-4 unfocused" id="logout-button">{{ .strings.logout }}</span>
|
||||
</div>
|
||||
<div class="top-4 right-4 absolute">
|
||||
<a class="button ~info unfocused" href="/" id="admin-back-button"><i class="ri-arrow-left-fill mr-2"></i>{{ .strings.admin }}</a>
|
||||
</div>
|
||||
<div class="page-container unfocused">
|
||||
<div class="card @low dark:~d_neutral mb-4" id="card-user">
|
||||
<span class="heading mb-2"></span>
|
||||
</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="{{ .urlBase }}/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: 40 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 |
|
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 |
412
images/src/banner-hanken.svg
Normal file
|
After Width: | Height: | Size: 64 KiB |
283
images/src/banner-quicksand.svg
Normal file
|
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 |
7
jellyseerr/go.mod
Normal file
@@ -0,0 +1,7 @@
|
||||
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 // indirect
|
||||
460
jellyseerr/jellyseerr.go
Normal file
@@ -0,0 +1,460 @@
|
||||
package jellyseerr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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 common.TimeoutHandler
|
||||
LogRequestBodies bool
|
||||
AutoImportUsers bool
|
||||
}
|
||||
|
||||
// NewJellyseerr returns an Ombi object.
|
||||
func NewJellyseerr(server, key string, timeoutHandler common.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,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
reqFailed := err != nil || !(resp.StatusCode == 200 || resp.StatusCode == 201)
|
||||
defer js.timeoutHandler()
|
||||
var responseText string
|
||||
defer resp.Body.Close()
|
||||
if response || reqFailed {
|
||||
responseText, err = js.decodeResp(resp)
|
||||
if err != nil {
|
||||
return responseText, resp.StatusCode, err
|
||||
}
|
||||
}
|
||||
if reqFailed {
|
||||
var msg ErrorDTO
|
||||
err = json.Unmarshal([]byte(responseText), &msg)
|
||||
if err != nil {
|
||||
return responseText, resp.StatusCode, err
|
||||
}
|
||||
if msg.Message == "" {
|
||||
err = fmt.Errorf("failed (error %d)", resp.StatusCode)
|
||||
} else {
|
||||
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, status, err := js.post(js.server+"/user/import-from-jellyfin", params, true)
|
||||
var data []User
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
if status != 200 && status != 201 {
|
||||
return data, fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
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, status, err := js.get(js.server+"/user", nil, params)
|
||||
var data GetUsersDTO
|
||||
if status != 200 {
|
||||
return data, fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
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, status, err := js.get(js.server+"/auth/me", nil, url.Values{})
|
||||
var data User
|
||||
data.ID = -1
|
||||
if status != 200 {
|
||||
return data, fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
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, status, err := js.get(fmt.Sprintf(js.server+"/user/%d/settings/permissions", u.ID), nil, url.Values{})
|
||||
if err != nil {
|
||||
return data.Permissions, err
|
||||
}
|
||||
if status != 200 {
|
||||
return data.Permissions, fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
_, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/permissions", u.ID), permissionsDTO{Permissions: perm}, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != 200 && status != 201 {
|
||||
return fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
_, status, err := js.put(fmt.Sprintf(js.server+"/user/%d", u.ID), tmpl, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != 200 && status != 201 {
|
||||
return fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
_, status, err := js.put(fmt.Sprintf(js.server+"/user/%d", u.ID), conf, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != 200 && status != 201 {
|
||||
return fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
status, err := js.delete(fmt.Sprintf(js.server+"/user/%d", u.ID), nil)
|
||||
if status != 200 && status != 201 {
|
||||
return fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
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, status, err := js.get(fmt.Sprintf(js.server+"/user/%d/settings/notifications", jellyseerrID), nil, url.Values{})
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
if status != 200 {
|
||||
return data, fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
_, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/notifications", u.ID), tmpl, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != 200 && status != 201 {
|
||||
return fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (js *Jellyseerr) ModifyNotifications(jfID string, conf map[NotificationsField]any) error {
|
||||
u, err := js.getUser(jfID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/notifications", u.ID), conf, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != 200 && status != 201 {
|
||||
return fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
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, status, err := js.get(js.server+fmt.Sprintf("/user/%d", jellyseerrID), nil, url.Values{})
|
||||
var data User
|
||||
if status != 200 {
|
||||
return data, fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
_, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/main", u.ID), conf, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != 200 && status != 201 {
|
||||
return fmt.Errorf("failed (error %d)", status)
|
||||
}
|
||||
// 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"`
|
||||
}
|
||||
81
jellyseerrdaemon.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/hrfee/jfa-go/jellyseerr"
|
||||
)
|
||||
|
||||
func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
|
||||
user, imported, err := app.js.GetOrImportUser(jfID)
|
||||
if err != nil {
|
||||
app.debug.Printf("Failed to get or trigger import for Jellyseerr (user \"%s\"): %v", jfID, err)
|
||||
return
|
||||
}
|
||||
if imported {
|
||||
app.debug.Printf("Jellyseerr: Triggered import for Jellyfin user \"%s\" (ID %d)", jfID, user.ID)
|
||||
}
|
||||
notif, err := app.js.GetNotificationPreferencesByID(user.ID)
|
||||
if err != nil {
|
||||
app.debug.Printf("Failed to get notification prefs for Jellyseerr (user \"%s\"): %v", 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("Failed to set Jellyseerr email address: %v\n", 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("Failed to sync contact methods with Jellyseerr: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (app *appContext) SynchronizeJellyseerrUsers() {
|
||||
users, status, err := app.jf.GetUsers(false)
|
||||
if err != nil || status != 200 {
|
||||
app.err.Printf("Failed to get users (%d): %s", status, 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 daemon")
|
||||
return d
|
||||
}
|
||||
41
lang.go
@@ -26,8 +26,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,9 +40,9 @@ type adminLang struct {
|
||||
JSON string
|
||||
}
|
||||
|
||||
type formLangs map[string]formLang
|
||||
type userLangs map[string]userLang
|
||||
|
||||
func (ls *formLangs) getOptions() [][2]string {
|
||||
func (ls *userLangs) getOptions() [][2]string {
|
||||
opts := make([][2]string, len(*ls))
|
||||
i := 0
|
||||
for key, lang := range *ls {
|
||||
@@ -50,13 +52,15 @@ func (ls *formLangs) getOptions() [][2]string {
|
||||
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
|
||||
@@ -89,18 +93,19 @@ func (ls *emailLangs) getOptions() [][2]string {
|
||||
}
|
||||
|
||||
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,6 +117,7 @@ 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"`
|
||||
@@ -119,6 +125,7 @@ type setupLang struct {
|
||||
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"`
|
||||
|
||||
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ů."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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…",
|
||||
@@ -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:",
|
||||
@@ -114,7 +102,35 @@
|
||||
"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."
|
||||
"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}.",
|
||||
@@ -128,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}",
|
||||
@@ -142,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.",
|
||||
@@ -150,14 +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.",
|
||||
"setOmbiProfile": "Gemt i ombi profilen.",
|
||||
"errorSetOmbiProfile": "Ombi profilen kunne ikke gemmes."
|
||||
"errorSetOmbiProfile": "Ombi profilen kunne ikke gemmes.",
|
||||
"referralsEnabled": "Henvisninger aktiveret.",
|
||||
"errorNoReferralTemplate": "Profilen indeholder ikke en henvisningsskabelon. Tilføj en i indstillingerne."
|
||||
},
|
||||
"quantityStrings": {
|
||||
"modifySettingsFor": {
|
||||
@@ -197,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.",
|
||||
@@ -215,6 +228,10 @@
|
||||
"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",
|
||||
@@ -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,7 +64,6 @@
|
||||
"variables": "Variablen",
|
||||
"preview": "Vorschau",
|
||||
"reset": "Zurücksetzen",
|
||||
"edit": "Bearbeiten",
|
||||
"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",
|
||||
@@ -79,23 +74,16 @@
|
||||
"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",
|
||||
@@ -106,7 +94,7 @@
|
||||
"accessJFA": "jfa-go Zugriff",
|
||||
"sendPWRValidFor": "Der Link ist 30m gültig.",
|
||||
"logs": "Logdaten",
|
||||
"setExpiry": "Ablauf setzen",
|
||||
"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",
|
||||
@@ -114,7 +102,41 @@
|
||||
"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."
|
||||
"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.",
|
||||
@@ -122,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.",
|
||||
|
||||
@@ -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} χρήστες."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,12 +68,8 @@
|
||||
"inviteHours": "Hours",
|
||||
"inviteInfiniteUsesWarning": "invites with infinite uses can be used abusively",
|
||||
"inviteSendToEmail": "Send to",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"apply": "Apply",
|
||||
"delete": "Delete",
|
||||
"updates": "Updates",
|
||||
"expiry": "Expiry",
|
||||
"variables": "Variables",
|
||||
"preview": "Preview",
|
||||
"markdownSupported": "Markdown is supported.",
|
||||
@@ -96,16 +92,10 @@
|
||||
"contactThrough": "Contact through:",
|
||||
"select": "Select",
|
||||
"date": "Date",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"disable": "Disable",
|
||||
"edit": "Edit",
|
||||
"extendExpiry": "Extend expiry",
|
||||
"sendPWR": "Send Password Reset",
|
||||
"inviteMonths": "Months",
|
||||
"inviteDuration": "Invite Duration",
|
||||
"add": "Add",
|
||||
"reEnable": "Re-enable",
|
||||
"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.",
|
||||
@@ -134,7 +124,6 @@
|
||||
"addProfileStoreHomescreenLayout": "Store homescreen layout",
|
||||
"inviteNoUsersCreated": "None yet!",
|
||||
"inviteUsersCreated": "Created users",
|
||||
"inviteRemainingUses": "Remaining uses",
|
||||
"inviteNoInvites": "None",
|
||||
"inviteExpiresInTime": "Expires in {n}",
|
||||
"notifyEvent": "Notify on:",
|
||||
@@ -150,7 +139,6 @@
|
||||
"settingsApplyRestartLater": "Apply, restart later",
|
||||
"subject": "Subject",
|
||||
"setExpiry": "Set expiry",
|
||||
"admin": "Admin",
|
||||
"download": "Download",
|
||||
"search": "Search",
|
||||
"advancedSettings": "Advanced Settings",
|
||||
@@ -175,9 +163,7 @@
|
||||
},
|
||||
"notifications": {
|
||||
"errorSettingsFailed": "Application failed.",
|
||||
"errorLoginBlank": "The username and/or password was left blank.",
|
||||
"errorLoadSettings": "Failed to load settings.",
|
||||
"errorUnknown": "Unknown error.",
|
||||
"errorDeleteProfile": "Failed to delete profile {n}",
|
||||
"sentAnnouncement": "Announcement sent.",
|
||||
"savedAnnouncement": "Announcement saved.",
|
||||
@@ -186,13 +172,11 @@
|
||||
"updateAppliedRefresh": "Update applied, please refresh.",
|
||||
"telegramVerified": "Telegram account verified.",
|
||||
"accountConnected": "Account connected.",
|
||||
"error401Unauthorized": "Unauthorised. Try refreshing the page.",
|
||||
"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.",
|
||||
"errorSaveSettings": "Couldn't save settings.",
|
||||
"errorSetOmbiProfile": "Failed to store ombi profile.",
|
||||
"errorLoadOmbiUsers": "Failed to load ombi users.",
|
||||
"errorFailureCheckLogs": "Failed (check console/logs)",
|
||||
@@ -207,11 +191,10 @@
|
||||
"saveEmail": "Email saved.",
|
||||
"createProfile": "Created profile {n}.",
|
||||
"saveSettings": "Settings were saved",
|
||||
"errorConnection": "Couldn't connect to jfa-go.",
|
||||
"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",
|
||||
@@ -40,7 +32,6 @@
|
||||
"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",
|
||||
@@ -50,6 +41,8 @@
|
||||
"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",
|
||||
@@ -59,12 +52,19 @@
|
||||
"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",
|
||||
"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.",
|
||||
@@ -74,7 +74,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",
|
||||
@@ -87,10 +96,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",
|
||||
@@ -101,7 +116,6 @@
|
||||
"inviteUsersCreated": "Created users",
|
||||
"inviteNoProfile": "No Profile",
|
||||
"inviteDateCreated": "Created",
|
||||
"inviteRemainingUses": "Remaining uses",
|
||||
"inviteNoInvites": "None",
|
||||
"inviteExpiresInTime": "Expires in {n}",
|
||||
"notifyEvent": "Notify on:",
|
||||
@@ -118,15 +132,79 @@
|
||||
"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",
|
||||
"actions": "Actions",
|
||||
"searchOptions": "Search Options",
|
||||
"matchText": "Match Text",
|
||||
"jellyfinID": "Jellyfin ID"
|
||||
"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",
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"notifications": {
|
||||
"pathCopied": "Full path copied to clipboard.",
|
||||
"changedEmailAddress": "Changed email address of {n}.",
|
||||
"userCreated": "User {n} created.",
|
||||
"createProfile": "Created profile {n}.",
|
||||
@@ -135,25 +213,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.",
|
||||
@@ -164,6 +243,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."
|
||||
},
|
||||
@@ -172,6 +254,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"
|
||||
|
||||
@@ -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",
|
||||
@@ -114,7 +102,41 @@
|
||||
"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."
|
||||
"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}.",
|
||||
@@ -125,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}",
|
||||
@@ -139,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.",
|
||||
|
||||
@@ -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é",
|
||||
@@ -58,7 +55,6 @@
|
||||
"inviteUsersCreated": "Utilisateurs créés",
|
||||
"inviteNoProfile": "Aucun profil",
|
||||
"inviteDateCreated": "Créer",
|
||||
"inviteRemainingUses": "Utilisations restantes",
|
||||
"inviteNoInvites": "Aucune",
|
||||
"inviteExpiresInTime": "Expires dans {n}",
|
||||
"notifyEvent": "Notifier sur :",
|
||||
@@ -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.",
|
||||
@@ -115,7 +103,100 @@
|
||||
"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."
|
||||
"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}.",
|
||||
@@ -123,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.",
|
||||
@@ -158,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": {
|
||||
@@ -216,6 +300,10 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,21 +15,12 @@
|
||||
"warning": "Figyelmeztetés",
|
||||
"inviteInfiniteUsesWarning": "a végtelen felhasználású meghívókkal visszaélhetnek",
|
||||
"inviteSendToEmail": "Címzett",
|
||||
"login": "Belépés",
|
||||
"logout": "Kijelentkezés",
|
||||
"create": "Létrehozás",
|
||||
"apply": "Alkalmaz",
|
||||
"delete": "Törlés",
|
||||
"add": "Hozzáadás",
|
||||
"select": "Kiválasztás",
|
||||
"name": "Név",
|
||||
"date": "Dátum",
|
||||
"enabled": "Engedélyezve",
|
||||
"disabled": "Tiltva",
|
||||
"reEnable": "Újra engedélyezés",
|
||||
"setExpiry": "Lejárat beállítása",
|
||||
"disable": "Letiltás",
|
||||
"admin": "Adminisztrátor",
|
||||
"updates": "Frissítések",
|
||||
"update": "Frissítés",
|
||||
"download": "Letöltés",
|
||||
@@ -38,9 +29,8 @@
|
||||
"lastActiveTime": "Utoljára aktív",
|
||||
"from": "Feladó",
|
||||
"user": "Felhasználó",
|
||||
"expiry": "Lejárat",
|
||||
"userExpiry": "Felhasználói lejárat",
|
||||
"userExpiryDescription": "Egy meghatározott idő után minden regisztrációt töröl, vagy felfüggeszt a jfa-go. Ezt a működést megváltoztathatod a beállításokban.",
|
||||
"userExpiry": "Felhasználó megszünése",
|
||||
"userExpiryDescription": "Egy meghatározott idő után minden létrehozott felhasználó, vagy felfüggesztésre vagy törlésre kerül rendszer által. A viselkedés a beállításokban módosítható.",
|
||||
"aboutProgram": "Névjegy",
|
||||
"version": "Verzió",
|
||||
"commitNoun": "Elkövet",
|
||||
@@ -57,53 +47,52 @@
|
||||
"conditionals": "Feltételek",
|
||||
"preview": "Előnézet",
|
||||
"reset": "Visszaállítás",
|
||||
"edit": "Szerkesztés",
|
||||
"donate": "Támogatás",
|
||||
"sendPWR": "Jelszó visszaállítás küldése",
|
||||
"contactThrough": "",
|
||||
"extendExpiry": "",
|
||||
"sendPWRManual": "",
|
||||
"sendPWRSuccess": "",
|
||||
"sendPWRSuccessManual": "",
|
||||
"sendPWRValidFor": "",
|
||||
"customizeMessages": "",
|
||||
"customizeMessagesDescription": "",
|
||||
"markdownSupported": "",
|
||||
"modifySettings": "",
|
||||
"modifySettingsDescription": "",
|
||||
"applyHomescreenLayout": "",
|
||||
"sendDeleteNotificationEmail": "",
|
||||
"sendDeleteNotifiationExample": "",
|
||||
"settingsRestart": "",
|
||||
"settingsRestarting": "",
|
||||
"settingsRestartRequired": "",
|
||||
"settingsRestartRequiredDescription": "",
|
||||
"settingsApplyRestartLater": "",
|
||||
"settingsApplyRestartNow": "",
|
||||
"settingsApplied": "",
|
||||
"settingsRefreshPage": "",
|
||||
"settingsRequiredOrRestartMessage": "",
|
||||
"settingsSave": "",
|
||||
"ombiProfile": "",
|
||||
"ombiUserDefaultsDescription": "",
|
||||
"userProfiles": "",
|
||||
"userProfilesDescription": "",
|
||||
"userProfilesIsDefault": "",
|
||||
"userProfilesLibraries": "",
|
||||
"addProfile": "",
|
||||
"addProfileDescription": "",
|
||||
"addProfileNameOf": "",
|
||||
"addProfileStoreHomescreenLayout": "",
|
||||
"inviteNoUsersCreated": "",
|
||||
"inviteUsersCreated": "",
|
||||
"inviteNoProfile": "",
|
||||
"inviteDateCreated": "",
|
||||
"inviteRemainingUses": "",
|
||||
"inviteNoInvites": "",
|
||||
"inviteExpiresInTime": "",
|
||||
"notifyEvent": "",
|
||||
"notifyInviteExpiry": "",
|
||||
"notifyUserCreation": "",
|
||||
"contactThrough": "Kapcsolatfelvétel vele:",
|
||||
"extendExpiry": "Lejárat kiterjesztése",
|
||||
"sendPWRManual": "{n} felhasználónak nincs beállítva egy kapcsolati lehetőség sem, kattints a másolásra a linkhez és küld tovább neki.",
|
||||
"sendPWRSuccess": "Jelszó visszaállítási link elküldve.",
|
||||
"sendPWRSuccessManual": "Ha a felhasználó nem kapta meg, nyomj a másolásra és küld el neki manuálisan.",
|
||||
"sendPWRValidFor": "A link érvényessége 30p.",
|
||||
"customizeMessages": "Üzenetek testreszabása",
|
||||
"customizeMessagesDescription": "Hogyha nem akarod a jfa-go üzenet sablonjait használni, létre hozhatsz egy sajátot, akár Markdown segítségével.",
|
||||
"markdownSupported": "Markdown támogatott.",
|
||||
"modifySettings": "Beállítások módosítása",
|
||||
"modifySettingsDescription": "Beállítások másolása egy meglévő profilról, vagy egy konkrét felhasználóról.",
|
||||
"applyHomescreenLayout": "Főképernyő elrendezés alkalmazása",
|
||||
"sendDeleteNotificationEmail": "Értesítések küldése",
|
||||
"sendDeleteNotifiationExample": "A fiókod törlésre került.",
|
||||
"settingsRestart": "Újraindítás",
|
||||
"settingsRestarting": "Újraindítás…",
|
||||
"settingsRestartRequired": "Újraindítás szükséges",
|
||||
"settingsRestartRequiredDescription": "A változtatott beállítások érvénybe léptetéséhez újraindítás szükséges. Most szeretnéd újraindítani vagy később?",
|
||||
"settingsApplyRestartLater": "Alkalmazás, újraindítás később",
|
||||
"settingsApplyRestartNow": "Alkalmazás és újraindítás",
|
||||
"settingsApplied": "Beállítások alkalmazva.",
|
||||
"settingsRefreshPage": "Frissítsd az oldalt egy pár másodperc múlva.",
|
||||
"settingsRequiredOrRestartMessage": "Megjegyzés: {n} jelöli a kötelező mezőket, {n} jelöli hogyha újraindítás szükséges.",
|
||||
"settingsSave": "Mentés",
|
||||
"ombiProfile": "Ombi felhasználói profil",
|
||||
"ombiUserDefaultsDescription": "Hozz létre egy Ombi felhasználót, állítsd be, majd válaszd ki lentebb. A beállításai/jogosultságai el lesznek mentve és alkalmazva lesznek az új Ombi felhasználókra amik ezzel a profillal lesznek létrehozva.",
|
||||
"userProfiles": "Felhasználói profilok",
|
||||
"userProfilesDescription": "A profilok alkalmazva lesznek a felhasználó létrehozáskor. A profil tartalmazza a könyvtár hozzáférést, és a kezdőképernyő elrendezést.",
|
||||
"userProfilesIsDefault": "Alapértelmezett",
|
||||
"userProfilesLibraries": "Könyvtárak",
|
||||
"addProfile": "Profil hozzáadása",
|
||||
"addProfileDescription": "Hozz létre egy Jellyfin felhasználót, állítsd be, majd válaszd ki lentebb. Amikor egy felhasználó létrejön egy meghívásból aminél ez a profil volt alkalmazva, megkapja az összes beállítását.",
|
||||
"addProfileNameOf": "Profil neve",
|
||||
"addProfileStoreHomescreenLayout": "Kezdőképernyő elrendezés elmentése",
|
||||
"inviteNoUsersCreated": "Még nincs!",
|
||||
"inviteUsersCreated": "Létrehozott felhasználók",
|
||||
"inviteNoProfile": "Profil nélkül",
|
||||
"inviteDateCreated": "Létrehozva",
|
||||
"inviteRemainingUses": "Hátralévő felhasználások",
|
||||
"inviteNoInvites": "Nincs meghívó",
|
||||
"inviteExpiresInTime": "Lejárat {n} múlva",
|
||||
"notifyEvent": "Értesítés ekkor:",
|
||||
"notifyInviteExpiry": "Lejáratkor",
|
||||
"notifyUserCreation": "Használatkor",
|
||||
"sendPIN": "",
|
||||
"searchDiscordUser": "",
|
||||
"findDiscordUser": "",
|
||||
@@ -111,7 +100,19 @@
|
||||
"matrixHomeServer": "",
|
||||
"saveAsTemplate": "",
|
||||
"deleteTemplate": "",
|
||||
"templateEnterName": ""
|
||||
"templateEnterName": "",
|
||||
"unlink": "Fiók leválasztása",
|
||||
"after": "Utánna",
|
||||
"before": "Elötte",
|
||||
"sortingBy": "Rendezés",
|
||||
"filters": "Szűrők",
|
||||
"clearSearch": "Keresés törlése",
|
||||
"actions": "Műveletek",
|
||||
"searchOptions": "Kereső paraméterek",
|
||||
"matchText": "Eggyező szöveg",
|
||||
"jellyfinID": "Jellyfin azonosító",
|
||||
"userPageLogin": "Felhasználói oldal: Bejelentkezés",
|
||||
"clickToRemoveFilter": "Szűrő eltávolítása."
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "",
|
||||
@@ -126,13 +127,9 @@
|
||||
"updateAppliedRefresh": "",
|
||||
"telegramVerified": "",
|
||||
"accountConnected": "",
|
||||
"errorConnection": "",
|
||||
"error401Unauthorized": "",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "",
|
||||
"errorHomescreenAppliedNoSettings": "",
|
||||
"errorSettingsFailed": "",
|
||||
"errorLoginBlank": "",
|
||||
"errorUnknown": "",
|
||||
"errorSaveEmail": "",
|
||||
"errorBlankFields": "",
|
||||
"errorDeleteProfile": "",
|
||||
@@ -140,7 +137,6 @@
|
||||
"errorCreateProfile": "",
|
||||
"errorSetDefaultProfile": "",
|
||||
"errorLoadUsers": "",
|
||||
"errorSaveSettings": "",
|
||||
"errorLoadSettings": "",
|
||||
"errorSetOmbiProfile": "",
|
||||
"errorLoadOmbiUsers": "",
|
||||
|
||||
@@ -13,11 +13,8 @@
|
||||
"warning": "Peringatan",
|
||||
"inviteInfiniteUsesWarning": "Undangan dalam jumlah tak terbatas dapat disalahgunakan",
|
||||
"inviteSendToEmail": "Dikirim kepada",
|
||||
"login": "Masuk",
|
||||
"logout": "Keluar",
|
||||
"create": "Buat",
|
||||
"apply": "Terapkan",
|
||||
"delete": "Hapus",
|
||||
"name": "Nama",
|
||||
"date": "Tanggal",
|
||||
"lastActiveTime": "Terakhir Aktif",
|
||||
@@ -33,7 +30,7 @@
|
||||
"modifySettings": "Ganti Pengaturan",
|
||||
"modifySettingsDescription": "Terapkan pengaturan dari profil yang ada, atau dapatkan langsung dari pengguna.",
|
||||
"applyHomescreenLayout": "Terapkan tata letak layar beranda",
|
||||
"sendDeleteNotificationEmail": "Kirim email notifikasi",
|
||||
"sendDeleteNotificationEmail": "Kirim pesan notifikasi",
|
||||
"sendDeleteNotifiationExample": "Akun anda telah dihapus.",
|
||||
"settingsRestart": "Mulai ulang",
|
||||
"settingsRestarting": "Mengulang kembali…",
|
||||
@@ -59,7 +56,6 @@
|
||||
"inviteUsersCreated": "Pengguna yang telah dibuat",
|
||||
"inviteNoProfile": "Tidak ada profil",
|
||||
"inviteDateCreated": "Dibuat",
|
||||
"inviteRemainingUses": "Penggunaan yang tersisa",
|
||||
"inviteNoInvites": "Tidak ada",
|
||||
"inviteExpiresInTime": "Kadaluarsa dalam {n}",
|
||||
"notifyEvent": "Beritahu pada:",
|
||||
@@ -68,13 +64,19 @@
|
||||
"variables": "Variabel",
|
||||
"preview": "Pratinjau",
|
||||
"reset": "Setel ulang",
|
||||
"edit": "Edit",
|
||||
"customizeMessages": "Sesuaikan Email",
|
||||
"customizeMessagesDescription": "Jika Anda tidak ingin menggunakan templat email jfa-go, Anda dapat membuatnya sendiri menggunakan Markdown.",
|
||||
"announce": "Mengumumkan",
|
||||
"subject": "Subjek Email",
|
||||
"subject": "Subjek",
|
||||
"message": "Pesan",
|
||||
"markdownSupported": "Markdown didukung."
|
||||
"markdownSupported": "Markdown didukung.",
|
||||
"donate": "Donasi",
|
||||
"select": "Pilih",
|
||||
"search": "Cari",
|
||||
"download": "Unduh",
|
||||
"inviteMonths": "Bulan",
|
||||
"inviteDuration": "Durasi undangan",
|
||||
"activity": "Aktivitas"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "Alamat email {n} diubah.",
|
||||
@@ -82,20 +84,15 @@
|
||||
"createProfile": "Membuat profil {n}.",
|
||||
"saveSettings": "Pengaturan telah disimpan",
|
||||
"setOmbiDefaults": "Default ombi tersimpan.",
|
||||
"errorConnection": "Tidak dapat terhubung ke jfa-go.",
|
||||
"error401Unauthorized": "Tidak ter-otorisasi. Coba segarkan halaman.",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "Pengaturan telah diterapkan, tetapi menerapkan tata letak layar utama mungkin gagal.",
|
||||
"errorHomescreenAppliedNoSettings": "Tata letak layar beranda diterapkan, tetapi menerapkan pengaturan mungkin gagal.",
|
||||
"errorSettingsFailed": "Aplikasi gagal.",
|
||||
"errorLoginBlank": "Nama pengguna dan / atau sandi kosong.",
|
||||
"errorUnknown": "Kesalahan yang tidak diketahui.",
|
||||
"errorBlankFields": "Isian dibiarkan kosong",
|
||||
"errorDeleteProfile": "Gagal menghapus profil {n}",
|
||||
"errorLoadProfiles": "Gagal memuat profil.",
|
||||
"errorCreateProfile": "Gagal membuat profil {n}",
|
||||
"errorSetDefaultProfile": "Gagal menyetel profil default.",
|
||||
"errorLoadUsers": "Gagal memuat pengguna.",
|
||||
"errorSaveSettings": "Tidak dapat menyimpan pengaturan.",
|
||||
"errorLoadSettings": "Gagal memuat pengaturan.",
|
||||
"errorSetOmbiDefaults": "Gagal menyimpan default ombi.",
|
||||
"errorLoadOmbiUsers": "Gagal memuat pengguna ombi.",
|
||||
|
||||
216
lang/admin/it-it.json
Normal file
@@ -0,0 +1,216 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Italiano (IT)"
|
||||
},
|
||||
"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": "",
|
||||
"aboutProgram": "",
|
||||
"version": "",
|
||||
"commitNoun": "",
|
||||
"newUser": "",
|
||||
"profile": "",
|
||||
"unknown": "",
|
||||
"label": "",
|
||||
"logs": "",
|
||||
"announce": "",
|
||||
"templates": "",
|
||||
"subject": "",
|
||||
"message": "",
|
||||
"variables": "",
|
||||
"conditionals": "",
|
||||
"preview": "",
|
||||
"reset": "Ripristino",
|
||||
"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": "",
|
||||
"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": ""
|
||||
},
|
||||
"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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,8 @@
|
||||
"warning": "Waarschuwing",
|
||||
"inviteInfiniteUsesWarning": "ongelimiteerde uitnodigingen kunnen misbruikt worden",
|
||||
"inviteSendToEmail": "Stuur naar",
|
||||
"login": "Inloggen",
|
||||
"logout": "Uitloggen",
|
||||
"create": "Aanmaken",
|
||||
"apply": "Toepassen",
|
||||
"delete": "Verwijderen",
|
||||
"name": "Naam",
|
||||
"date": "Datum",
|
||||
"lastActiveTime": "Laatst actief",
|
||||
@@ -56,7 +53,6 @@
|
||||
"inviteUsersCreated": "Aangemaakte gebruikers",
|
||||
"inviteNoProfile": "Geen profiel",
|
||||
"inviteDateCreated": "Aangemaakt",
|
||||
"inviteRemainingUses": "Resterend aantal keer te gebruiken",
|
||||
"inviteNoInvites": "Geen",
|
||||
"inviteExpiresInTime": "Verloopt over {n}",
|
||||
"notifyEvent": "Meldingen:",
|
||||
@@ -73,14 +69,9 @@
|
||||
"customizeMessagesDescription": "Als je de e-mailsjablonen van jfa-go niet wilt gebruiken, kun je met gebruik van Markdown je eigen aanmaken.",
|
||||
"preview": "Voorbeeld",
|
||||
"reset": "Reset",
|
||||
"edit": "Bewerken",
|
||||
"customizeMessages": "E-mails aanpassen",
|
||||
"inviteDuration": "Geldigheidsduur uitnodiging",
|
||||
"userExpiryDescription": "Een bepaalde tijd na elke aanmelding, wordt de account verwijderd/uitgeschakeld door jfa-go. Dit kan aangepast worden in de instellingen.",
|
||||
"enabled": "Ingeschakeld",
|
||||
"disabled": "Uitgeschakeld",
|
||||
"admin": "Beheerder",
|
||||
"expiry": "Verloop",
|
||||
"userExpiry": "Gebruikersverloop",
|
||||
"extendExpiry": "Verleng verloop",
|
||||
"updates": "Updates",
|
||||
@@ -89,13 +80,10 @@
|
||||
"search": "Zoeken",
|
||||
"advancedSettings": "Geavanceerde instellingen",
|
||||
"inviteMonths": "Maanden",
|
||||
"reEnable": "Opnieuw inschakelen",
|
||||
"disable": "Uitschakelen",
|
||||
"conditionals": "Voorwaarden",
|
||||
"donate": "Doneer",
|
||||
"contactThrough": "Stuur bericht via:",
|
||||
"sendPIN": "Vraag de gebruiker om onderstaande pincode naar de bot te sturen.",
|
||||
"add": "Voeg toe",
|
||||
"searchDiscordUser": "Begin de Discord gebruikersnaam te typen om de gebruiker te vinden.",
|
||||
"linkMatrixDescription": "Vul de gebruikersnaam en wachtwoord in van de gebruiker om als bot te gebruiken. De app start zodra ze zijn verstuurd.",
|
||||
"select": "Selecteer",
|
||||
@@ -114,7 +102,100 @@
|
||||
"ombiProfile": "Ombi gebruikersprofiel",
|
||||
"logs": "Logs",
|
||||
"accessJFA": "Toegang tot jfa-go",
|
||||
"accessJFASettings": "Kan niet worden aangepast, omdat \"Alleen beheerders\" of \"Laat alle Jellyfin-gebruikers inloggen\" is aangevinkt in Instellingen > Algemeen."
|
||||
"accessJFASettings": "Kan niet worden aangepast, omdat \"Alleen beheerders\" of \"Laat alle Jellyfin-gebruikers inloggen\" is aangevinkt in Instellingen > Algemeen.",
|
||||
"noResultsFound": "Geen resultaten gevonden",
|
||||
"settingsHiddenDependency": "Overeenkomende instellingen zijn verborgen, omdat ze afhangen van een andere instelling:",
|
||||
"settingsAdvancedMode": "{setting}: Geavanceerde instellingen moet ingeschakeld zijn",
|
||||
"builtBy": "Build door",
|
||||
"buildTime": "Build moment",
|
||||
"userPageLogin": "Gebruikerspagina: Inloggen",
|
||||
"loginNotAdmin": "Geen beheerder?",
|
||||
"before": "Voor",
|
||||
"unlink": "Ontkoppel account",
|
||||
"after": "Na",
|
||||
"invite": "Uitnodiging",
|
||||
"userLabel": "Gebruikerslabel",
|
||||
"userLabelDescription": "Label om toe te wijzen aan gebruikers aangemaakt met deze uitnodiging.",
|
||||
"enableReferrals": "Verwijzingen inschakelen",
|
||||
"disableReferrals": "Verwijzingen uitschakelen",
|
||||
"enableReferralsDescription": "Geef gebruikers een persoonlijke verwijslink gelijkend op een uitnodiging, om naar vrienden/familie te sturen. Kan opgebouwd worden aan de hand van een verwijssjabloon in een profiel, of een bestaande uitnodiging.",
|
||||
"enableReferralsProfileDescription": "Geef gebruikers aangemaakt met dit profiel een persoonlijke verwijslink gelijkend op een uitnodiging, om naar vrienden/familie te sturen. Maak een uitnodiging aan met de gewenste instellingen, en selecteer die hier. Elke verwijzing wordt gebaseerd op die uitnodiging. Je kunt de uitnodiging daarna verwijderen.",
|
||||
"settingsDependsOn": "{setting}: hangt af van {dependency}",
|
||||
"settingsMaybeUnderAdvanced": "Tip: je vindt misschien wat je zoekt door Geavanceerde instellingen in te schakelen.",
|
||||
"sortingBy": "Sorteren naar",
|
||||
"filters": "Filters",
|
||||
"clickToRemoveFilter": "Klik om dit filter te verwijderen.",
|
||||
"clearSearch": "Zoekopdracht verwijderen",
|
||||
"actions": "Acties",
|
||||
"searchOptions": "Zoekopties",
|
||||
"matchText": "Tekstovereenkomst",
|
||||
"jellyfinID": "Jellyfin ID",
|
||||
"userPagePage": "Gebruikerspagina: Pagina",
|
||||
"activity": "Activiteit",
|
||||
"deleted": "Verwijderd",
|
||||
"disabled": "Uitgeschakeld",
|
||||
"keepSearching": "Blijf zoeken",
|
||||
"keepSearchingDescription": "Alleen momenteel ingeladen activiteiten zijn doorzocht. Klik hieronder om alle activiteiten te doorzoeken.",
|
||||
"sortDirection": "Sorteerrichting",
|
||||
"referrer": "Verwijzer",
|
||||
"accountLinked": "{contactMethod} gekoppeld: {user}",
|
||||
"accountUnlinked": "{contactMethod} verwijderd: {user}",
|
||||
"accountResetPassword": "{user} heeft hun wachtwoord gereset",
|
||||
"accountChangedPassword": "{user} heeft hun wachtwoord gewijzigd",
|
||||
"accountDisabled": "Account uitgeschakeld: {user}",
|
||||
"accountDeleted": "Account verwijderd: {user}",
|
||||
"accountCreated": "Account aangemaakt: {user}",
|
||||
"accountReEnabled": "Account opnieuw ingeschakeld: {user}",
|
||||
"accountExpired": "Account verlopen: {user}",
|
||||
"userDeleted": "Gebruiker is verwijderd.",
|
||||
"userDisabled": "Gebruiker is uitgeschakeld",
|
||||
"inviteCreated": "Uitnodiging aangemaakt: {invite}",
|
||||
"inviteDeleted": "Uitnodiging verwijderd: {invite}",
|
||||
"inviteExpired": "Uitnodiging verlopen: {invite}",
|
||||
"fromInvite": "Via uitnodiging",
|
||||
"byAdmin": "Door beheerder",
|
||||
"byUser": "Door gebruiker",
|
||||
"byJfaGo": "Door jfa-go",
|
||||
"activityID": "Activiteit ID",
|
||||
"title": "Titel",
|
||||
"usersMentioned": "Genoemde gebruiker",
|
||||
"actor": "Uitvoerder",
|
||||
"actorDescription": "Wat deze actie veroorzaakt heeft. \"gebruiker\"/\"beheerder\"/\"daemon\" of een gebruikersnaam.",
|
||||
"accountCreationFilter": "Aanmaken van account",
|
||||
"accountDeletionFilter": "Verwijderen van account",
|
||||
"accountDisabledFilter": "Account uitgeschakeld",
|
||||
"accountEnabledFilter": "Account ingeschakeld",
|
||||
"contactLinkedFilter": "Contact gekoppeld",
|
||||
"contactUnlinkedFilter": "Contact ontkoppeld",
|
||||
"passwordChangeFilter": "Wachtwoord gewijzigd",
|
||||
"passwordResetFilter": "Wachtwoord gereset",
|
||||
"inviteCreatedFilter": "Uitnodiging aangemaakt",
|
||||
"inviteDeletedFilter": "Uitnodiging verwijderd/verlopen",
|
||||
"loadMore": "Laad meer",
|
||||
"loadAll": "Laad alles",
|
||||
"noMoreResults": "Niet meer resultaten.",
|
||||
"totalRecords": "{n} documenten totaal",
|
||||
"loadedRecords": "{n} geladen",
|
||||
"shownRecords": "{n} getoond",
|
||||
"useInviteExpiry": "Neem verloop over van profiel/uitnodiging",
|
||||
"backups": "Backups",
|
||||
"removeExpiry": "Verwijder verloop",
|
||||
"enterExpiry": "Voer verloop in",
|
||||
"accountWillExpire": "Account verloopt op {date}.",
|
||||
"expirationBasedOn": "Datum gebaseerd op 1e gebruiker.",
|
||||
"backupsFormatNote": "Alleen backupbestanden met het standaard naamformaat worden hier getoond. Upload handmatig om een ander bestand te gebruiken.",
|
||||
"backupDownloadRestore": "Downloaden / Terugzetten",
|
||||
"backupUpload": "Upload backup & zet terug",
|
||||
"backupDownload": "Download backup",
|
||||
"backupRestore": "Backup terugzetten",
|
||||
"backupNow": "Nu backup maken",
|
||||
"backupCreated": "Backup gemaakt",
|
||||
"backupCanBeFound": "De backup kan op de server gevonden worden onder {filepath}.",
|
||||
"backupCanDownload": "Of klik hieronder om de backup te downloaden.",
|
||||
"wikiPage": "Wiki pagina",
|
||||
"useInviteExpiryNote": "Standaard verlopen uitnodigingen na 90 dagen, maar kunnen ze vernieuwd worden door de gebruiker. Schakel in om de verwijzing uit te schakelen na de ingestelde tijd.",
|
||||
"backupsDescription": "Hier kunnen backups van de database gemaakt, teruggezet, of gedownload worden.",
|
||||
"backupsCopy": "Bij het toepassen van een backup wordt er een kopie van de originele \"db\" folder naast gemaakt, voor het geval er iets misgaat."
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "E-mailadres van {n} gewijzigd.",
|
||||
@@ -122,20 +203,15 @@
|
||||
"createProfile": "Profiel {n} aangemaakt.",
|
||||
"saveSettings": "De instellingen zijn opgeslagen",
|
||||
"setOmbiDefaults": "De ombi standaardinstellingen zijn opgeslagen.",
|
||||
"errorConnection": "Kon geen verbinding maken met jfa-go.",
|
||||
"error401Unauthorized": "Geen toegang. Probeer de pagina te vernieuwen.",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "De instellingen zijn toegepast, maar wijzigen van de startpaginaindeling is misschien mislukt.",
|
||||
"errorHomescreenAppliedNoSettings": "Startpaginaindeling toegepast, maar opslaan van instellingen is misschien mislukt.",
|
||||
"errorSettingsFailed": "Opslaan mislukt.",
|
||||
"errorLoginBlank": "De gebruikersnaam en/of wachtwoord is leeg.",
|
||||
"errorUnknown": "Onbekende fout.",
|
||||
"errorBlankFields": "Velden leeggelaten",
|
||||
"errorDeleteProfile": "Verwijderen van profiel {n} mislukt",
|
||||
"errorLoadProfiles": "Fout bij het laden van profielen.",
|
||||
"errorCreateProfile": "Aanmaken van profile {n} mislukt",
|
||||
"errorSetDefaultProfile": "Fout bij instellen van standaardprofiel.",
|
||||
"errorLoadUsers": "Laden van gebruikers mislukt.",
|
||||
"errorSaveSettings": "Opslaan van instellingen mislukt.",
|
||||
"errorLoadSettings": "Laden van instellingen mislukt.",
|
||||
"errorSetOmbiDefaults": "Opslaan van ombi standaardinstellingen mislukt.",
|
||||
"errorLoadOmbiUsers": "Laden van ombi gebruikers mislukt.",
|
||||
@@ -157,7 +233,15 @@
|
||||
"accountConnected": "Account gekoppeld.",
|
||||
"savedAnnouncement": "Aankondiging opgeslagen.",
|
||||
"setOmbiProfile": "Opgeslagen ombi-profiel.",
|
||||
"errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt."
|
||||
"errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt.",
|
||||
"errorNoReferralTemplate": "Profiel bevat geen verwijzingssjabloon, voeg er een toe bij instellingen.",
|
||||
"referralsEnabled": "Verwijzingen actief.",
|
||||
"activityDeleted": "Activiteit verwijderd.",
|
||||
"errorInviteNoLongerExists": "Uitnodiging bestaat niet meer.",
|
||||
"errorInviteNotFound": "Uitnodiging niet gevonden.",
|
||||
"errorLoadActivities": "Laden van activiteiten mislukt.",
|
||||
"pathCopied": "Volledig pad gekopieerd naar klembord.",
|
||||
"errorInvalidDate": "Ongeldige datum."
|
||||
},
|
||||
"quantityStrings": {
|
||||
"modifySettingsFor": {
|
||||
@@ -215,6 +299,10 @@
|
||||
"setExpiry": {
|
||||
"singular": "Stel verloop in voor {n} gebruiker",
|
||||
"plural": "Stel verloop in voor {n} gebruikers"
|
||||
},
|
||||
"enableReferralsFor": {
|
||||
"plural": "Verwijzingen activeren voor {1} gebruikers",
|
||||
"singular": "Verwijzingen activeren voor {1} gebruiker"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,21 +15,12 @@
|
||||
"warning": "Ostrzeżenie",
|
||||
"inviteInfiniteUsesWarning": "",
|
||||
"inviteSendToEmail": "",
|
||||
"login": "",
|
||||
"logout": "",
|
||||
"create": "",
|
||||
"apply": "",
|
||||
"delete": "",
|
||||
"add": "",
|
||||
"select": "",
|
||||
"name": "Imię",
|
||||
"date": "Data",
|
||||
"enabled": "Włączone",
|
||||
"disabled": "Wyłączone",
|
||||
"reEnable": "",
|
||||
"setExpiry": "",
|
||||
"disable": "Wyłączone",
|
||||
"admin": "Admin",
|
||||
"updates": "Aktualizacje",
|
||||
"update": "Aktualizacja",
|
||||
"download": "Pobierz",
|
||||
@@ -38,7 +29,6 @@
|
||||
"lastActiveTime": "Ostatnia aktywność",
|
||||
"from": "Od",
|
||||
"user": "Użytkownik",
|
||||
"expiry": "Wygasa",
|
||||
"userExpiry": "Użytkownik wygasa",
|
||||
"userExpiryDescription": "",
|
||||
"aboutProgram": "O",
|
||||
@@ -57,7 +47,6 @@
|
||||
"conditionals": "",
|
||||
"preview": "",
|
||||
"reset": "Zresetuj",
|
||||
"edit": "Edytuj",
|
||||
"donate": "",
|
||||
"sendPWR": "",
|
||||
"contactThrough": "",
|
||||
@@ -98,7 +87,6 @@
|
||||
"inviteUsersCreated": "",
|
||||
"inviteNoProfile": "",
|
||||
"inviteDateCreated": "Utworzone",
|
||||
"inviteRemainingUses": "",
|
||||
"inviteNoInvites": "",
|
||||
"inviteExpiresInTime": "",
|
||||
"notifyEvent": "",
|
||||
@@ -113,7 +101,8 @@
|
||||
"deleteTemplate": "Usuń szablon",
|
||||
"templateEnterName": "Wprowadź nazwę aby zapisać szablon.",
|
||||
"accessJFA": "",
|
||||
"accessJFASettings": ""
|
||||
"accessJFASettings": "",
|
||||
"invite": "Zaproś"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "Zmieniono adres email {n}.",
|
||||
@@ -128,13 +117,9 @@
|
||||
"updateAppliedRefresh": "Aktualizacja zastosowana, odśwież.",
|
||||
"telegramVerified": "Konto telegramu zweryfikowane.",
|
||||
"accountConnected": "Konto połączone.",
|
||||
"errorConnection": "Nie udało się połączyć z jfa-go.",
|
||||
"error401Unauthorized": "Nieautoryzowany. Spróbuj odświeżyć stronę.",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "Zastosowano ustawienia, ale zastosowanie układu ekranu głównego mogło się nie powieść.",
|
||||
"errorHomescreenAppliedNoSettings": "",
|
||||
"errorSettingsFailed": "",
|
||||
"errorLoginBlank": "",
|
||||
"errorUnknown": "Nieznany błąd.",
|
||||
"errorSaveEmail": "",
|
||||
"errorBlankFields": "",
|
||||
"errorDeleteProfile": "",
|
||||
@@ -142,7 +127,6 @@
|
||||
"errorCreateProfile": "",
|
||||
"errorSetDefaultProfile": "",
|
||||
"errorLoadUsers": "",
|
||||
"errorSaveSettings": "",
|
||||
"errorLoadSettings": "",
|
||||
"errorSetOmbiProfile": "",
|
||||
"errorLoadOmbiUsers": "",
|
||||
@@ -13,11 +13,8 @@
|
||||
"warning": "Aviso",
|
||||
"inviteInfiniteUsesWarning": "convites infinitos podem ser usados de forma abusiva",
|
||||
"inviteSendToEmail": "Enviar para",
|
||||
"login": "Login",
|
||||
"logout": "Sair",
|
||||
"create": "Criar",
|
||||
"apply": "Aplicar",
|
||||
"delete": "Deletar",
|
||||
"name": "Nome",
|
||||
"date": "Data",
|
||||
"lastActiveTime": "Ativo pela última vez",
|
||||
@@ -57,7 +54,6 @@
|
||||
"inviteUsersCreated": "Usuários criado",
|
||||
"inviteNoProfile": "Sem Perfil",
|
||||
"inviteDateCreated": "Criado",
|
||||
"inviteRemainingUses": "Uso restantes",
|
||||
"inviteNoInvites": "Nenhum",
|
||||
"inviteExpiresInTime": "Expira em {n}",
|
||||
"notifyEvent": "Notificar em:",
|
||||
@@ -73,14 +69,9 @@
|
||||
"variables": "Variáveis",
|
||||
"preview": "Pre-visualizar",
|
||||
"reset": "Redefinir",
|
||||
"edit": "Editar",
|
||||
"customizeMessages": "Customizar Emails",
|
||||
"disabled": "Desativado",
|
||||
"userExpiryDescription": "Após um determinado período de tempo de cada inscrição, o jfa-go apagará/desabilitará a conta. Você pode alterar essa opção nas configurações.",
|
||||
"inviteDuration": "Duração do Convite",
|
||||
"enabled": "Habilitado",
|
||||
"admin": "Admin",
|
||||
"expiry": "Expira",
|
||||
"userExpiry": "Vencimento do Usuário",
|
||||
"extendExpiry": "Extender o vencimento",
|
||||
"updates": "Atualizações",
|
||||
@@ -89,15 +80,12 @@
|
||||
"search": "Procurar",
|
||||
"advancedSettings": "Configurações Avançada",
|
||||
"inviteMonths": "Meses",
|
||||
"reEnable": "Reativar",
|
||||
"disable": "Desativar",
|
||||
"conditionals": "Condicionais",
|
||||
"donate": "Doar",
|
||||
"contactThrough": "Contato através:",
|
||||
"sendPIN": "Peça que o usuário envie o PIN abaixo para o bot.",
|
||||
"sendPIN": "Peça ao usuário para enviar o PIN abaixo para o bot.",
|
||||
"searchDiscordUser": "Digite o nome de usuário do Discord.",
|
||||
"findDiscordUser": "Encontrar usuário Discord",
|
||||
"add": "Adicionar",
|
||||
"linkMatrixDescription": "Digite o nome de usuário e a senha para usar como bot. Depois de enviado, o aplicativo será reiniciado.",
|
||||
"select": "Selecionar",
|
||||
"templates": "Modelos",
|
||||
@@ -114,7 +102,100 @@
|
||||
"sendPWRSuccess": "Link de redefinição de senha enviado.",
|
||||
"sendPWRSuccessManual": "Se o usuário não o recebeu, pressione copiar para obter um link para enviar manualmente a ele.",
|
||||
"sendPWRValidFor": "O link é válido por 30m.",
|
||||
"accessJFASettings": "Não pode ser alterado porque \"Só Administrador\" ou \"Permitir todos\" foi definido em Configurações> Geral."
|
||||
"accessJFASettings": "Não pode ser alterado porque \"Só Administrador\" ou \"Permitir todos\" foi definido em Configurações> Geral.",
|
||||
"after": "Depois",
|
||||
"removeExpiry": "Remover expiração",
|
||||
"enableReferrals": "Habilitar referências",
|
||||
"disableReferrals": "Desativar referências",
|
||||
"invite": "Convite",
|
||||
"before": "Antes",
|
||||
"unlink": "Desvincular conta",
|
||||
"enterExpiry": "Insira um vencimento",
|
||||
"useInviteExpiry": "Definir expiração do perfil/convite",
|
||||
"useInviteExpiryNote": "Por padrão, os convites expiram após 90 dias, mas podem ser renovados pelo usuário. Habilite para que o encaminhamento seja desabilitado após o tempo definido.",
|
||||
"noResultsFound": "Nenhum resultado encontrado",
|
||||
"activity": "Atividade",
|
||||
"userLabel": "Rótulo de usuário",
|
||||
"userLabelDescription": "Rótulo a ser aplicado aos usuários criados com este convite.",
|
||||
"deleted": "Excluído",
|
||||
"disabled": "Desabilitado",
|
||||
"keepSearching": "Continue procurando",
|
||||
"keepSearchingDescription": "Apenas as atividades atualmente carregadas foram pesquisadas. Clique abaixo se desejar pesquisar todas as atividades.",
|
||||
"enableReferralsDescription": "Forneça aos usuários um link de indicação pessoal semelhante a um convite, para enviar a amigos/familiares. Pode ser proveniente de um modelo de indicação em um perfil ou de um convite existente.",
|
||||
"enableReferralsProfileDescription": "Forneça aos usuários criados com este perfil um link de indicação pessoal semelhante a um convite, para enviar a amigos/familiares. Crie um convite com as configurações desejadas e selecione-o aqui. Cada indicação será então baseada neste convite. Você pode excluir o convite depois de concluído.",
|
||||
"fromInvite": "Do convite",
|
||||
"inviteDeletedFilter": "Convite excluído/expirado",
|
||||
"accountDisabled": "Conta desativada: {user}",
|
||||
"backupsDescription": "Cópias de segurança do banco de dados podem ser feitos, restaurados ou baixados aqui.",
|
||||
"backupsFormatNote": "Somente arquivos de Cópias de segurança com formato de nome padrão serão mostrados aqui. Para usar qualquer outro, carregue o backup manualmente.",
|
||||
"backupCanDownload": "Como alternativa, clique abaixo para baixar Cópia de segurança.",
|
||||
"inviteCreated": "Convite criado: {invite}",
|
||||
"buildTime": "Hora de construir",
|
||||
"inviteDeleted": "Convite excluído: {invite}",
|
||||
"inviteExpired": "O convite expirou: {invite}",
|
||||
"byAdmin": "Por administrador",
|
||||
"byUser": "Por usuário",
|
||||
"byJfaGo": "Por jfa-go",
|
||||
"actor": "Ator",
|
||||
"actorDescription": "O que causou essa ação. \"user\"/\"admin\"/\"daemon\" ou um nome de usuário.",
|
||||
"accountCreationFilter": "Criação de conta",
|
||||
"accountDeletionFilter": "Exclusão de conta",
|
||||
"accountDisabledFilter": "conta desativada",
|
||||
"accountEnabledFilter": "Conta ativada",
|
||||
"contactLinkedFilter": "Contato Linkedin",
|
||||
"contactUnlinkedFilter": "Contato não vinculado",
|
||||
"passwordResetFilter": "Redefinição de senha",
|
||||
"inviteCreatedFilter": "Convite criado",
|
||||
"loginNotAdmin": "Você não é administrador?",
|
||||
"loadedRecords": "{n} Carregado",
|
||||
"shownRecords": "{n} Exibido",
|
||||
"searchOptions": "Opções de busca",
|
||||
"matchText": "Corresponder Texto",
|
||||
"jellyfinID": "ID do Jellyfin",
|
||||
"sortingBy": "Classificando por",
|
||||
"sortDirection": "Classificar direção",
|
||||
"settingsHiddenDependency": "As configurações correspondentes ficam ocultas porque dependem do valor de outra configuração:",
|
||||
"settingsDependsOn": "{setting}: depende de {dependency}",
|
||||
"settingsAdvancedMode": "{setting}: as configurações avançadas devem estar habilitadas",
|
||||
"settingsMaybeUnderAdvanced": "Dica: você pode encontrar o que procura ativando Configurações avançadas.",
|
||||
"accountResetPassword": "{user} redefiniu a senha",
|
||||
"accountCreated": "Conta criada: {user}",
|
||||
"accountDeleted": "Conta excluída: {user}",
|
||||
"accountExpired": "A conta expirou: {user}",
|
||||
"accountWillExpire": "A conta expirará em {data}.",
|
||||
"expirationBasedOn": "Data fornecida com base no primeiro usuário.",
|
||||
"userDeleted": "O usuário foi excluído.",
|
||||
"userDisabled": "O usuário foi desativado",
|
||||
"activityID": "ID da atividade",
|
||||
"accountChangedPassword": "{user} alterou a senha",
|
||||
"title": "Título",
|
||||
"accountReEnabled": "Conta reativada: {user}",
|
||||
"referrer": "Indicador",
|
||||
"usersMentioned": "Usuário mencionado",
|
||||
"passwordChangeFilter": "Senha alterada",
|
||||
"loadAll": "Carregar tudo",
|
||||
"loadMore": "Carregar mais",
|
||||
"noMoreResults": "Não há mais resultados.",
|
||||
"totalRecords": "{n} Total de registros",
|
||||
"filters": "Filtros",
|
||||
"clickToRemoveFilter": "Clique para remover este filtro.",
|
||||
"clearSearch": "Limpar pesquisa",
|
||||
"actions": "Ações",
|
||||
"userPageLogin": "Página do usuário: Entrar",
|
||||
"userPagePage": "Página do usuário: página",
|
||||
"builtBy": "Criado por",
|
||||
"backups": "Cópias de segurança",
|
||||
"backupDownloadRestore": "Baixar / Restaurar",
|
||||
"backupsCopy": "Ao aplicar uma Cópias de segurança, será feita uma cópia da pasta \"db\" original ao lado dele, caso algo dê errado.",
|
||||
"backupUpload": "Carregar e restaurar Cópias de segurança",
|
||||
"backupDownload": "Baixar cópia de segurança",
|
||||
"backupRestore": "Restaurar cópia de segurança",
|
||||
"backupNow": "Faça cópia de segurança agora",
|
||||
"backupCreated": "Cópia de segurança criada",
|
||||
"backupCanBeFound": "A Cópia de segurança pode ser encontrado no servidor em {filepath}.",
|
||||
"wikiPage": "Página Wiki",
|
||||
"accountLinked": "{contactMethod} vinculado: {user}",
|
||||
"accountUnlinked": "{contactMethod} removido: {user}"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "Endereço de e-mail alterado de {n}.",
|
||||
@@ -122,20 +203,15 @@
|
||||
"createProfile": "Perfil {n} criado.",
|
||||
"saveSettings": "As configurações foram salvas",
|
||||
"setOmbiDefaults": "Padrões do ombi armazenados.",
|
||||
"errorConnection": "Não foi possível conectar ao jfa-go.",
|
||||
"error401Unauthorized": "Não autorizado. Tente atualizar a página.",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "As configurações foram aplicadas, mas a aplicação do layout da tela inicial pode ter falhado.",
|
||||
"errorHomescreenAppliedNoSettings": "O layout da tela inicial foi aplicado, mas a aplicação das configurações pode ter falhado.",
|
||||
"errorSettingsFailed": "Falha na aplicação.",
|
||||
"errorLoginBlank": "O nome de usuário e/ou senha foram deixados em branco.",
|
||||
"errorUnknown": "Erro desconhecido.",
|
||||
"errorBlankFields": "Os campos foram deixados em branco",
|
||||
"errorDeleteProfile": "Falha ao excluir perfil {n}",
|
||||
"errorLoadProfiles": "Falha ao carregar perfis.",
|
||||
"errorCreateProfile": "Falha ao criar perfil {n}",
|
||||
"errorSetDefaultProfile": "Falha ao definir o perfil padrão.",
|
||||
"errorLoadUsers": "Falha ao carregar usuários.",
|
||||
"errorSaveSettings": "Não foi possível salvar as configurações.",
|
||||
"errorLoadSettings": "Falha ao carregar as configurações.",
|
||||
"errorSetOmbiDefaults": "Falha em armazenar os padrões ombi.",
|
||||
"errorLoadOmbiUsers": "Falha ao carregar usuários ombi.",
|
||||
@@ -157,7 +233,15 @@
|
||||
"accountConnected": "Conta conectada.",
|
||||
"savedAnnouncement": "Anúncio salvo.",
|
||||
"setOmbiProfile": "Perfil ombi armazenado.",
|
||||
"errorSetOmbiProfile": "Falha ao armazenar o perfil ombi."
|
||||
"errorSetOmbiProfile": "Falha ao armazenar o perfil ombi.",
|
||||
"errorNoReferralTemplate": "O perfil não contém modelo de referência. Adicione um nas configurações.",
|
||||
"pathCopied": "Caminho completo copiado para a área de transferência.",
|
||||
"referralsEnabled": "Referências habilitadas.",
|
||||
"activityDeleted": "Atividade excluída.",
|
||||
"errorInviteNoLongerExists": "O convite não existe mais.",
|
||||
"errorInviteNotFound": "Convite não encontrado.",
|
||||
"errorLoadActivities": "Falha ao carregar atividades.",
|
||||
"errorInvalidDate": "A data é inválida."
|
||||
},
|
||||
"quantityStrings": {
|
||||
"modifySettingsFor": {
|
||||
@@ -215,6 +299,10 @@
|
||||
"setExpiry": {
|
||||
"singular": "Definir expiração para {a} usuário",
|
||||
"plural": "Definir expiração para {a} usuários"
|
||||
},
|
||||
"enableReferralsFor": {
|
||||
"plural": "Ativar referências para {n} usuários",
|
||||
"singular": "Ativar referências para {n} usuário"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,8 @@
|
||||
"warning": "Varning",
|
||||
"inviteInfiniteUsesWarning": "inbjudningar med oändligt antal användningar kan missbrukas",
|
||||
"inviteSendToEmail": "Skicka till",
|
||||
"login": "Logga in",
|
||||
"logout": "Logga ut",
|
||||
"create": "Skapa",
|
||||
"apply": "Tillämpa",
|
||||
"delete": "Radera",
|
||||
"name": "Namn",
|
||||
"date": "Datum",
|
||||
"lastActiveTime": "Senast aktiv",
|
||||
@@ -36,7 +33,6 @@
|
||||
"variables": "Variabler",
|
||||
"preview": "Förhandsvisning",
|
||||
"reset": "Återställ",
|
||||
"edit": "Redigera",
|
||||
"customizeMessages": "Anpassa e-post",
|
||||
"customizeMessagesDescription": "Om du inte vill använda jfa-go's e-postmallar, så kan du skapa dina egna med Markdown.",
|
||||
"markdownSupported": "Markdown stöds.",
|
||||
@@ -69,17 +65,12 @@
|
||||
"inviteUsersCreated": "Skapade användare",
|
||||
"inviteNoProfile": "Ingen profil",
|
||||
"inviteDateCreated": "Skapad",
|
||||
"inviteRemainingUses": "Återstående användningar",
|
||||
"inviteNoInvites": "Ingen",
|
||||
"inviteExpiresInTime": "Går ut om {n}",
|
||||
"notifyEvent": "Meddela den:",
|
||||
"notifyInviteExpiry": "Vid utgång",
|
||||
"notifyUserCreation": "Vid användarskapande",
|
||||
"disabled": "Inaktiverad",
|
||||
"enabled": "Aktiverad",
|
||||
"inviteDuration": "Varaktighet för inbjudan",
|
||||
"admin": "Admin",
|
||||
"expiry": "Löper ut",
|
||||
"userExpiry": "Användarutgång",
|
||||
"userExpiryDescription": "Efter en angiven tid efter varje registrering så tar jfa-go bort/inaktiverar kontot. Du kan ändra detta beteende i inställningarna.",
|
||||
"extendExpiry": "Förläng utgång"
|
||||
@@ -92,13 +83,9 @@
|
||||
"saveEmail": "E-post sparad.",
|
||||
"sentAnnouncement": "Meddelande skickat.",
|
||||
"setOmbiDefaults": "Lagrade ombi-standardvärden.",
|
||||
"errorConnection": "Det gick inte att ansluta till jfa-go.",
|
||||
"error401Unauthorized": "Obehörig. Prova att uppdatera sidan.",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "Inställningarna tillämpades, men tillämpningen av hemskärmslayout kan ha misslyckats.",
|
||||
"errorHomescreenAppliedNoSettings": "Hemskärmslayout tillämpades, men tillämpningen av inställningar kan ha misslyckats.",
|
||||
"errorSettingsFailed": "Tillämpning misslyckades.",
|
||||
"errorLoginBlank": "Användarnamnet och/eller lösenordet lämnades tomt.",
|
||||
"errorUnknown": "Okänt fel.",
|
||||
"errorSaveEmail": "Det gick inte att spara e-postmeddelandet.",
|
||||
"errorBlankFields": "Fält lämnades tomma",
|
||||
"errorDeleteProfile": "Det gick inte att ta bort profilen {n}",
|
||||
@@ -106,7 +93,6 @@
|
||||
"errorCreateProfile": "Det gick inte att skapa profilen {n}",
|
||||
"errorSetDefaultProfile": "Det gick inte att ange standardprofil.",
|
||||
"errorLoadUsers": "Det gick inte att läsa in användare.",
|
||||
"errorSaveSettings": "Det gick inte att spara inställningarna.",
|
||||
"errorLoadSettings": "Det gick inte att läsa in inställningarna.",
|
||||
"errorSetOmbiDefaults": "Det gick inte att lagra ombi-standardvärden.",
|
||||
"errorLoadOmbiUsers": "Det gick inte att ladda ombi-användare.",
|
||||
@@ -154,4 +140,4 @@
|
||||
"plural": "Utökad giltighetstid för {n} användare."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,21 +15,12 @@
|
||||
"warning": "Cảnh báo",
|
||||
"inviteInfiniteUsesWarning": "các lời mời không giới hạn số lần sử dụng có thể bị lạm dụng",
|
||||
"inviteSendToEmail": "Gửi tới",
|
||||
"login": "Đăng nhập",
|
||||
"logout": "Đăng xuất",
|
||||
"create": "Tạo mới",
|
||||
"apply": "Áp dụng",
|
||||
"delete": "Xóa",
|
||||
"add": "Thêm",
|
||||
"select": "Chọn",
|
||||
"name": "Tên",
|
||||
"date": "Ngày",
|
||||
"enabled": "Mở",
|
||||
"disabled": "Tắt",
|
||||
"reEnable": "Mở lại",
|
||||
"setExpiry": "Đặt hết hạn",
|
||||
"disable": "Tắt",
|
||||
"admin": "Admin",
|
||||
"updates": "Cập nhật",
|
||||
"update": "Cập nhật",
|
||||
"download": "Tải về",
|
||||
@@ -38,7 +29,6 @@
|
||||
"lastActiveTime": "Lần cuối Hoạt động",
|
||||
"from": "Từ",
|
||||
"user": "Người dùng",
|
||||
"expiry": "Hết hạn",
|
||||
"userExpiry": "Hết hạn Người dùng",
|
||||
"userExpiryDescription": "Sau một khoảng thời gian nhất định sau khi mỗi đăng ký, jfa-go sẽ xóa/vô hiệu hóa tài khoản. Bạn có thể chỉnh sửa chế độ này trong cài đặt.",
|
||||
"aboutProgram": "Thông tin",
|
||||
@@ -56,7 +46,6 @@
|
||||
"conditionals": "Điều kiện",
|
||||
"preview": "Xem trước",
|
||||
"reset": "Đặt lại",
|
||||
"edit": "Chỉnh sửa",
|
||||
"donate": "Đóng góp",
|
||||
"sendPWR": "Gửi Đặt lại Mật khẩu",
|
||||
"contactThrough": "Liên lạc qua:",
|
||||
@@ -97,7 +86,6 @@
|
||||
"inviteUsersCreated": "Người dùng đã tạo",
|
||||
"inviteNoProfile": "Không có Tài khoản mẫu",
|
||||
"inviteDateCreated": "Tạo",
|
||||
"inviteRemainingUses": "Số lần sử dụng còn lại",
|
||||
"inviteNoInvites": "Không có",
|
||||
"inviteExpiresInTime": "Hết hạn trong {n}",
|
||||
"notifyEvent": "Thông báo khi:",
|
||||
@@ -127,13 +115,9 @@
|
||||
"updateAppliedRefresh": "Cập nhật mới đã được áp dụng, vui lòng làm mới lại trang.",
|
||||
"telegramVerified": "Tài khoản Telegram đã được xác thực.",
|
||||
"accountConnected": "Tài khoản đã được kết nối.",
|
||||
"errorConnection": "Không thể kết nối với jfa-go.",
|
||||
"error401Unauthorized": "Không được phép. Hãy thử làm mới trang.",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "Cài đặt đã được áp dụng, nhưng việc áp dụng bố cục màn hình chính có thể không thành công.",
|
||||
"errorHomescreenAppliedNoSettings": "",
|
||||
"errorSettingsFailed": "",
|
||||
"errorLoginBlank": "",
|
||||
"errorUnknown": "",
|
||||
"errorSaveEmail": "",
|
||||
"errorBlankFields": "",
|
||||
"errorDeleteProfile": "",
|
||||
@@ -141,7 +125,6 @@
|
||||
"errorCreateProfile": "",
|
||||
"errorSetDefaultProfile": "",
|
||||
"errorLoadUsers": "",
|
||||
"errorSaveSettings": "",
|
||||
"errorLoadSettings": "",
|
||||
"errorSetOmbiProfile": "",
|
||||
"errorLoadOmbiUsers": "",
|
||||
@@ -209,4 +192,4 @@
|
||||
"plural": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,20 +15,11 @@
|
||||
"warning": "警告",
|
||||
"inviteInfiniteUsesWarning": "无限使用次数的邀请码可能被滥用",
|
||||
"inviteSendToEmail": "发送到",
|
||||
"login": "登录",
|
||||
"logout": "登出",
|
||||
"create": "创建",
|
||||
"apply": "申请",
|
||||
"delete": "删除",
|
||||
"add": "添加",
|
||||
"select": "选择",
|
||||
"name": "名称",
|
||||
"date": "日期",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"reEnable": "重新启用",
|
||||
"disable": "禁用",
|
||||
"admin": "管理员",
|
||||
"updates": "更新",
|
||||
"update": "更新",
|
||||
"download": "下载",
|
||||
@@ -37,7 +28,6 @@
|
||||
"lastActiveTime": "上次活动",
|
||||
"from": "从",
|
||||
"user": "用户",
|
||||
"expiry": "到期",
|
||||
"userExpiry": "用户到期",
|
||||
"userExpiryDescription": "每次注册后的指定时间,jfa-go 将删除/禁用该帐户。您可以在设置中更改此行为。",
|
||||
"aboutProgram": "关于",
|
||||
@@ -55,7 +45,6 @@
|
||||
"conditionals": "条件性条款",
|
||||
"preview": "预览",
|
||||
"reset": "重设",
|
||||
"edit": "编辑",
|
||||
"donate": "捐助",
|
||||
"contactThrough": "联系方式:",
|
||||
"extendExpiry": "延长有效期",
|
||||
@@ -80,7 +69,7 @@
|
||||
"ombiUserDefaults": "Ombi 用户默认值",
|
||||
"ombiUserDefaultsDescription": "创建并配置 Ombi 用户,然后在下面选择它。它的设置/权限将被存储并应用于由 jfa-go 创建的新 Ombi 用户。",
|
||||
"userProfiles": "用户档案",
|
||||
"userProfilesDescription": "配置文件在用户创建帐户时应用于用户。配置文件包括库访问权限和主屏幕布局。",
|
||||
"userProfilesDescription": "个人资料在用户创建帐户时应用于他们。个人资料包括库访问权限和主屏幕布局。",
|
||||
"userProfilesIsDefault": "默认",
|
||||
"userProfilesLibraries": "库",
|
||||
"addProfile": "添加档案",
|
||||
@@ -91,7 +80,6 @@
|
||||
"inviteUsersCreated": "已创建的用户",
|
||||
"inviteNoProfile": "没有个人资料",
|
||||
"inviteDateCreated": "已创建",
|
||||
"inviteRemainingUses": "剩余使用次数",
|
||||
"inviteNoInvites": "无",
|
||||
"inviteExpiresInTime": "在 {n} 到期",
|
||||
"notifyEvent": "通知:",
|
||||
@@ -114,7 +102,100 @@
|
||||
"sendPWRValidFor": "此链接有效30分钟。",
|
||||
"ombiProfile": "Ombi 用户配置文件",
|
||||
"accessJFASettings": "无法更改,因为“仅限管理员”或“允许所有”已在“设置”>“常规”中设置。",
|
||||
"accessJFA": "访问jfa-go"
|
||||
"accessJFA": "访问 jfa-go",
|
||||
"buildTime": "构建时间",
|
||||
"builtBy": "由",
|
||||
"clickToRemoveFilter": "单击此处以取消此筛选器。",
|
||||
"filters": "筛选器",
|
||||
"jellyfinID": "Jellyfin ID",
|
||||
"clearSearch": "清除搜索",
|
||||
"searchOptions": "搜索选项",
|
||||
"matchText": "匹配文本",
|
||||
"userPagePage": "用户页面",
|
||||
"actions": "操作",
|
||||
"after": "之后",
|
||||
"before": "之前",
|
||||
"unlink": "取消关联帐户",
|
||||
"sortingBy": "排序方式",
|
||||
"userPageLogin": "用户页面:登录",
|
||||
"activity": "活动",
|
||||
"userLabelDescription": "标签应用于使用此邀请创建的用户。",
|
||||
"disabled": "禁用",
|
||||
"keepSearchingDescription": "只有当前加载的活动被搜索了。如果您想搜索所有活动,请点击下方。",
|
||||
"enableReferralsDescription": "为用户提供一个个人的推荐链接,类似于邀请,以便他们发送给朋友和家人。可以从个人资料中的推荐模板获取,或从现有的邀请中获取。",
|
||||
"userDeleted": "用户已被删除。",
|
||||
"inviteCreated": "邀请已创建:{invite}",
|
||||
"usersMentioned": "用户提到的",
|
||||
"actorDescription": "引起这个操作的事物。可以是“用户”、“管理员”、“守护程序”或用户名。",
|
||||
"loginNotAdmin": "不是管理员?",
|
||||
"invite": "邀请",
|
||||
"noResultsFound": "没有发现任何结果",
|
||||
"settingsHiddenDependency": "匹配设置被隐藏,因为它们取决于另一个设置的值:",
|
||||
"settingsDependsOn": "{setting}:依赖于 {dependency}",
|
||||
"settingsAdvancedMode": "{setting}:必须启用高级设置",
|
||||
"settingsMaybeUnderAdvanced": "提示:通过启用高级设置,您可能会找到您正在寻找的内容。",
|
||||
"userLabel": "用户标签",
|
||||
"deleted": "删除",
|
||||
"keepSearching": "继续搜索",
|
||||
"enableReferrals": "启用推荐",
|
||||
"disableReferrals": "禁用推荐",
|
||||
"enableReferralsProfileDescription": "为使用该个人资料创建的用户提供一个类似邀请的个人推荐链接,以便他们发送给朋友和家人。创建一个具有所需设置的邀请,然后在此处进行选择。然后,每个推荐都将基于这个邀请。完成后,您可以删除邀请。",
|
||||
"sortDirection": "排序方向",
|
||||
"referrer": "推荐人",
|
||||
"accountLinked": "{contactMethod} 已关联:{user}",
|
||||
"accountUnlinked": "{contactMethod} 已移除:{user}",
|
||||
"accountResetPassword": "{user} 重置了他们的密码",
|
||||
"accountChangedPassword": "{user} 更改了他们的密码",
|
||||
"accountCreated": "账户已创建:{user}",
|
||||
"accountDeleted": "账户已删除:{user}",
|
||||
"accountDisabled": "账户已禁用:{user}",
|
||||
"accountReEnabled": "账户已重新启用:{user}",
|
||||
"accountExpired": "账户已过期:{user}",
|
||||
"userDisabled": "用户已被禁用",
|
||||
"inviteDeleted": "邀请已删除:{invite}",
|
||||
"inviteExpired": "邀请已过期:{invite}",
|
||||
"fromInvite": "来自邀请",
|
||||
"byAdmin": "由管理员发起的",
|
||||
"byUser": "由用户发起的",
|
||||
"byJfaGo": "由jfa-go发起的",
|
||||
"activityID": "活动ID",
|
||||
"title": "标题",
|
||||
"actor": "角色",
|
||||
"accountCreationFilter": "账户创建",
|
||||
"accountDeletionFilter": "账户删除",
|
||||
"accountDisabledFilter": "账户禁用",
|
||||
"accountEnabledFilter": "账户启用",
|
||||
"contactLinkedFilter": "联系方式已关联",
|
||||
"contactUnlinkedFilter": "联系方式未关联",
|
||||
"passwordChangeFilter": "密码已更改",
|
||||
"passwordResetFilter": "密码重置",
|
||||
"inviteCreatedFilter": "邀请已创建",
|
||||
"inviteDeletedFilter": "邀请已删除/过期",
|
||||
"loadMore": "加载更多",
|
||||
"loadAll": "加载全部",
|
||||
"noMoreResults": "没有更多结果了。",
|
||||
"totalRecords": "{n} 总记录数",
|
||||
"loadedRecords": "已加载{n}",
|
||||
"shownRecords": "已显示{n}",
|
||||
"removeExpiry": "用户过期删除时间",
|
||||
"useInviteExpiryNote": "在默认情况下,邀请会在90天之后过期,但是用户可以手动续期该邀请。启动该设置手动设置有效期后会关闭推荐设置。",
|
||||
"accountWillExpire": "账户将在{date}后过期。",
|
||||
"expirationBasedOn": "根据第一个用户给出的日期。",
|
||||
"backupsFormatNote": "此处仅显示具有标准名称格式的备份文件。如要使用其他名称的备份,请手动上传。",
|
||||
"backupCanDownload": "或者,单击下面的按钮下载备份。",
|
||||
"enterExpiry": "输入自定义过期时间",
|
||||
"useInviteExpiry": "设置个人资料/邀请的有效期",
|
||||
"backups": "备份设置",
|
||||
"backupsDescription": "可以从这里制作、恢复或下载数据库的备份。",
|
||||
"backupsCopy": "当在使用备份文件恢复时,程序将创建原始“db”文件夹的副本,以防出现问题。",
|
||||
"backupDownloadRestore": "下载/恢复",
|
||||
"backupUpload": "上传和恢复备份",
|
||||
"backupRestore": "恢复备份",
|
||||
"backupDownload": "下载备份",
|
||||
"backupNow": "立即备份",
|
||||
"backupCreated": "备份已创建",
|
||||
"backupCanBeFound": "该备份可以在服务器上的 {filepath} 处找到。",
|
||||
"wikiPage": "帮助文档"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "更改了 {n} 的电子邮件地址。",
|
||||
@@ -129,13 +210,9 @@
|
||||
"updateAppliedRefresh": "已应用更新,请刷新。",
|
||||
"telegramVerified": "Telegram账户已验证。",
|
||||
"accountConnected": "帐户已连接。",
|
||||
"errorConnection": "无法连接到 jfa-go。",
|
||||
"error401Unauthorized": "无授权。尝试刷新页面。",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "已应用设置,但应用主屏幕布局可能失败。",
|
||||
"errorHomescreenAppliedNoSettings": "已应用主屏幕布局,但应用设置可能失败。",
|
||||
"errorSettingsFailed": "应用失败。",
|
||||
"errorLoginBlank": "用户名/密码留空。",
|
||||
"errorUnknown": "未知错误。",
|
||||
"errorSaveEmail": "电子邮箱保存失败。",
|
||||
"errorBlankFields": "字段留空",
|
||||
"errorDeleteProfile": "删除配置文件{n}失败",
|
||||
@@ -143,7 +220,6 @@
|
||||
"errorCreateProfile": "创建配置文件{n}失败",
|
||||
"errorSetDefaultProfile": "设置默认配置文件失败。",
|
||||
"errorLoadUsers": "加载用户列表失败。",
|
||||
"errorSaveSettings": "无法保存设置。",
|
||||
"errorLoadSettings": "加载配置列表失败。",
|
||||
"errorSetOmbiDefaults": "存储Ombi默认值失败。",
|
||||
"errorLoadOmbiUsers": "加载ombi用户列表失败。",
|
||||
@@ -157,7 +233,15 @@
|
||||
"updateAvailable": "有新更新可用,请检查设置。",
|
||||
"noUpdatesAvailable": "没有可用的更新。",
|
||||
"setOmbiProfile": "保存ombi配置文件。",
|
||||
"errorSetOmbiProfile": "无法保存ombi配置文件。"
|
||||
"errorSetOmbiProfile": "无法保存ombi配置文件。",
|
||||
"activityDeleted": "活动已删除。",
|
||||
"errorNoReferralTemplate": "个人资料不包含推荐模板,请在设置中添加一个。",
|
||||
"referralsEnabled": "已启用推荐。",
|
||||
"errorInviteNoLongerExists": "邀请已不存在。",
|
||||
"errorInviteNotFound": "未找到邀请。",
|
||||
"errorLoadActivities": "无法加载活动。",
|
||||
"pathCopied": "完整路径已复制到剪贴板。",
|
||||
"errorInvalidDate": "日期无效。"
|
||||
},
|
||||
"quantityStrings": {
|
||||
"modifySettingsFor": {
|
||||
@@ -215,6 +299,10 @@
|
||||
"setExpiry": {
|
||||
"plural": "为{n}用户设置到期时间",
|
||||
"singular": "为{n}用户设置到期时间"
|
||||
},
|
||||
"enableReferralsFor": {
|
||||
"singular": "为{n}用户启用推荐功能",
|
||||
"plural": "为{n}个用户启用推荐功能"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,21 +15,12 @@
|
||||
"warning": "警告",
|
||||
"inviteInfiniteUsesWarning": "無限使用次數的邀請碼可能被濫用",
|
||||
"inviteSendToEmail": "發送到",
|
||||
"login": "登錄",
|
||||
"logout": "登出",
|
||||
"create": "創建",
|
||||
"apply": "應用",
|
||||
"delete": "刪除",
|
||||
"add": "添加",
|
||||
"select": "選擇",
|
||||
"name": "帳戶名稱",
|
||||
"date": "日期",
|
||||
"enabled": "已啟用",
|
||||
"disabled": "已禁用",
|
||||
"reEnable": "重新啟用",
|
||||
"setExpiry": "設置到期時間",
|
||||
"disable": "禁用",
|
||||
"admin": "管理員",
|
||||
"updates": "更新",
|
||||
"update": "更新",
|
||||
"download": "下載",
|
||||
@@ -38,7 +29,6 @@
|
||||
"lastActiveTime": "上次啟用時間",
|
||||
"from": "從",
|
||||
"user": "帳戶",
|
||||
"expiry": "到期",
|
||||
"userExpiry": "帳戶到期",
|
||||
"userExpiryDescription": "每次註冊后指定的時間,jfa-go 將刪除/禁用該帳戶。您可以在設定中更改此行為。",
|
||||
"aboutProgram": "關於",
|
||||
@@ -57,7 +47,6 @@
|
||||
"conditionals": "條件",
|
||||
"preview": "預覽",
|
||||
"reset": "重設",
|
||||
"edit": "編輯",
|
||||
"donate": "捐贈",
|
||||
"sendPWR": "發送密碼重置",
|
||||
"contactThrough": "聯繫方式:",
|
||||
@@ -98,7 +87,6 @@
|
||||
"inviteUsersCreated": "創建的帳戶",
|
||||
"inviteNoProfile": "無資料",
|
||||
"inviteDateCreated": "已創建",
|
||||
"inviteRemainingUses": "剩餘使用次數",
|
||||
"inviteNoInvites": "無",
|
||||
"inviteExpiresInTime": "在 {n} 到期",
|
||||
"notifyEvent": "通知:",
|
||||
@@ -128,13 +116,9 @@
|
||||
"updateAppliedRefresh": "更新已應用,請重新整理。",
|
||||
"telegramVerified": "Telegram 帳戶已驗證。",
|
||||
"accountConnected": "帳戶已連接。",
|
||||
"errorConnection": "無法連接到 jfa-go。",
|
||||
"error401Unauthorized": "未經授權。嘗試重新整理頁面。",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "已應用設置,但應用主螢幕佈局可能失敗。",
|
||||
"errorHomescreenAppliedNoSettings": "已應用主螢幕佈局,但應用設置可能失敗。",
|
||||
"errorSettingsFailed": "應用失敗。",
|
||||
"errorLoginBlank": "帳戶名稱和/或密碼留空。",
|
||||
"errorUnknown": "未知的錯誤。",
|
||||
"errorSaveEmail": "無法儲存電子郵件。",
|
||||
"errorBlankFields": "欄位留空",
|
||||
"errorDeleteProfile": "無法刪除設置文件 {n}",
|
||||
@@ -142,7 +126,6 @@
|
||||
"errorCreateProfile": "無法創建設置文件 {n}",
|
||||
"errorSetDefaultProfile": "無法設置預設設置文件。",
|
||||
"errorLoadUsers": "無法讀取帳戶。",
|
||||
"errorSaveSettings": "無法儲存設置。",
|
||||
"errorLoadSettings": "無法讀取設置。",
|
||||
"errorSetOmbiProfile": "無法儲存 ombi 設置文件。",
|
||||
"errorLoadOmbiUsers": "無法讀取 ombi 帳戶。",
|
||||
@@ -214,4 +197,4 @@
|
||||
"plural": "已延長 {n} 個帳戶的到期時間。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
65
lang/common/ar-aa.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "العربية (AR)"
|
||||
},
|
||||
"strings": {
|
||||
"username": "اسم المستخدم",
|
||||
"password": "كلمة المرور",
|
||||
"emailAddress": "البريد الالكتروني",
|
||||
"name": "الاسم",
|
||||
"submit": "ادخال",
|
||||
"success": "نجاح",
|
||||
"continue": "اكمل",
|
||||
"error": "خطأ",
|
||||
"copy": "نسخ",
|
||||
"time24h": "توقيت 24 ساعة",
|
||||
"time12h": "توقيت 12 ساعة",
|
||||
"linkTelegram": "رابط تلغرام",
|
||||
"contactTelegram": "التواصل عبر التلغرام",
|
||||
"linkDiscord": "رابط الدسكورد",
|
||||
"linkMatrix": "ربط Matrix",
|
||||
"contactDiscord": "التواصل عبر الدسكورد",
|
||||
"theme": "القالب",
|
||||
"refresh": "تحديث",
|
||||
"required": "مطلوب",
|
||||
"login": "تسجيل الدخول",
|
||||
"admin": "المسؤول",
|
||||
"reEnable": "اعادة تفعيل",
|
||||
"disable": "تجميد",
|
||||
"accountStatus": "حالة الحساب",
|
||||
"notSet": "لم تحدد",
|
||||
"expiry": "انتهاء الصلاحية",
|
||||
"add": "اضافة",
|
||||
"edit": "تعديل",
|
||||
"delete": "حذف",
|
||||
"myAccount": "حسابي",
|
||||
"disabled": "معطل",
|
||||
"enabled": "مفعل",
|
||||
"send": "ارسال",
|
||||
"copied": "تم النسخ",
|
||||
"contactEmail": "التواصل عبر البريد الالكتروني",
|
||||
"logout": "تسجيل الخروج",
|
||||
"contactMethods": "وسيلة التواصل"
|
||||
},
|
||||
"notifications": {
|
||||
"errorUnknown": "خطأ غير معروف.",
|
||||
"error401Unauthorized": "غير مخول. حاول تحديث الصفحة.",
|
||||
"errorSaveSettings": "لا يمكن حفظ الاعدادات.",
|
||||
"errorLoginBlank": "اسم المستخدم و/أو كلمة المرور لم يتم ادخالها.",
|
||||
"errorConnection": "لا يمكن الاتصال بـالبرنامج."
|
||||
},
|
||||
"quantityStrings": {
|
||||
"year": {
|
||||
"singular": "{n} سنة",
|
||||
"plural": "{n} سنوات"
|
||||
},
|
||||
"month": {
|
||||
"singular": "{n} شهر",
|
||||
"plural": "{n} أشهر"
|
||||
},
|
||||
"day": {
|
||||
"singular": "{n} يوم",
|
||||
"plural": "{n} أيام"
|
||||
}
|
||||
}
|
||||
}
|
||||
67
lang/common/cs-cz.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Čeština (CZ)"
|
||||
},
|
||||
"strings": {
|
||||
"username": "Uživatelské jméno",
|
||||
"password": "Heslo",
|
||||
"emailAddress": "Emailová adresa",
|
||||
"name": "Název",
|
||||
"submit": "Odeslat",
|
||||
"send": "Poslat",
|
||||
"success": "Hotovo",
|
||||
"continue": "Pokračovat",
|
||||
"error": "Chyba",
|
||||
"copy": "Kopírovat",
|
||||
"copied": "Zkopírováno",
|
||||
"time24h": "Čas 24 hodin",
|
||||
"time12h": "Čas 12 hodin",
|
||||
"linkTelegram": "Link Telegram",
|
||||
"contactEmail": "Kontakt přes Email",
|
||||
"contactTelegram": "Kontakt přes Telegram",
|
||||
"linkDiscord": "Link Discord",
|
||||
"linkMatrix": "Link Matrix",
|
||||
"contactDiscord": "Kontakt přes Discord",
|
||||
"theme": "Téma",
|
||||
"refresh": "Obnovit",
|
||||
"required": "Požadované",
|
||||
"login": "Přihlásit se",
|
||||
"logout": "Odhlásit se",
|
||||
"admin": "Admin",
|
||||
"enabled": "Povoleno",
|
||||
"disabled": "Zakázáno",
|
||||
"reEnable": "Znovu povolit",
|
||||
"disable": "Zakázat",
|
||||
"contactMethods": "Kontaktní metody",
|
||||
"accountStatus": "Stav účtu",
|
||||
"notSet": "Nenastaveno",
|
||||
"expiry": "Uplynutí",
|
||||
"add": "Přidat",
|
||||
"edit": "Upravit",
|
||||
"delete": "Vymazat",
|
||||
"myAccount": "Můj účet",
|
||||
"referrals": "Doporučení",
|
||||
"inviteRemainingUses": "Zbývající použití"
|
||||
},
|
||||
"notifications": {
|
||||
"errorLoginBlank": "Uživatelské jméno a/nebo heslo zůstalo prázdné.",
|
||||
"errorConnection": "Nelze se připojit k jfa-go.",
|
||||
"errorUnknown": "Neznámá chyba.",
|
||||
"error401Unauthorized": "Neoprávněný. Zkuste stránku obnovit.",
|
||||
"errorSaveSettings": "Nastavení se nepodařilo uložit."
|
||||
},
|
||||
"quantityStrings": {
|
||||
"year": {
|
||||
"singular": "{n} rok",
|
||||
"plural": "{n} let"
|
||||
},
|
||||
"month": {
|
||||
"singular": "{n} měsíc",
|
||||
"plural": "{n} měsíců"
|
||||
},
|
||||
"day": {
|
||||
"singular": "{n} den",
|
||||
"plural": "{n} dní"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"strings": {
|
||||
"username": "Brugernavn",
|
||||
"password": "Adgangskode",
|
||||
"emailAddress": "E-mail Adresse",
|
||||
"emailAddress": "Email adresse",
|
||||
"name": "Navn",
|
||||
"submit": "Indsend",
|
||||
"send": "Send",
|
||||
@@ -24,6 +24,44 @@
|
||||
"contactDiscord": "Kontakt gennem Discord",
|
||||
"theme": "Tema",
|
||||
"refresh": "Opdater",
|
||||
"required": "Påkrævet"
|
||||
"required": "Påkrævet",
|
||||
"login": "Log på",
|
||||
"logout": "Log ud",
|
||||
"admin": "Administrator",
|
||||
"enabled": "Aktiveret",
|
||||
"disabled": "Deaktiveret",
|
||||
"reEnable": "Genaktiver",
|
||||
"disable": "Deaktiver",
|
||||
"expiry": "Udløb",
|
||||
"add": "Tilføj",
|
||||
"edit": "Rediger",
|
||||
"delete": "Slet",
|
||||
"inviteRemainingUses": "Resterende anvendelser",
|
||||
"referrals": "Henvisninger",
|
||||
"contactMethods": "Kontakt Metoder",
|
||||
"accountStatus": "Kontostatus",
|
||||
"notSet": "Ikke sat",
|
||||
"myAccount": "Min Konto"
|
||||
},
|
||||
"notifications": {
|
||||
"errorLoginBlank": "Brugernavnet og/eller adgangskoden blev efterladt tomme.",
|
||||
"errorConnection": "Kunne ikke oprette forbindelse til jfa-go.",
|
||||
"errorUnknown": "Ukendt fejl.",
|
||||
"error401Unauthorized": "Adgang nægtet. Prøv at genindlæse siden.",
|
||||
"errorSaveSettings": "Kunne ikke gemme indstillingerne."
|
||||
},
|
||||
"quantityStrings": {
|
||||
"year": {
|
||||
"singular": "{n} År",
|
||||
"plural": "{n} År"
|
||||
},
|
||||
"month": {
|
||||
"singular": "{n} Månede",
|
||||
"plural": "{n} Måneder"
|
||||
},
|
||||
"day": {
|
||||
"singular": "{n} Dag",
|
||||
"plural": "{n} Dage"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,26 +4,46 @@
|
||||
},
|
||||
"strings": {
|
||||
"username": "Benutzername",
|
||||
"name": "Name",
|
||||
"password": "Passwort",
|
||||
"emailAddress": "E-Mail Adresse",
|
||||
"name": "Name",
|
||||
"submit": "Absenden",
|
||||
"send": "Senden",
|
||||
"success": "Erfolgreich",
|
||||
"continue": "Weiter",
|
||||
"error": "Fehler",
|
||||
"copy": "Kopieren",
|
||||
"theme": "Thema",
|
||||
"copied": "Kopiert",
|
||||
"time24h": "24h-Format",
|
||||
"time12h": "12h-Format",
|
||||
"copied": "Kopiert",
|
||||
"linkTelegram": "Link Telegram",
|
||||
"contactEmail": "Kontakt über E-Mail",
|
||||
"contactTelegram": "Kontakt über Telegram",
|
||||
"linkDiscord": "Link Discord",
|
||||
"linkMatrix": "Link Matrix",
|
||||
"send": "Senden",
|
||||
"contactDiscord": "Kontakt über Discord",
|
||||
"theme": "Thema",
|
||||
"refresh": "Aktualisieren",
|
||||
"required": "Erforderlich"
|
||||
}
|
||||
}
|
||||
"required": "Erforderlich",
|
||||
"login": "Anmelden",
|
||||
"logout": "Abmelden",
|
||||
"admin": "Admin",
|
||||
"enabled": "Aktiviert",
|
||||
"disabled": "Deaktiviert",
|
||||
"reEnable": "Wieder aktivieren",
|
||||
"disable": "Deaktivieren",
|
||||
"expiry": "Ablaufdatum",
|
||||
"add": "Hinzufügen",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"inviteRemainingUses": "Verbleibende Verwendungen"
|
||||
},
|
||||
"notifications": {
|
||||
"errorLoginBlank": "Der Benutzername und/oder das Passwort wurden nicht ausgefüllt.",
|
||||
"errorConnection": "Konnte keine Verbindung zu jfa-go herstellen.",
|
||||
"errorUnknown": "Unbekannter Fehler.",
|
||||
"error401Unauthorized": "Unberechtigt. Versuch, die Seite zu aktualisieren.",
|
||||
"errorSaveSettings": "Einstellungen konnten nicht gespeichert werden."
|
||||
},
|
||||
"quantityStrings": {}
|
||||
}
|
||||
@@ -12,9 +12,28 @@
|
||||
"continue": "Συνέχεια",
|
||||
"error": "Σφάλμα",
|
||||
"copy": "Αντιγραφή",
|
||||
"theme": "Θέμα",
|
||||
"copied": "Αντιγράφηκε",
|
||||
"time24h": "24 Ώρες",
|
||||
"time12h": "12 Ώρες",
|
||||
"copied": "Αντιγράφηκε"
|
||||
}
|
||||
}
|
||||
"theme": "Θέμα",
|
||||
"login": "Σύνδεση",
|
||||
"logout": "Αποσύνδεση",
|
||||
"admin": "Διαχειριστής",
|
||||
"enabled": "Ενεργοποιημένο",
|
||||
"disabled": "Απενεργοποιημένο",
|
||||
"reEnable": "Επανα-ενεργοποίηση",
|
||||
"disable": "Απενεργοποίηση",
|
||||
"expiry": "Λήξη",
|
||||
"edit": "Επεξεργασία",
|
||||
"delete": "Διαγραφή",
|
||||
"inviteRemainingUses": "Εναπομείναντες χρήσεις"
|
||||
},
|
||||
"notifications": {
|
||||
"errorLoginBlank": "Το όνομα χρήστη και/ή ο κωδικός ήταν κενά.",
|
||||
"errorConnection": "Δεν μπόρεσε να συνδεθεί με το jfa-go.",
|
||||
"errorUnknown": "Άγνωστο σφάλμα.",
|
||||
"error401Unauthorized": "Ανεξουσιοδότητος. Προσπαθήστε να κάνετε επαναφόρτωση την σελίδα.",
|
||||
"errorSaveSettings": "Αποτυχία αποθήκευσης ρυθμίσεων."
|
||||
},
|
||||
"quantityStrings": {}
|
||||
}
|
||||
@@ -3,27 +3,47 @@
|
||||
"name": "English (GB)"
|
||||
},
|
||||
"strings": {
|
||||
"continue": "Continue",
|
||||
"time24h": "24h Time",
|
||||
"linkTelegram": "Link Telegram",
|
||||
"send": "Send",
|
||||
"linkDiscord": "Link Discord",
|
||||
"linkMatrix": "Link Matrix",
|
||||
"contactDiscord": "Contact through Discord",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"emailAddress": "Email Address",
|
||||
"name": "Name",
|
||||
"submit": "Submit",
|
||||
"send": "Send",
|
||||
"success": "Success",
|
||||
"continue": "Continue",
|
||||
"error": "Error",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"submit": "Submit",
|
||||
"success": "Success",
|
||||
"error": "Error",
|
||||
"time24h": "24h Time",
|
||||
"time12h": "12h Time",
|
||||
"theme": "Theme",
|
||||
"linkTelegram": "Link Telegram",
|
||||
"contactEmail": "Contact through Email",
|
||||
"contactTelegram": "Contact through Telegram",
|
||||
"name": "Name",
|
||||
"linkDiscord": "Link Discord",
|
||||
"linkMatrix": "Link Matrix",
|
||||
"contactDiscord": "Contact through Discord",
|
||||
"theme": "Theme",
|
||||
"refresh": "Refresh",
|
||||
"required": "Required"
|
||||
}
|
||||
}
|
||||
"required": "Required",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"admin": "Admin",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"reEnable": "Re-enable",
|
||||
"disable": "Disable",
|
||||
"expiry": "Expiry",
|
||||
"add": "Add",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"inviteRemainingUses": "Remaining uses"
|
||||
},
|
||||
"notifications": {
|
||||
"errorLoginBlank": "The username and/or password was left blank.",
|
||||
"errorConnection": "Couldn't connect to jfa-go.",
|
||||
"errorUnknown": "Unknown error.",
|
||||
"error401Unauthorized": "Unauthorised. Try refreshing the page.",
|
||||
"errorSaveSettings": "Couldn't save settings."
|
||||
},
|
||||
"quantityStrings": {}
|
||||
}
|
||||
@@ -24,6 +24,44 @@
|
||||
"contactDiscord": "Contact through Discord",
|
||||
"theme": "Theme",
|
||||
"refresh": "Refresh",
|
||||
"required": "Required"
|
||||
"required": "Required",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"admin": "Admin",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"reEnable": "Re-enable",
|
||||
"disable": "Disable",
|
||||
"contactMethods": "Contact Methods",
|
||||
"accountStatus": "Account Status",
|
||||
"notSet": "Not set",
|
||||
"expiry": "Expiry",
|
||||
"add": "Add",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"myAccount": "My Account",
|
||||
"referrals": "Referrals",
|
||||
"inviteRemainingUses": "Remaining uses"
|
||||
},
|
||||
"notifications": {
|
||||
"errorLoginBlank": "The username and/or password were left blank.",
|
||||
"errorConnection": "Couldn't connect to jfa-go.",
|
||||
"errorUnknown": "Unknown error.",
|
||||
"error401Unauthorized": "Unauthorized. Try refreshing the page.",
|
||||
"errorSaveSettings": "Couldn't save settings."
|
||||
},
|
||||
"quantityStrings": {
|
||||
"year": {
|
||||
"singular": "{n} Year",
|
||||
"plural": "{n} Years"
|
||||
},
|
||||
"month": {
|
||||
"singular": "{n} Month",
|
||||
"plural": "{n} Months"
|
||||
},
|
||||
"day": {
|
||||
"singular": "{n} Day",
|
||||
"plural": "{n} Days"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"emailAddress": "Correo electrónico",
|
||||
"name": "Nombre",
|
||||
"submit": "Enviar",
|
||||
"send": "Enviar",
|
||||
"success": "Éxito",
|
||||
"continue": "Continuar",
|
||||
"error": "Error",
|
||||
@@ -15,15 +16,52 @@
|
||||
"copied": "Copiado",
|
||||
"time24h": "Formato de 24 horas",
|
||||
"time12h": "Formato de 12 horas",
|
||||
"theme": "Tema",
|
||||
"send": "Enviar",
|
||||
"contactDiscord": "Contactar por Discord",
|
||||
"linkTelegram": "Enlace Telegram",
|
||||
"contactEmail": "Contactar por correo electrónico",
|
||||
"contactTelegram": "Contactar por Telegram",
|
||||
"linkMatrix": "Enlace Matrix",
|
||||
"linkDiscord": "Enlace Discord",
|
||||
"linkTelegram": "Enlace Telegram",
|
||||
"linkMatrix": "Enlace Matrix",
|
||||
"contactDiscord": "Contactar por Discord",
|
||||
"theme": "Tema",
|
||||
"refresh": "Refrescar",
|
||||
"required": "Requerido"
|
||||
"required": "Requerido",
|
||||
"login": "Acceso",
|
||||
"logout": "Cerrar sesión",
|
||||
"admin": "Administrador",
|
||||
"enabled": "Activado",
|
||||
"disabled": "Desactivado",
|
||||
"reEnable": "Reactivar",
|
||||
"disable": "Desactivar",
|
||||
"expiry": "Expiración",
|
||||
"add": "Agregar",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar",
|
||||
"inviteRemainingUses": "Usos restantes",
|
||||
"contactMethods": "Métodos de contacto",
|
||||
"accountStatus": "Estado de la cuenta",
|
||||
"notSet": "No establecido",
|
||||
"myAccount": "Mi cuenta",
|
||||
"referrals": "Referencias"
|
||||
},
|
||||
"notifications": {
|
||||
"errorLoginBlank": "El nombre de usuario y/o la contraseña se dejaron en blanco.",
|
||||
"errorConnection": "No se pudo conectar a jfa-go.",
|
||||
"errorUnknown": "Error desconocido.",
|
||||
"error401Unauthorized": "No autorizado. Intente actualizar la página.",
|
||||
"errorSaveSettings": "No se pudo guardar la configuración."
|
||||
},
|
||||
"quantityStrings": {
|
||||
"year": {
|
||||
"plural": "{n} años",
|
||||
"singular": "{n} año"
|
||||
},
|
||||
"month": {
|
||||
"singular": "{n} mes",
|
||||
"plural": "{n} meses"
|
||||
},
|
||||
"day": {
|
||||
"singular": "{n} día",
|
||||
"plural": "{n} días"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,7 @@
|
||||
"linkMatrix": "پیوند ماتریکس",
|
||||
"contactDiscord": "از طریق دیسکورد تماس بگیرید",
|
||||
"theme": "موضوع"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {},
|
||||
"quantityStrings": {}
|
||||
}
|
||||
@@ -1,28 +1,67 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Français (FR)",
|
||||
"author": "https://github.com/Killianbe"
|
||||
"name": "Français (FR)"
|
||||
},
|
||||
"strings": {
|
||||
"username": "Nom d'utilisateur",
|
||||
"name": "Nom",
|
||||
"password": "Mot de passe",
|
||||
"emailAddress": "Addresse Email",
|
||||
"emailAddress": "Adresse mail",
|
||||
"name": "Nom",
|
||||
"submit": "Soumettre",
|
||||
"send": "Envoyer",
|
||||
"success": "Succès",
|
||||
"continue": "Continuer",
|
||||
"error": "Erreur",
|
||||
"copy": "Copier",
|
||||
"copied": "Copié",
|
||||
"time24h": "Temps 24h",
|
||||
"time12h": "Temps 12h",
|
||||
"theme": "Thème",
|
||||
"copied": "Copié",
|
||||
"linkTelegram": "Lien Telegram",
|
||||
"contactEmail": "Contact par e-mail",
|
||||
"contactTelegram": "Contact par Telegram",
|
||||
"linkDiscord": "Lier Discord",
|
||||
"linkMatrix": "Lier Matrix",
|
||||
"send": "Envoyer",
|
||||
"contactDiscord": "Contacter par Discord"
|
||||
"contactDiscord": "Contacter par Discord",
|
||||
"theme": "Thème",
|
||||
"refresh": "Actualiser",
|
||||
"required": "Requis",
|
||||
"login": "S'identifier",
|
||||
"logout": "Se déconnecter",
|
||||
"admin": "Administrateur",
|
||||
"enabled": "Activé",
|
||||
"disabled": "Désactivé",
|
||||
"reEnable": "Ré-activé",
|
||||
"disable": "Désactiver",
|
||||
"expiry": "Expiration",
|
||||
"add": "Ajouter",
|
||||
"edit": "Éditer",
|
||||
"delete": "Effacer",
|
||||
"inviteRemainingUses": "Utilisations restantes",
|
||||
"accountStatus": "Statut du compte",
|
||||
"notSet": "Non défini",
|
||||
"myAccount": "Mon compte",
|
||||
"contactMethods": "Moyens de contact",
|
||||
"referrals": "Programme de parrainage"
|
||||
},
|
||||
"notifications": {
|
||||
"errorLoginBlank": "Le nom d'utilisateur et/ou le mot de passe sont vides.",
|
||||
"errorConnection": "Impossible de se connecter à jfa-go.",
|
||||
"errorUnknown": "Erreur inconnue.",
|
||||
"error401Unauthorized": "Non autorisé. Essayez d'actualiser la page.",
|
||||
"errorSaveSettings": "Impossible d'enregistrer les paramètres."
|
||||
},
|
||||
"quantityStrings": {
|
||||
"year": {
|
||||
"plural": "{n} années",
|
||||
"singular": "{n} année"
|
||||
},
|
||||
"day": {
|
||||
"singular": "{n} jour",
|
||||
"plural": "{n} jours"
|
||||
},
|
||||
"month": {
|
||||
"singular": "{n} mois",
|
||||
"plural": "{n} mois"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
65
lang/common/hu-hu.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Magyar (HU)"
|
||||
},
|
||||
"strings": {
|
||||
"login": "Belépés",
|
||||
"logout": "Kijelentkezés",
|
||||
"admin": "Adminisztrátor",
|
||||
"enabled": "Engedélyezve",
|
||||
"disabled": "Tiltva",
|
||||
"reEnable": "Újra engedélyezés",
|
||||
"disable": "Letiltás",
|
||||
"expiry": "Lejárat",
|
||||
"add": "Hozzáadás",
|
||||
"edit": "Szerkesztés",
|
||||
"delete": "Törlés",
|
||||
"password": "Jelszó",
|
||||
"username": "Felhasználónév",
|
||||
"emailAddress": "E-mail cím",
|
||||
"name": "Név",
|
||||
"submit": "Mentés",
|
||||
"send": "Küldés",
|
||||
"success": "Siker",
|
||||
"continue": "Folytatás",
|
||||
"error": "Hiba",
|
||||
"copy": "Másolás",
|
||||
"copied": "Másolva",
|
||||
"time24h": "24 órás idő",
|
||||
"time12h": "12 órás idő",
|
||||
"linkTelegram": "Telegram összekötése",
|
||||
"contactEmail": "Kapcsolat e-mailen keresztül",
|
||||
"contactTelegram": "Kapcsolat telegramon keresztül",
|
||||
"linkDiscord": "Discord összekötése",
|
||||
"linkMatrix": "Matrix összekötése",
|
||||
"contactDiscord": "Kapcsolat discordon keresztül",
|
||||
"theme": "Téma",
|
||||
"refresh": "Frissítés",
|
||||
"required": "Kötelező",
|
||||
"contactMethods": "Kapcsolati lehetőségek",
|
||||
"accountStatus": "Fiók státusz",
|
||||
"notSet": "Nincs beállítva",
|
||||
"myAccount": "Saját fiókom"
|
||||
},
|
||||
"notifications": {
|
||||
"errorLoginBlank": "A felhasználónév és/vagy a jelszó üresen lett hagyva.",
|
||||
"errorConnection": "Nem lehet csatlakozni a jfa-go-hoz.",
|
||||
"errorUnknown": "Ismeretlen hiba.",
|
||||
"error401Unauthorized": "Nincs jogosultság. Próbáld frissíteni az oldalt.",
|
||||
"errorSaveSettings": "Nem lehet menteni a beállításokat."
|
||||
},
|
||||
"quantityStrings": {
|
||||
"year": {
|
||||
"singular": "{n} Év",
|
||||
"plural": "{n} Évek"
|
||||
},
|
||||
"month": {
|
||||
"singular": "{n} Hónap",
|
||||
"plural": "{n} Hónapok"
|
||||
},
|
||||
"day": {
|
||||
"singular": "{n} Nap",
|
||||
"plural": "{n} Napok"
|
||||
}
|
||||
}
|
||||
}
|
||||