Compare commits

...

122 Commits

Author SHA1 Message Date
Harvey Tindall
b2b5083102 fix checkCheckCount on accounts reload 2021-05-03 18:55:46 +01:00
Harvey Tindall
c0f316d049 add preview to Announcements 2021-05-03 18:35:27 +01:00
Harvey Tindall
2c6d08319b add typechecking step to Makefile when DEBUG=on 2021-05-03 18:32:56 +01:00
Harvey Tindall
5d8f139356 fix race condition; rename route functions; fix swagger params
fix race condition when notifying of invite expiry, rename custom email
related functions as to reduce confusion, and add proper path params for
some swagger routes. Also moved some stuff around in api.go.
2021-05-02 20:42:37 +01:00
Harvey Tindall
87ef71b415 lowercase lang 2021-05-02 15:25:09 +01:00
André Cruz
cf99ae880c Translated using Weblate (Spanish)
Currently translated at 100.0% (6 of 6 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/password-reset-links/es/
2021-05-02 16:23:10 +02:00
André Cruz
8e86078394 Translated using Weblate (Spanish)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/emails/es/
2021-05-02 16:23:10 +02:00
André Cruz
beea903879 translation from Weblate (Spanish)
Currently translated at 100.0% (151 of 151 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/admin/es/
2021-05-02 16:23:10 +02:00
André Cruz
c5e4c5d509 Translated using Weblate (Spanish)
Currently translated at 100.0% (13 of 13 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/common-strings/es/
2021-05-02 16:23:10 +02:00
André Cruz
fac951c733 Translated using Weblate (Spanish)
Currently translated at 100.0% (98 of 98 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/setup/es/
2021-05-02 16:23:10 +02:00
Cornichon420
83449f3332 Translated using Weblate (French)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/emails/fr/
2021-05-02 16:23:10 +02:00
Cornichon420
2a9fc8c7a5 translation from Weblate (French)
Currently translated at 88.0% (133 of 151 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/admin/fr/
2021-05-02 16:23:10 +02:00
André Cruz
f8d4f79271 Translated using Weblate (Spanish)
Currently translated at 100.0% (98 of 98 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/setup/es/
2021-05-02 16:23:10 +02:00
André Cruz
bc466d0c6f Added translation using Weblate (Spanish) 2021-05-02 16:23:10 +02:00
Harvey Tindall
382a0f4c3c add donate button to about 2021-05-02 15:16:28 +01:00
Harvey Tindall
488c2f5df5 fix broken url in welcome email 2021-05-02 14:44:19 +01:00
Harvey Tindall
43effd0c32 add reset link option to setup 2021-05-02 14:15:03 +01:00
Harvey Tindall
af61549bf1 ombi: reset password when using pwr links
When password reset links are enabled, the ombi password will be reset
to the PIN along with Jellyfin.
2021-05-02 13:23:59 +01:00
Harvey Tindall
22a0d8925d Remove unused typescript, update config readme 2021-05-02 13:23:33 +01:00
Harvey Tindall
59a014f681 fix title for invite emails 2021-05-02 12:50:04 +01:00
Harvey Tindall
9944cc2db9 refactor; move logger to module 2021-05-01 00:13:57 +01:00
Harvey Tindall
570e3a1e54 fix en-es name and filename 2021-04-30 13:54:53 +01:00
woosade
a9bde40661 translation from Weblate (Spanish)
Currently translated at 100.0% (151 of 151 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/admin/es/
2021-04-29 23:54:04 +02:00
woosade
b03a185e88 Translated using Weblate (Spanish)
Currently translated at 100.0% (98 of 98 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/setup/es/
2021-04-29 23:54:03 +02:00
woosade
e450587eea translation from Weblate (Portuguese (Brazil))
Currently translated at 100.0% (151 of 151 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/admin/pt_BR/
2021-04-29 23:54:03 +02:00
Richard de Boer
30a529baac translation from Weblate (Dutch)
Currently translated at 100.0% (151 of 151 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/admin/nl/
2021-04-29 23:54:03 +02:00
woosade
adbb74f56b Translated using Weblate (Spanish)
Currently translated at 100.0% (98 of 98 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/setup/es/
2021-04-28 15:15:40 +02:00
woosade
223b4df172 translation from Weblate (Spanish)
Currently translated at 100.0% (28 of 28 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/form/es/
2021-04-28 15:15:40 +02:00
woosade
44dc315914 Translated using Weblate (Spanish)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/emails/es/
2021-04-28 15:15:40 +02:00
woosade
c959e2ce4d translation from Weblate (Spanish)
Currently translated at 100.0% (150 of 150 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/admin/es/
2021-04-28 15:15:40 +02:00
woosade
57b10dd514 Translated using Weblate (Spanish)
Currently translated at 100.0% (13 of 13 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/common-strings/es/
2021-04-28 15:15:40 +02:00
woosade
9da0f89613 Added translation using Weblate (Spanish) 2021-04-28 15:15:40 +02:00
woosade
4104cb334e add translation from Weblate (Spanish) 2021-04-28 15:15:40 +02:00
woosade
94067a1ec2 Added translation using Weblate (Spanish) 2021-04-28 15:15:40 +02:00
woosade
3e9da3baf7 add translation from Weblate (Spanish) 2021-04-28 15:15:40 +02:00
woosade
6129305b2c Added translation using Weblate (Spanish) 2021-04-28 15:15:40 +02:00
ClankJake
7165eb1f59 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/emails/pt_BR/
2021-04-28 15:15:40 +02:00
ClankJake
a4820de423 translation from Weblate (Portuguese (Brazil))
Currently translated at 100.0% (150 of 150 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/admin/pt_BR/
2021-04-28 15:15:40 +02:00
Marketos Damigos
0c09f3b05f Translated using Weblate (Greek)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/emails/el/
2021-04-28 15:15:40 +02:00
Marketos Damigos
269d67f071 translation from Weblate (Greek)
Currently translated at 100.0% (150 of 150 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/admin/el/
2021-04-28 15:15:39 +02:00
Richard de Boer
bdc0c0ffa2 Translated using Weblate (Dutch)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/emails/nl/
2021-04-28 15:15:39 +02:00
Richard de Boer
c00f5f4330 translation from Weblate (Dutch)
Currently translated at 100.0% (150 of 150 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/admin/nl/
2021-04-28 15:15:39 +02:00
Harvey Tindall
a2c344de83 add shorthand flag names
along with an ugly wrapper for the help message that merges the
descriptions for the short & long versions.
2021-04-24 23:54:56 +01:00
Harvey Tindall
886ae64feb add "systemd" command to generate a .service file
never got around to adding this from jellyfin-accounts for some reason.
2021-04-24 18:54:31 +01:00
Harvey Tindall
90a2c1f2e7 Fix email editor for other email types 2021-04-22 19:16:41 +01:00
Harvey Tindall
d772e43e44 merge language changes 2021-04-15 15:34:52 +01:00
Harvey Tindall
8fdab39b18 use templateEmail and show conditionals in editor 2021-04-15 15:34:17 +01:00
Harvey Tindall
f7d2771263 add email templater with basic if statements
at this point I really should've just used text/template, but I guess
this way compatibility is kept with existing custom emails. If statement
works as so:

{if variable}variable was true{endif}
{if !variable}variable was false{endif}

no else yet, just do as above (two if statements).
2021-04-14 23:58:54 +01:00
ClankJake
e8b1cca9ca translation from Weblate (Portuguese (Brazil))
Currently translated at 100.0% (140 of 140 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/admin/pt_BR/
2021-04-14 19:15:43 +02:00
Richard de Boer
d4d7219801 translation from Weblate (Dutch)
Currently translated at 100.0% (140 of 140 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/admin/nl/
2021-04-14 19:15:43 +02:00
Harvey Tindall
3273607fc3 translation: add fallback option to langMeta
If set to a language code (e.g fr-fr), any missing strings will be
filled in from that language (if possible) rather than from the default
en-us. Currently not used, but could be useful in the future for
variations of the same language.
2021-04-13 18:34:13 +01:00
Harvey Tindall
55e21f8be3 accounts: add user enable/disable & emails 2021-04-12 21:28:36 +01:00
ClankJake
dafb439a7d Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (45 of 45 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/emails/pt_BR/
2021-04-12 18:27:42 +02:00
Richard de Boer
ab94de2f95 Translated using Weblate (Dutch)
Currently translated at 100.0% (45 of 45 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/emails/nl/
2021-04-12 18:27:42 +02:00
Harvey Tindall
3dc0df0ac2 fix user expiry when only month field set 2021-04-09 13:35:46 +01:00
Harvey Tindall
d701c5f27d add months field to invites & expiry 2021-04-08 20:43:01 +01:00
Harvey Tindall
a8f71c83da store language preference as cookie 2021-04-08 16:03:46 +01:00
Harvey Tindall
7a3e0d60f9 add expiry to welcome email, add dummy emailer for debugging
the "yourAccountWillExpire" has also been added to the editor for #81.
To use the dummy emailer, set [email]/method to "dummy".
2021-04-08 14:20:13 +01:00
Harvey Tindall
2687af31ca updater: immediately store executable
for some reason I kept the response body and downloaded file in memory,
which led to timeouts and failed updates.
2021-04-07 18:17:18 +01:00
Harvey Tindall
d51a6abb02 remove cl.md 2021-04-07 17:45:31 +01:00
Harvey Tindall
374ffbf01f fix incomplete lang patching, add en-gb stub
en-gb is empty, so it's patched with en-us strings. Added so DD/MM/YY
date formatting was possible in the ui.
2021-04-07 17:42:15 +01:00
Harvey Tindall
871bc9f396 use proper date formatting on form for expiry 2021-04-07 15:17:15 +01:00
Harvey Tindall
66b7df7cde use selected language for time format, add manual selector
You can now choose between 12h and 24h time in the top left language
menu. Your preference is stored by the browser for future visits.
2021-04-07 15:09:44 +01:00
Harvey Tindall
bc76770ca4 move 12h/24h time strings to common 2021-04-07 15:09:25 +01:00
Harvey Tindall
7196361cf6 (hopefully) get proper locale from browser 2021-04-07 14:05:17 +01:00
Harvey Tindall
3e73d16cce merge language changes 2021-04-06 21:30:14 +01:00
Harvey Tindall
3f8414c70a use unix timestamp for inv created & usedBy
usedBy is still stored as a string in invites.json to cope with existing
invites with times stored formatted. knz/strtime requires cgo for
strptime, so it has been replaced with the native itchyny/timefmt-go.
2021-04-06 21:25:44 +01:00
Harvey Tindall
6ec2186bdf switch accounts tab to unix times
should now respect the client's locale.
2021-04-06 20:53:30 +01:00
ClankJake
6dd575b276 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (44 of 44 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/emails/pt_BR/
2021-04-06 19:49:18 +02:00
JoshiJoshiJoshi
1a98946d71 Translated using Weblate (German)
Currently translated at 100.0% (100 of 100 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/setup/de/
2021-04-06 19:49:18 +02:00
ClankJake
8922549bdb Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (11 of 11 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/common-strings/pt_BR/
2021-04-06 19:49:18 +02:00
Richard de Boer
173b49aeb7 Translated using Weblate (Dutch)
Currently translated at 100.0% (11 of 11 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/common-strings/nl/
2021-04-06 19:49:18 +02:00
JoshiJoshiJoshi
eee6046465 Translated using Weblate (German)
Currently translated at 100.0% (11 of 11 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/common-strings/de/
2021-04-06 19:49:18 +02:00
JoshiJoshiJoshi
b76011be4f translation from Weblate (German)
Currently translated at 100.0% (28 of 28 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/form/de/
2021-04-06 19:49:18 +02:00
Richard de Boer
3d93d79b0b Translated using Weblate (Dutch)
Currently translated at 100.0% (44 of 44 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/emails/nl/
2021-04-06 19:49:18 +02:00
Harvey Tindall
7dcc9b20a1 clear user cache when user expires 2021-04-06 18:39:12 +01:00
Harvey Tindall
754b956206 remove extra logs 2021-04-06 18:32:32 +01:00
Harvey Tindall
47ac505cac shutdown your background workers!
I believe everything #74 was caused by not shutting down the userDaemon
when we do a pseudo-restart. shutdown of it and the invite daemon are
now deferred so this should fix any problems and reduce log spam.
2021-04-06 18:12:06 +01:00
Harvey Tindall
e6e5231f63 add extra logging 2021-04-06 18:02:15 +01:00
Harvey Tindall
78049d4a33 hyphenate/dehyphenate users.json if necessary
doubt this would have caused problems anyway but why not.
2021-04-06 15:46:28 +01:00
Harvey Tindall
8a6cfe0b4d disallow negative values in ExtendExpiry, fix nil map err 2021-04-06 14:00:32 +01:00
Harvey Tindall
afedc78113 only load users if they don't exist already
another guess for #77.
2021-04-06 13:53:07 +01:00
Harvey Tindall
76b822213e add more error logging; mutex for app.storage.users 2021-04-06 13:44:52 +01:00
Harvey Tindall
ab3d5f3321 fix logging for expiry extension
also delete expiries for users that no longer exist.
2021-04-06 13:31:42 +01:00
Harvey Tindall
e1d42c8a87 Update CONTRIBUTING.md, mb 0.3.3
One last missing field added for #76.
2021-04-05 16:34:47 +01:00
Harvey Tindall
f53c852a4d bump mb to v0.3.2
includes missing struct fields for user Policy, fixes #76.
2021-04-05 15:07:30 +01:00
Harvey Tindall
aaea889e47 use apt-get in drone.yml 2021-04-03 21:38:26 +01:00
Harvey Tindall
bf98c74ecf Merge pull request #75 from Toucan-Sam/patch-1
Fix docker link in README.md
2021-04-03 21:37:54 +01:00
Toucan-Sam
fcadabd339 Fix docker link in README.md 2021-04-04 08:32:38 +12:00
Harvey Tindall
2a0edeb3c5 bump mediabrowser version, more consistent logs
uses descriptive errors added in mb v0.2.0. Also improved
the consistency of logs in api.go/main.go.
2021-04-02 22:13:04 +01:00
Harvey Tindall
30f16e7207 email: use strconv.Itoa instead of sprintf 2021-04-02 15:56:34 +01:00
Harvey Tindall
dbe7e2e659 remove ts-debug 2021-04-01 14:33:57 +01:00
Harvey Tindall
e16f05b130 use build constraints for embed, clean up makefile
internal-files/external-files and compile-debug are gone, the
environment variables INTERNAL=on/off and DEBUG=on/off replace them.
2021-04-01 14:22:11 +01:00
Harvey Tindall
07573a515a merge translation 2021-04-01 12:58:06 +01:00
Harvey Tindall
b3a2de50cf hide no_username support message on setup
fixes #74.
2021-04-01 12:56:47 +01:00
Marketos Damigos
5388d3d4c0 Translated using Weblate (Greek)
Currently translated at 100.0% (11 of 11 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/common-strings/el/
2021-03-31 21:18:01 +02:00
Marketos Damigos
c392d48174 Translated using Weblate (Greek)
Currently translated at 100.0% (44 of 44 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/emails/el/
2021-03-31 21:18:00 +02:00
Marketos Damigos
967fab3411 Translated using Weblate (Greek)
Currently translated at 100.0% (100 of 100 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/setup/el/
2021-03-31 14:54:34 +02:00
Marketos Damigos
d7845b78f6 Translated using Weblate (Greek)
Currently translated at 100.0% (43 of 43 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/emails/el/
2021-03-31 14:54:34 +02:00
Marketos Damigos
a253858625 translation from Weblate (Greek)
Currently translated at 100.0% (140 of 140 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/admin/el/
2021-03-31 14:54:34 +02:00
Marketos Damigos
ad1aae16e3 translation from Weblate (Greek)
Currently translated at 100.0% (28 of 28 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/form/el/
2021-03-31 14:54:34 +02:00
Harvey Tindall
9370913ace add password reset link option
When enabled (in Settings > Password Resets), a magic link will be sent
instead of a PIN when the user tries reset their password. By doing
this the user doesn't have to keep the Jellyfin tab open to enter the
code.
2021-03-30 22:41:28 +01:00
Harvey Tindall
dcd2e234e8 move "copy" string to common, add "copied"
for a new password reset feature.
2021-03-30 21:16:24 +01:00
Harvey Tindall
762dac2581 move mediabrowser to separate repo 2021-03-29 21:49:46 +01:00
Harvey Tindall
1cf8d3037b remove dependency on common from mediabrowser 2021-03-29 20:57:13 +01:00
Harvey Tindall
40808bdcb9 merge language changes 2021-03-29 20:54:06 +01:00
Harvey Tindall
2451d69341 rewrite lang.go format and templateString
surprisingly not much faster than the originals.
2021-03-27 16:07:22 +00:00
virusperfect
e449853568 Translated using Weblate (German)
Currently translated at 100.0% (100 of 100 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/setup/de/
2021-03-27 00:19:06 +01:00
virusperfect
2082e960c2 Translated using Weblate (German)
Currently translated at 100.0% (43 of 43 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/emails/de/
2021-03-27 00:19:06 +01:00
virusperfect
7b2a083f98 translation from Weblate (German)
Currently translated at 100.0% (28 of 28 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/form/de/
2021-03-27 00:19:06 +01:00
virusperfect
270143a8f6 translation from Weblate (German)
Currently translated at 100.0% (140 of 140 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/admin/de/
2021-03-27 00:19:06 +01:00
ClankJake
766b69d95e translation from Weblate (Portuguese (Brazil))
Currently translated at 100.0% (140 of 140 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/admin/pt_BR/
2021-03-27 00:19:06 +01:00
ClankJake
f5addc4947 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (100 of 100 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/setup/pt_BR/
2021-03-27 00:19:06 +01:00
ClankJake
55eb59c526 translation from Weblate (Portuguese (Brazil))
Currently translated at 100.0% (28 of 28 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/form/pt_BR/
2021-03-27 00:19:06 +01:00
Richard de Boer
679cac4dbd Translated using Weblate (Dutch)
Currently translated at 100.0% (100 of 100 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.hrfee.dev/projects/jfa-go/setup/nl/
2021-03-27 00:19:06 +01:00
Harvey Tindall
a0a25d64f1 rewrite stripmd, fix some typos
doesn't work any better, but more efficient and doesn't require
eyebleach after viewing.
2021-03-26 23:13:19 +00:00
Harvey Tindall
9875458b01 rewrite time unmarshaler for mediabrowser
Last ditch effort for #69, removes quotes and trailing Z's manually and
also removes nanoseconds since they're useless.
2021-03-23 21:59:41 +00:00
Harvey Tindall
f0dccc58aa separate pprof from debug mode
enabled with -pprof now.
2021-03-23 21:59:04 +00:00
Harvey Tindall
636bc22d52 reimplement Lshortfile for log wrapper
Fixes all debug messages having "logger:<line>:" instead of the actual
caller.
2021-03-23 21:57:53 +00:00
Harvey Tindall
fc6b6a9c6b Fix time parser for "ZZ" prefix
I think this means UTC-08:00, but this just strips it since time
handling is pretty naïve already.
2021-03-23 16:10:25 +00:00
Harvey Tindall
1a6d78352c add comments, fix user expiry log spam
now actually removes the already deleted user from the expiry list.
2021-03-21 22:50:33 +00:00
Harvey Tindall
e351c35cc8 use banner class on banner in about 2021-03-21 00:59:51 +00:00
114 changed files with 3647 additions and 2556 deletions

View File

@@ -16,10 +16,10 @@ steps:
GITHUB_TOKEN:
from_secret: github_token
commands:
- apt update -y
- apt install build-essential python3-pip curl software-properties-common sed upx -y
- apt-get update -y
- apt-get install build-essential python3-pip curl software-properties-common sed upx -y
- (curl -sL https://deb.nodesource.com/setup_14.x | bash -)
- apt install nodejs
- apt-get install nodejs
- curl -sL https://git.io/goreleaser > ../goreleaser
- chmod +x ../goreleaser
- ./scripts/version.sh ../goreleaser
@@ -73,10 +73,10 @@ steps:
- name: build
image: golang:latest
commands:
- apt update -y
- apt install build-essential python3-pip curl software-properties-common sed upx -y
- apt-get update -y
- apt-get install build-essential python3-pip curl software-properties-common sed upx -y
- (curl -sL https://deb.nodesource.com/setup_14.x | bash -)
- apt install nodejs
- apt-get install nodejs
- curl -sL https://git.io/goreleaser > goreleaser
- chmod +x goreleaser
- ./scripts/version.sh ./goreleaser --snapshot --skip-publish --rm-dist
@@ -143,10 +143,10 @@ steps:
- name: build
image: golang:latest
commands:
- apt update -y
- apt install build-essential python3-pip curl software-properties-common sed upx -y
- apt-get update -y
- apt-get install build-essential python3-pip curl software-properties-common sed upx -y
- (curl -sL https://deb.nodesource.com/setup_14.x | bash -)
- apt install nodejs
- apt-get install nodejs
- curl -sL https://git.io/goreleaser > goreleaser
- chmod +x goreleaser
- ./scripts/version.sh ./goreleaser --snapshot --skip-publish --rm-dist

2
.gitignore vendored
View File

@@ -4,7 +4,6 @@ dist/
build/
data/
version.go
embed.go
notes
docs/*
lang/langtostruct.py
@@ -14,3 +13,4 @@ server.key
server.pem
server.crt
instructions-debian.txt
cl.md

View File

@@ -22,11 +22,11 @@ before:
- python3 scripts/generate_ini.py -i config/config-base.json -o data/config-default.ini
- python3 scripts/compile_mjml.py -o data/
- npx esbuild --bundle ts/admin.ts --outfile=./data/web/js/admin.js --minify
- npx esbuild --bundle ts/pwr.ts --outfile=./data/web/js/pwr.js --minify
- npx esbuild --bundle ts/form.ts --outfile=./data/web/js/form.js --minify
- npx esbuild --bundle ts/setup.ts --outfile=./data/web/js/setup.js --minify
- go get -u github.com/swaggo/swag/cmd/swag
- swag init -g main.go
- python3 scripts/embed.py internal
builds:
- dir: ./
env:

View File

@@ -1,8 +1,15 @@
#### 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.
If you need to test your changes:
* `make debug` will build everything, and include sourcemaps for typescript. This should be the first thing you run.
* `make compile` compiles go into `build/jfa-go`.
* `make ts-debug` will compile typescript w/ sourcemaps into `build/data/web/js`.
* `make copy` will copy css, html, language and static files into `build/data`.
#### Compiling
Prefix each of these with `make DEBUG=on INTERNAL=off `:
* `all` will download deps and build everything. The executable and data will be placed in `build`. This is only necessary the first time.
* `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`.
* `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`.
* `copy` will copy iconography, html, language files and static data into `build/data`.
See the [wiki](https://github.com/hrfee/jfa-go/wiki/Build) for more info.

View File

@@ -6,7 +6,7 @@ RUN apt-get update -y \
&& apt-get install build-essential python3-pip curl software-properties-common sed -y \
&& (curl -sL https://deb.nodesource.com/setup_14.x | bash -) \
&& apt-get install nodejs \
&& (cd /opt/build; make configuration npm email typescript bundle-css swagger copy external-files GOESBUILD=on) \
&& (cd /opt/build; make configuration npm email typescript bundle-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
@@ -16,7 +16,7 @@ ENV GOARCH=$TARGETARCH
COPY --from=support /opt/build /opt/build
RUN (cd /opt/build; make compile UPDATER=docker)
RUN (cd /opt/build; make compile INTERNAL=off UPDATER=docker)
FROM golang:latest

111
Makefile
View File

@@ -11,11 +11,33 @@ VERSION := $(shell echo $(VERSION) | sed 's/v//g')
COMMIT ?= $(shell git rev-parse --short HEAD || echo unknown)
UPDATER ?= off
BUILDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT)
LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT)
ifeq ($(UPDATER), on)
BUILDFLAGS := $(BUILDFLAGS) -X main.updater=binary
LDFLAGS := $(LDFLAGS) -X main.updater=binary
else ifneq ($(UPDATER), off)
BUILDFLAGS := $(BUILDFLAGS) -X main.updater=$(UPDATER)
LDFLAGS := $(LDFLAGS) -X main.updater=$(UPDATER)
endif
INTERNAL ?= on
ifeq ($(INTERNAL), on)
TAGS :=
DATA := data
else
DATA := build/data
TAGS := -tags external
endif
DEBUG ?= off
ifeq ($(DEBUG), on)
LDFLAGS := -s -w $(LDFLAGS)
SOURCEMAP := --sourcemap
TYPECHECK := tsc -noEmit --project ts/tsconfig.json
# jank
COPYTS := rm -r $(DATA)/web/js/ts; cp -r ts $(DATA)/web/js
else
SOURCEMAP :=
COPYTS :=
TYPECHECK :=
endif
npm:
@@ -29,31 +51,24 @@ npm:
configuration:
$(info Fixing config-base)
-mkdir -p data
python3 scripts/enumerate_config.py -i config/config-base.json -o data/config-base.json
-mkdir -p $(DATA)
python3 scripts/enumerate_config.py -i config/config-base.json -o $(DATA)/config-base.json
$(info Generating config-default.ini)
python3 scripts/generate_ini.py -i config/config-base.json -o data/config-default.ini
python3 scripts/generate_ini.py -i config/config-base.json -o $(DATA)/config-default.ini
email:
$(info Generating email html)
python3 scripts/compile_mjml.py -o data/
python3 scripts/compile_mjml.py -o $(DATA)/
typescript:
$(TYPECHECK)
$(info compiling typescript)
-mkdir -p data/web/js
-$(ESBUILD) --bundle ts/admin.ts --outfile=./data/web/js/admin.js --minify
-$(ESBUILD) --bundle ts/form.ts --outfile=./data/web/js/form.js --minify
-$(ESBUILD) --bundle ts/setup.ts --outfile=./data/web/js/setup.js --minify
ts-debug:
$(info compiling typescript w/ sourcemaps)
-mkdir -p data/web/js
-$(ESBUILD) --bundle ts/admin.ts --sourcemap --outfile=./data/web/js/admin.js
-$(ESBUILD) --bundle ts/form.ts --sourcemap --outfile=./data/web/js/form.js
-$(ESBUILD) --bundle ts/setup.ts --sourcemap --outfile=./data/web/js/setup.js
-rm -r data/web/js/ts
$(info copying typescript)
cp -r ts data/web/js
-mkdir -p $(DATA)/web/js
-$(ESBUILD) --bundle ts/admin.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/admin.js --minify
-$(ESBUILD) --bundle ts/pwr.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/pwr.js --minify
-$(ESBUILD) --bundle ts/form.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/form.js --minify
-$(ESBUILD) --bundle ts/setup.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/setup.js --minify
$(COPYTS)
swagger:
$(GOBINARY) get github.com/swaggo/swag/cmd/swag
@@ -64,47 +79,47 @@ compile:
$(GOBINARY) mod download
$(info Building)
mkdir -p build
cd build && CGO_ENABLED=0 $(GOBINARY) build -ldflags="-s -w $(BUILDFLAGS)" -o ./jfa-go ../*.go
compile-debug:
$(info Downloading deps)
$(GOBINARY) mod download
$(info Building)
mkdir -p build
cd build && CGO_ENABLED=0 $(GOBINARY) build -ldflags "$(BUILDFLAGS)" -o ./jfa-go ../*.go
CGO_ENABLED=0 $(GOBINARY) build -ldflags="-s -w $(LDFLAGS)" $(TAGS) -o build/jfa-go
compress:
upx --lzma build/jfa-go
bundle-css:
-mkdir -p data/web/css
-mkdir -p $(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 --minify
copy:
$(info copying fonts)
cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 data/web/css/
cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 $(DATA)/web/css/
$(info copying html)
cp -r html data/
cp -r html $(DATA)/
$(info copying static data)
-mkdir -p data/web
cp -r static/* data/web/
-mkdir -p $(DATA)/web
cp -r static/* $(DATA)/web/
$(info copying systemd service)
cp jfa-go.service $(DATA)/
$(info copying language files)
cp -r lang data/
cp LICENSE data/
cp -r lang $(DATA)/
cp LICENSE $(DATA)/
internal-files:
python3 scripts/embed.py internal
external-files:
python3 scripts/embed.py external
-mkdir -p build
$(info copying internal data into build/)
cp -r data build/
# internal-files:
# python3 scripts/embed.py internal
#
# external-files:
# python3 scripts/embed.py external
# -mkdir -p build
# $(info copying internal data into build/)
# cp -r data build/
install:
cp -r build $(DESTDIR)/jfa-go
all: configuration npm email typescript bundle-css swagger copy internal-files compile
all-external: configuration npm email typescript bundle-css swagger copy external-files compile
debug: configuration npm email ts-debug bundle-css swagger copy external-files compile-debug
clean:
-rm -r $(DATA)
-rm -r build
-rm mail/*.html
-rm docs/docs.go docs/swagger.json docs/swagger.yaml
go clean
all: configuration npm email typescript bundle-css swagger copy compile

View File

@@ -8,26 +8,24 @@
---
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.
I chose to rewrite the python [jellyfin-accounts](https://github.com/hrfee/jellyfin-accounts) in Go mainly as a learning experience, but also to slightly improve speeds and efficiency.
#### Features
* 🧑 Invite based account creation: Sends invites to your friends or family, and let them choose their own username and password without relying on you.
* Send invites via a link and/or email
* Granular control over invites: Validity period as well as number of uses can be specified.
* Account profiles: Assign settings profiles to invites so new users have your predefined permissions, homescreen layout, etc. applied to their account on creation.
* Password validation: Ensure users choose a strong password.
* ⌛ User expiry: Specify a validity period, and new user's accounts will be disabled/deleted after it. The period can be manually extended too.
* ⌛ 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.
* 📨 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 user's forget their passwords and request a change in Jellyfin, jfa-go reads the PIN from the created file and sends it straight to the user via email.
* 🔑 Password resets: When users forget their passwords and request a change in Jellyfin, jfa-go reads the PIN from the created file and sends it straight to the user via email.
* Notifications: Get notified when someone creates an account, or an invite expires.
* 📣 Announcements: Bulk email 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
* 🌓 Customizable look
* Edit emails with variables and markdown
* 🌓 Customizations
* Customize emails with variables and markdown
* Specify contact and help messages to appear in emails and pages
* Light and dark themes available
@@ -44,7 +42,7 @@ I chose to rewrite the python [jellyfin-accounts](https://github.com/hrfee/jelly
#### Install
The [Docker](https://hub.docker.com/repository/docker/hrfee/jfa-go) image is your best bet.
The [Docker](https://hub.docker.com/r/hrfee/jfa-go) image is your best bet.
```sh
docker create \
--name "jfa-go" \ # Whatever you want to name it
@@ -71,8 +69,6 @@ Otherwise, full build instructions can be found [here](https://github.com/hrfee/
#### Usage
Simply run `jfa-go` to start the application. A setup wizard will start on `localhost:8056` (or your own specified address). Upon completion, refresh the page.
Note: jfa-go does not run as a daemon by default. You'll need to figure this out yourself.
```
Usage of ./jfa-go:
-config string
@@ -89,6 +85,11 @@ Usage of ./jfa-go:
Enable swagger at /swagger/index.html
```
#### 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,

828
api.go

File diff suppressed because it is too large Load Diff

154
args.go Normal file
View File

@@ -0,0 +1,154 @@
package main
import (
"bufio"
"bytes"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
)
func (app *appContext) loadArgs(firstCall bool) {
if firstCall {
flag.Usage = helpFunc
help := flag.Bool("help", false, "prints this message.")
flag.BoolVar(help, "h", false, "SHORTHAND")
DATA = flag.String("data", app.dataPath, "alternate path to data directory.")
flag.StringVar(DATA, "d", app.dataPath, "SHORTHAND")
CONFIG = flag.String("config", app.configPath, "alternate path to config file.")
flag.StringVar(CONFIG, "c", app.configPath, "SHORTHAND")
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")
DEBUG = flag.Bool("debug", false, "Enables debug logging.")
PPROF = flag.Bool("pprof", false, "Exposes pprof profiler on /debug/pprof.")
SWAGGER = flag.Bool("swagger", false, "Enable swagger at /swagger/index.html")
flag.Parse()
if *help {
flag.Usage()
os.Exit(0)
}
if *SWAGGER {
os.Setenv("SWAGGER", "1")
}
if *DEBUG {
os.Setenv("DEBUG", "1")
}
if *PPROF {
os.Setenv("PPROF", "1")
}
}
if os.Getenv("SWAGGER") == "1" {
*SWAGGER = true
}
if os.Getenv("DEBUG") == "1" {
*DEBUG = true
}
if os.Getenv("PPROF") == "1" {
*PPROF = true
}
// attempt to apply command line flags correctly
if app.configPath == *CONFIG && app.dataPath != *DATA {
app.dataPath = *DATA
app.configPath = filepath.Join(app.dataPath, "config.ini")
} else if app.configPath != *CONFIG && app.dataPath == *DATA {
app.configPath = *CONFIG
} else {
app.configPath = *CONFIG
app.dataPath = *DATA
}
// Previously used for self-restarts but leaving them here as they might be useful.
if v := os.Getenv("JFA_CONFIGPATH"); v != "" {
app.configPath = v
}
if v := os.Getenv("JFA_DATAPATH"); v != "" {
app.dataPath = v
}
os.Setenv("JFA_CONFIGPATH", app.configPath)
os.Setenv("JFA_DATAPATH", app.dataPath)
}
/* Adds start/stop/systemd to help message, and
also gets rid of usage for shorthand flags, and merge them with the full-length one.
implementation is 🤢, will clean this up eventually.
-h SHORTHAND
-help
prints this message.
becomes:
-help, -h
prints this message.
*/
func helpFunc() {
fmt.Fprint(os.Stderr, `Usage of jfa-go:
start
start jfa-go as a daemon and run in the background.
stop
stop a daemonized instance of jfa-go.
systemd
generate a systemd .service file.
`)
shortHands := []string{"-help", "-data", "-config", "-port"}
var b bytes.Buffer
// Write defaults into buffer then remove any shorthands
flag.CommandLine.SetOutput(&b)
flag.PrintDefaults()
flag.CommandLine.SetOutput(os.Stderr)
scanner := bufio.NewScanner(&b)
out := ""
line := scanner.Text()
eof := !scanner.Scan()
lastLine := false
for !eof || lastLine {
nextline := scanner.Text()
start := 0
if len(nextline) != 0 {
for nextline[start] == ' ' && start < len(nextline) {
start++
}
}
if strings.Contains(line, "SHORTHAND") || (len(nextline) != 0 && strings.Contains(nextline, "SHORTHAND") && nextline[start] != '-') {
line = nextline
if lastLine {
break
}
eof := !scanner.Scan()
if eof {
lastLine = true
}
continue
}
// if !strings.Contains(line, "SHORTHAND") && !(strings.Contains(nextline, "SHORTHAND") && !strings.Contains(nextline, "-")) {
match := false
for i, c := range line {
if c != '-' {
continue
}
for _, s := range shortHands {
if i+len(s) <= len(line) && line[i:i+len(s)] == s {
out += line[:i+len(s)] + ", " + s[:2] + line[i+len(s):] + "\n"
match = true
break
}
}
}
if !match {
out += line + "\n"
}
line = nextline
if lastLine {
break
}
eof := !scanner.Scan()
if eof {
lastLine = true
}
}
fmt.Fprint(os.Stderr, out)
}

View File

@@ -5,7 +5,7 @@ import (
"log"
)
// TimeoutHandler recovers from an http timeout.
// TimeoutHandler recovers from an http timeout or panic.
type TimeoutHandler func()
// NewTimeoutHandler returns a new Timeout handler.

View File

@@ -64,6 +64,12 @@ func (app *appContext) loadConfig() error {
app.MustSetValue("deletion", "email_html", "jfa-go:"+"deleted.html")
app.MustSetValue("deletion", "email_text", "jfa-go:"+"deleted.txt")
// Deletion template is good enough for these as well.
app.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html")
app.MustSetValue("disable_enable", "disabled_text", "jfa-go:"+"deleted.txt")
app.MustSetValue("disable_enable", "enabled_html", "jfa-go:"+"deleted.html")
app.MustSetValue("disable_enable", "enabled_text", "jfa-go:"+"deleted.txt")
app.MustSetValue("welcome_email", "email_html", "jfa-go:"+"welcome.html")
app.MustSetValue("welcome_email", "email_text", "jfa-go:"+"welcome.txt")
@@ -121,6 +127,7 @@ func (app *appContext) loadConfig() error {
}
app.storage.lang.chosenAdminLang = app.config.Section("ui").Key("language-admin").MustString("en-us")
app.storage.lang.chosenEmailLang = app.config.Section("email").Key("language").MustString("en-us")
app.storage.lang.chosenPWRLang = app.config.Section("password_resets").Key("language").MustString("en-us")
app.email = NewEmailer(app)

View File

@@ -1,11 +1,4 @@
### fixconfig
Python's `json` library retains the order of data in a JSON file, which meant settings sent to the web page would be in the right order. Go's `encoding/json` and maps do not retain order, so this script opens the json file, and for each section, adds an "order" list which tells the web page in which order to display settings.
Python's `json` library retains the order of data in a JSON file, which meant settings sent to the web page would be in the right order. Go's `encoding/json` and maps do not retain order, so `enumerate/enumerate_config.py` opens the json file, and for each section, adds an "order" array which tells the web page in which order to display settings.
Specify the input and output files with `-i` and `-o` respectively.
### jsontostruct
Generates a go struct from `config-base.json`. I wrote this because i was annoyed with the `ini` library, but i've since realised mapping the ini values onto it is painful.

View File

@@ -124,7 +124,7 @@
["en-us", "English (US)"]
],
"value": "en-us",
"description": "Default Account Form Language. See issue #12 on Github if you'd like to translate."
"description": "Default Account Form Language. Visit weblate.hrfee.dev if you'd like to translate."
},
"language-admin": {
"name": "Default Admin Language",
@@ -135,7 +135,7 @@
["en-us", "English (US)"]
],
"value": "en-us",
"description": "Default Admin page Language. Settings has not been translated. Submit a PR on github if you'd like to translate."
"description": "Default Admin page Language. Settings has not been translated. Visit weblate.hrfee.dev if you'd like to translate."
},
"theme": {
"name": "Default Look",
@@ -468,6 +468,27 @@
"value": "/path/to/jellyfin",
"description": "Path to the folder Jellyfin puts password-reset files."
},
"link_reset": {
"name": "Use reset link instead of PIN (Required for Ombi)",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "bool",
"value": false,
"description": "Send users a link to reset their password instead of a PIN. Must be enabled to reset Ombi password at the same time as the Jellyfin password."
},
"language": {
"name": "Default reset link language",
"required": false,
"requires_restart": true,
"depends_true": "link_reset",
"type": "select",
"options": [
["en-us", "English (US)"]
],
"value": "en-us",
"description": "Default language for password reset success screen."
},
"email_html": {
"name": "Custom email (HTML)",
"required": false,
@@ -701,7 +722,7 @@
"order": [],
"meta": {
"name": "Ombi Integration",
"description": "Connect to Ombi to automatically create both Ombi and Jellyfin accounts for new users. You'll need to create a user template for this to work. Once enabled, refresh to see an option in settings for this."
"description": "Connect to Ombi to automatically create both Ombi and Jellyfin accounts for new users. You'll need to create a user template for this to work. Once enabled, refresh to see an option in settings for this. To handle password resets for Ombi & Jellyfin, enable \"Use reset link instead of PIN\"."
},
"settings": {
"enabled": {
@@ -878,6 +899,68 @@
}
}
},
"disable_enable": {
"order": [],
"meta": {
"name": "Account Disabling/Enabling",
"description": "Subject/email files for account disabling/enabling emails.",
"depends_true": "email|method"
},
"settings": {
"subject_disabled": {
"name": "Email subject (Disabled)",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Subject of account disabling emails."
},
"subject_enabled": {
"name": "Email subject (Enabled)",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Subject of account enabling emails."
},
"disabled_html": {
"name": "Custom disabling email (HTML)",
"required": false,
"requires_restart": false,
"advanced": true,
"type": "text",
"value": "",
"description": "Path to custom email html"
},
"disabled_text": {
"name": "Custom disabling email (plaintext)",
"required": false,
"requires_restart": false,
"advanced": true,
"type": "text",
"value": "",
"description": "Path to custom email in plain text"
},
"enabled_html": {
"name": "Custom enabling email (HTML)",
"required": false,
"requires_restart": false,
"advanced": true,
"type": "text",
"value": "",
"description": "Path to custom email html"
},
"enabled_text": {
"name": "Custom enabling email (plaintext)",
"required": false,
"requires_restart": false,
"advanced": true,
"type": "text",
"value": "",
"description": "Path to custom email in plain text"
}
}
},
"deletion": {
"order": [],
"meta": {

View File

@@ -1,541 +0,0 @@
package main
type Metadata struct{
Name string `json:"name"`
Description string `json:"description"`
}
type Config struct{
Order []string `json:"order"`
Jellyfin struct{
Order []string `json:"order"`
Meta Metadata `json:"meta"`
Username struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"username"`
} `json:"username" cfg:"username"`
Password struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"password"`
} `json:"password" cfg:"password"`
Server struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"server"`
} `json:"server" cfg:"server"`
PublicServer struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"public_server"`
} `json:"public_server" cfg:"public_server"`
Client struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"client"`
} `json:"client" cfg:"client"`
Version struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"version"`
} `json:"version" cfg:"version"`
Device struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"device"`
} `json:"device" cfg:"device"`
DeviceId struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"device_id"`
} `json:"device_id" cfg:"device_id"`
} `json:"jellyfin"`
Ui struct{
Order []string `json:"order"`
Meta Metadata `json:"meta"`
Theme struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Options []string `json:"options"`
Value string `json:"value" cfg:"theme"`
} `json:"theme" cfg:"theme"`
Host struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"host"`
} `json:"host" cfg:"host"`
Port struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value int `json:"value" cfg:"port"`
} `json:"port" cfg:"port"`
JellyfinLogin struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value bool `json:"value" cfg:"jellyfin_login"`
} `json:"jellyfin_login" cfg:"jellyfin_login"`
AdminOnly struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value bool `json:"value" cfg:"admin_only"`
} `json:"admin_only" cfg:"admin_only"`
Username struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"username"`
} `json:"username" cfg:"username"`
Password struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"password"`
} `json:"password" cfg:"password"`
Email struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"email"`
} `json:"email" cfg:"email"`
Debug struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value bool `json:"value" cfg:"debug"`
} `json:"debug" cfg:"debug"`
ContactMessage struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"contact_message"`
} `json:"contact_message" cfg:"contact_message"`
HelpMessage struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"help_message"`
} `json:"help_message" cfg:"help_message"`
SuccessMessage struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"success_message"`
} `json:"success_message" cfg:"success_message"`
Bs5 struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value bool `json:"value" cfg:"bs5"`
} `json:"bs5" cfg:"bs5"`
} `json:"ui"`
PasswordValidation struct{
Order []string `json:"order"`
Meta Metadata `json:"meta"`
Enabled struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value bool `json:"value" cfg:"enabled"`
} `json:"enabled" cfg:"enabled"`
MinLength struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"min_length"`
} `json:"min_length" cfg:"min_length"`
Upper struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"upper"`
} `json:"upper" cfg:"upper"`
Lower struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"lower"`
} `json:"lower" cfg:"lower"`
Number struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"number"`
} `json:"number" cfg:"number"`
Special struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"special"`
} `json:"special" cfg:"special"`
} `json:"password_validation"`
Email struct{
Order []string `json:"order"`
Meta Metadata `json:"meta"`
NoUsername struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value bool `json:"value" cfg:"no_username"`
} `json:"no_username" cfg:"no_username"`
Use24H struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value bool `json:"value" cfg:"use_24h"`
} `json:"use_24h" cfg:"use_24h"`
DateFormat struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"date_format"`
} `json:"date_format" cfg:"date_format"`
Message struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"message"`
} `json:"message" cfg:"message"`
Method struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Options []string `json:"options"`
Value string `json:"value" cfg:"method"`
} `json:"method" cfg:"method"`
Address struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"address"`
} `json:"address" cfg:"address"`
From struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"from"`
} `json:"from" cfg:"from"`
} `json:"email"`
PasswordResets struct{
Order []string `json:"order"`
Meta Metadata `json:"meta"`
Enabled struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value bool `json:"value" cfg:"enabled"`
} `json:"enabled" cfg:"enabled"`
WatchDirectory struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"watch_directory"`
} `json:"watch_directory" cfg:"watch_directory"`
EmailHtml struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"email_html"`
} `json:"email_html" cfg:"email_html"`
EmailText struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"email_text"`
} `json:"email_text" cfg:"email_text"`
Subject struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"subject"`
} `json:"subject" cfg:"subject"`
} `json:"password_resets"`
InviteEmails struct{
Order []string `json:"order"`
Meta Metadata `json:"meta"`
Enabled struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value bool `json:"value" cfg:"enabled"`
} `json:"enabled" cfg:"enabled"`
EmailHtml struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"email_html"`
} `json:"email_html" cfg:"email_html"`
EmailText struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"email_text"`
} `json:"email_text" cfg:"email_text"`
Subject struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"subject"`
} `json:"subject" cfg:"subject"`
UrlBase struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"url_base"`
} `json:"url_base" cfg:"url_base"`
} `json:"invite_emails"`
Notifications struct{
Order []string `json:"order"`
Meta Metadata `json:"meta"`
Enabled struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value bool `json:"value" cfg:"enabled"`
} `json:"enabled" cfg:"enabled"`
ExpiryHtml struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"expiry_html"`
} `json:"expiry_html" cfg:"expiry_html"`
ExpiryText struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"expiry_text"`
} `json:"expiry_text" cfg:"expiry_text"`
CreatedHtml struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"created_html"`
} `json:"created_html" cfg:"created_html"`
CreatedText struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"created_text"`
} `json:"created_text" cfg:"created_text"`
} `json:"notifications"`
Mailgun struct{
Order []string `json:"order"`
Meta Metadata `json:"meta"`
ApiUrl struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"api_url"`
} `json:"api_url" cfg:"api_url"`
ApiKey struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"api_key"`
} `json:"api_key" cfg:"api_key"`
} `json:"mailgun"`
Smtp struct{
Order []string `json:"order"`
Meta Metadata `json:"meta"`
Encryption struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Options []string `json:"options"`
Value string `json:"value" cfg:"encryption"`
} `json:"encryption" cfg:"encryption"`
Server struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"server"`
} `json:"server" cfg:"server"`
Port struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value int `json:"value" cfg:"port"`
} `json:"port" cfg:"port"`
Password struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"password"`
} `json:"password" cfg:"password"`
} `json:"smtp"`
Files struct{
Order []string `json:"order"`
Meta Metadata `json:"meta"`
Invites struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"invites"`
} `json:"invites" cfg:"invites"`
Emails struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"emails"`
} `json:"emails" cfg:"emails"`
UserTemplate struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"user_template"`
} `json:"user_template" cfg:"user_template"`
UserConfiguration struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"user_configuration"`
} `json:"user_configuration" cfg:"user_configuration"`
UserDisplayprefs struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"user_displayprefs"`
} `json:"user_displayprefs" cfg:"user_displayprefs"`
CustomCss struct{
Name string `json:"name"`
Required bool `json:"required"`
Restart bool `json:"requires_restart"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" cfg:"custom_css"`
} `json:"custom_css" cfg:"custom_css"`
} `json:"files"`
}

View File

@@ -52,6 +52,11 @@
padding: var(--spacing-4,1rem);
}
.modal-content .banner {
margin-left: calc(-1 * var(--spacing-4,1rem) - 0.5%); /* Not sure why this is necessary */
margin-right: calc(-1 * var(--spacing-4,1rem) - 0.5%);
}
div.card:contains(section.banner.footer) {
padding-bottom: 0px;
}
@@ -96,6 +101,10 @@ div.card:contains(section.banner.footer) {
margin-right: 1rem;
}
.p-1 {
padding: 1rem;
}
.pb-1 {
padding-bottom: 1rem;
}

280
email.go
View File

@@ -11,6 +11,7 @@ import (
"io"
"net/smtp"
"os"
"strconv"
"strings"
"sync"
textTemplate "text/template"
@@ -18,8 +19,8 @@ import (
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/itchyny/timefmt-go"
jEmail "github.com/jordan-wright/email"
"github.com/knz/strtime"
"github.com/mailgun/mailgun-go/v4"
)
@@ -28,6 +29,13 @@ type emailClient interface {
send(fromName, fromAddr string, email *Email, address ...string) error
}
type dummyClient struct{}
func (dc *dummyClient) send(fromName, fromAddr string, email *Email, address ...string) error {
fmt.Printf("FROM: %s <%s>\nTO: %s\nTEXT: %s\n", fromName, fromAddr, strings.Join(address, ", "), email.Text)
return nil
}
// Mailgun client implements emailClient.
type Mailgun struct {
client *mailgun.MailgunImpl
@@ -100,21 +108,21 @@ type Email struct {
}
func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool, datePattern, timePattern string) (d, t, expiresIn string) {
d, _ = strtime.Strftime(expiry, datePattern)
t, _ = strtime.Strftime(expiry, timePattern)
d = timefmt.Format(expiry, datePattern)
t = timefmt.Format(expiry, timePattern)
currentTime := time.Now()
if tzaware {
currentTime = currentTime.UTC()
}
_, _, days, hours, minutes, _ := timeDiff(expiry, currentTime)
if days != 0 {
expiresIn += fmt.Sprintf("%dd ", days)
expiresIn += strconv.Itoa(days) + "d "
}
if hours != 0 {
expiresIn += fmt.Sprintf("%dh ", hours)
expiresIn += strconv.Itoa(hours) + "h "
}
if minutes != 0 {
expiresIn += fmt.Sprintf("%dm ", minutes)
expiresIn += strconv.Itoa(minutes) + "m "
}
expiresIn = strings.TrimSuffix(expiresIn, " ")
return
@@ -145,6 +153,8 @@ func NewEmailer(app *appContext) *Emailer {
}
} else if method == "mailgun" {
emailer.NewMailgun(app.config.Section("mailgun").Key("api_url").String(), app.config.Section("mailgun").Key("api_key").String())
} else if method == "dummy" {
emailer.sender = &dummyClient{}
}
return emailer
}
@@ -264,13 +274,12 @@ 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 {
content := app.storage.customEmails.EmailConfirmation.Content
for _, v := range app.storage.customEmails.EmailConfirmation.Variables {
replaceWith, ok := template[v[1:len(v)-1]]
if ok {
content = strings.ReplaceAll(content, v, replaceWith.(string))
}
}
content := templateEmail(
app.storage.customEmails.EmailConfirmation.Content,
app.storage.customEmails.EmailConfirmation.Variables,
nil,
template,
)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.HTML, email.Text, err = emailer.construct(app, "email_confirmation", "email_", template)
@@ -331,18 +340,17 @@ func (emailer *Emailer) inviteValues(code string, invite Invite, app *appContext
func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext, noSub bool) (*Email, error) {
email := &Email{
Subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.InviteEmail.get("title")),
Subject: app.config.Section("invite_emails").Key("subject").MustString(emailer.lang.InviteEmail.get("title")),
}
template := emailer.inviteValues(code, invite, app, noSub)
var err error
if app.storage.customEmails.InviteEmail.Enabled {
content := app.storage.customEmails.InviteEmail.Content
for _, v := range app.storage.customEmails.InviteEmail.Variables {
replaceWith, ok := template[v[1:len(v)-1]]
if ok {
content = strings.ReplaceAll(content, v, replaceWith.(string))
}
}
content := templateEmail(
app.storage.customEmails.InviteEmail.Content,
app.storage.customEmails.InviteEmail.Variables,
nil,
template,
)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.HTML, email.Text, err = emailer.construct(app, "invite_emails", "email_", template)
@@ -376,13 +384,12 @@ 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 {
content := app.storage.customEmails.InviteExpiry.Content
for _, v := range app.storage.customEmails.InviteExpiry.Variables {
replaceWith, ok := template[v[1:len(v)-1]]
if ok {
content = strings.ReplaceAll(content, v, replaceWith.(string))
}
}
content := templateEmail(
app.storage.customEmails.InviteExpiry.Content,
app.storage.customEmails.InviteExpiry.Variables,
nil,
template,
)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.HTML, email.Text, err = emailer.construct(app, "notifications", "expiry_", template)
@@ -431,13 +438,12 @@ 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 {
content := app.storage.customEmails.UserCreated.Content
for _, v := range app.storage.customEmails.UserCreated.Variables {
replaceWith, ok := template[v[1:len(v)-1]]
if ok {
content = strings.ReplaceAll(content, v, replaceWith.(string))
}
}
content := templateEmail(
app.storage.customEmails.UserCreated.Content,
app.storage.customEmails.UserCreated.Variables,
nil,
template,
)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.HTML, email.Text, err = emailer.construct(app, "notifications", "created_", template)
@@ -453,15 +459,21 @@ func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bo
message := app.config.Section("email").Key("message").String()
template := map[string]interface{}{
"someoneHasRequestedReset": emailer.lang.PasswordReset.get("someoneHasRequestedReset"),
"ifItWasYou": emailer.lang.PasswordReset.get("ifItWasYou"),
"ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"),
"pinString": emailer.lang.PasswordReset.get("pin"),
"link_reset": false,
"message": "",
"username": pwr.Username,
"date": d,
"time": t,
"expiresInMinutes": expiresIn,
}
linkResetEnabled := app.config.Section("password_resets").Key("link_reset").MustBool(false)
if linkResetEnabled {
template["ifItWasYou"] = emailer.lang.PasswordReset.get("ifItWasYouLink")
} else {
template["ifItWasYou"] = emailer.lang.PasswordReset.get("ifItWasYou")
}
if noSub {
template["helloUser"] = emailer.lang.Strings.get("helloUser")
template["codeExpiry"] = emailer.lang.PasswordReset.get("codeExpiry")
@@ -472,7 +484,22 @@ func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bo
} else {
template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": pwr.Username})
template["codeExpiry"] = emailer.lang.PasswordReset.template("codeExpiry", tmpl{"date": d, "time": t, "expiresInMinutes": expiresIn})
template["pin"] = pwr.Pin
inviteLink := app.config.Section("invite_emails").Key("url_base").String()
if linkResetEnabled {
if inviteLink != "" {
// Strip /invite form end of this URL, ik its ugly.
template["link_reset"] = true
pinLink := fmt.Sprintf("%s/reset?pin=%s", strings.Replace(inviteLink, "/invite", "", 1), pwr.Pin)
template["pin"] = pinLink
// Only used in html email.
template["pin_code"] = pwr.Pin
} else {
app.info.Println("Password Reset link disabled as no URL Base provided. Set in Settings > Invite Emails.")
template["pin"] = pwr.Pin
}
} else {
template["pin"] = pwr.Pin
}
template["message"] = message
}
return template
@@ -485,13 +512,12 @@ func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub
template := emailer.resetValues(pwr, app, noSub)
var err error
if app.storage.customEmails.PasswordReset.Enabled {
content := app.storage.customEmails.PasswordReset.Content
for _, v := range app.storage.customEmails.PasswordReset.Variables {
replaceWith, ok := template[v[1:len(v)-1]]
if ok {
content = strings.ReplaceAll(content, v, replaceWith.(string))
}
}
content := templateEmail(
app.storage.customEmails.PasswordReset.Content,
app.storage.customEmails.PasswordReset.Variables,
nil,
template,
)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.HTML, email.Text, err = emailer.construct(app, "password_resets", "email_", template)
@@ -504,9 +530,9 @@ func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub
func (emailer *Emailer) deletedValues(reason string, app *appContext, noSub bool) map[string]interface{} {
template := map[string]interface{}{
"yourAccountWasDeleted": emailer.lang.UserDeleted.get("yourAccountWasDeleted"),
"reasonString": emailer.lang.UserDeleted.get("reason"),
"message": "",
"yourAccountWas": emailer.lang.UserDeleted.get("yourAccountWasDeleted"),
"reasonString": emailer.lang.Strings.get("reason"),
"message": "",
}
if noSub {
empty := []string{"reason"}
@@ -527,13 +553,12 @@ 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 {
content := app.storage.customEmails.UserDeleted.Content
for _, v := range app.storage.customEmails.UserDeleted.Variables {
replaceWith, ok := template[v[1:len(v)-1]]
if ok {
content = strings.ReplaceAll(content, v, replaceWith.(string))
}
}
content := templateEmail(
app.storage.customEmails.UserDeleted.Content,
app.storage.customEmails.UserDeleted.Variables,
nil,
template,
)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.HTML, email.Text, err = emailer.construct(app, "deletion", "email_", template)
@@ -544,16 +569,99 @@ func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub b
return email, nil
}
func (emailer *Emailer) welcomeValues(username string, app *appContext, noSub bool) map[string]interface{} {
func (emailer *Emailer) disabledValues(reason string, app *appContext, noSub bool) map[string]interface{} {
template := map[string]interface{}{
"welcome": emailer.lang.WelcomeEmail.get("welcome"),
"youCanLoginWith": emailer.lang.WelcomeEmail.get("youCanLoginWith"),
"jellyfinURLString": emailer.lang.WelcomeEmail.get("jellyfinURL"),
"usernameString": emailer.lang.Strings.get("username"),
"message": "",
"yourAccountWas": emailer.lang.UserDisabled.get("yourAccountWasDisabled"),
"reasonString": emailer.lang.Strings.get("reason"),
"message": "",
}
if noSub {
empty := []string{"jellyfinURL", "username"}
empty := []string{"reason"}
for _, v := range empty {
template[v] = "{" + v + "}"
}
} else {
template["reason"] = reason
template["message"] = app.config.Section("email").Key("message").String()
}
return template
}
func (emailer *Emailer) constructDisabled(reason string, app *appContext, noSub bool) (*Email, error) {
email := &Email{
Subject: app.config.Section("disable_enable").Key("subject_disabled").MustString(emailer.lang.UserDisabled.get("title")),
}
var err error
template := emailer.disabledValues(reason, app, noSub)
if app.storage.customEmails.UserDisabled.Enabled {
content := templateEmail(
app.storage.customEmails.UserDisabled.Content,
app.storage.customEmails.UserDisabled.Variables,
nil,
template,
)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.HTML, email.Text, err = emailer.construct(app, "disable_enable", "disabled_", template)
}
if err != nil {
return nil, err
}
return email, nil
}
func (emailer *Emailer) enabledValues(reason string, app *appContext, noSub bool) map[string]interface{} {
template := map[string]interface{}{
"yourAccountWas": emailer.lang.UserEnabled.get("yourAccountWasEnabled"),
"reasonString": emailer.lang.Strings.get("reason"),
"message": "",
}
if noSub {
empty := []string{"reason"}
for _, v := range empty {
template[v] = "{" + v + "}"
}
} else {
template["reason"] = reason
template["message"] = app.config.Section("email").Key("message").String()
}
return template
}
func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub bool) (*Email, error) {
email := &Email{
Subject: app.config.Section("disable_enable").Key("subject_enabled").MustString(emailer.lang.UserEnabled.get("title")),
}
var err error
template := emailer.enabledValues(reason, app, noSub)
if app.storage.customEmails.UserEnabled.Enabled {
content := templateEmail(
app.storage.customEmails.UserEnabled.Content,
app.storage.customEmails.UserEnabled.Variables,
nil,
template,
)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.HTML, email.Text, err = emailer.construct(app, "disable_enable", "enabled_", 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"),
"youCanLoginWith": emailer.lang.WelcomeEmail.get("youCanLoginWith"),
"jellyfinURLString": emailer.lang.WelcomeEmail.get("jellyfinURL"),
"usernameString": emailer.lang.Strings.get("username"),
"message": "",
"yourAccountWillExpire": "",
}
if noSub {
empty := []string{"jellyfinURL", "username", "yourAccountWillExpire"}
for _, v := range empty {
template[v] = "{" + v + "}"
}
@@ -561,24 +669,43 @@ func (emailer *Emailer) welcomeValues(username string, app *appContext, noSub bo
template["jellyfinURL"] = app.config.Section("jellyfin").Key("public_server").String()
template["username"] = username
template["message"] = app.config.Section("email").Key("message").String()
exp := app.formatDatetime(expiry)
if !expiry.IsZero() {
if custom {
template["yourAccountWillExpire"] = exp
} else if !expiry.IsZero() {
template["yourAccountWillExpire"] = emailer.lang.WelcomeEmail.template("yourAccountWillExpire", tmpl{
"date": exp,
})
}
}
}
return template
}
func (emailer *Emailer) constructWelcome(username string, app *appContext, noSub bool) (*Email, error) {
func (emailer *Emailer) constructWelcome(username string, expiry time.Time, app *appContext, noSub bool) (*Email, error) {
email := &Email{
Subject: app.config.Section("welcome_email").Key("subject").MustString(emailer.lang.WelcomeEmail.get("title")),
}
var err error
template := emailer.welcomeValues(username, app, noSub)
var template map[string]interface{}
if app.storage.customEmails.WelcomeEmail.Enabled {
content := app.storage.customEmails.WelcomeEmail.Content
for _, v := range app.storage.customEmails.WelcomeEmail.Variables {
replaceWith, ok := template[v[1:len(v)-1]]
if ok {
content = strings.ReplaceAll(content, v, replaceWith.(string))
}
}
template = emailer.welcomeValues(username, expiry, app, noSub, true)
} else {
template = emailer.welcomeValues(username, expiry, app, noSub, false)
}
if noSub {
template["yourAccountWillExpire"] = emailer.lang.WelcomeEmail.template("yourAccountWillExpire", tmpl{
"date": "{yourAccountWillExpire}",
})
}
if app.storage.customEmails.WelcomeEmail.Enabled {
content := templateEmail(
app.storage.customEmails.WelcomeEmail.Content,
app.storage.customEmails.WelcomeEmail.Variables,
app.storage.customEmails.WelcomeEmail.Conditionals,
template,
)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.HTML, email.Text, err = emailer.construct(app, "welcome_email", "email_", template)
@@ -608,13 +735,12 @@ func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Emai
var err error
template := emailer.userExpiredValues(app, noSub)
if app.storage.customEmails.UserExpired.Enabled {
content := app.storage.customEmails.UserExpired.Content
for _, v := range app.storage.customEmails.UserExpired.Variables {
replaceWith, ok := template[v[1:len(v)-1]]
if ok {
content = strings.ReplaceAll(content, v, replaceWith.(string))
}
}
content := templateEmail(
app.storage.customEmails.UserExpired.Content,
app.storage.customEmails.UserExpired.Variables,
nil,
template,
)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.HTML, email.Text, err = emailer.construct(app, "user_expiry", "email_", template)

View File

@@ -1 +0,0 @@
`scripts/embed.py [internal/external]` will copy the respective file into the main directory. If internal, `//go:embed` is used to embed the `data/` directory in the binary. If external, `os.DirFS` is used to access the `data/` directory, which should be placed next to the executable.

View File

@@ -1,3 +1,5 @@
// +build external
package main
import (

18
go.mod
View File

@@ -4,16 +4,14 @@ go 1.16
replace github.com/hrfee/jfa-go/docs => ./docs
replace github.com/hrfee/jfa-go/mediabrowser => ./mediabrowser
replace github.com/hrfee/jfa-go/common => ./common
replace github.com/hrfee/jfa-go/ombi => ./ombi
replace github.com/hrfee/jfa-go/logger => ./logger
require (
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/evanw/esbuild v0.8.50 // indirect
github.com/fatih/color v1.10.0
github.com/fsnotify/fsnotify v1.4.9
github.com/gin-contrib/pprof v1.3.0
@@ -21,30 +19,32 @@ require (
github.com/gin-gonic/gin v1.6.3
github.com/go-chi/chi v4.1.2+incompatible // indirect
github.com/go-openapi/spec v0.20.3 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/validator/v10 v10.4.1 // indirect
github.com/golang/protobuf v1.4.3 // indirect
github.com/gomarkdown/markdown v0.0.0-20210208175418-bda154fe17d8
github.com/google/uuid v1.1.2 // indirect
github.com/hrfee/jfa-go/common v0.0.0-20210105184019-fdc97b4e86cc
github.com/hrfee/jfa-go/docs v0.0.0-20201112212552-b6f3cd7c1f71
github.com/hrfee/jfa-go/mediabrowser v0.0.0-20201112212552-b6f3cd7c1f71
github.com/hrfee/jfa-go/logger v0.0.0-00010101000000-000000000000 // indirect
github.com/hrfee/jfa-go/ombi v0.0.0-20201112212552-b6f3cd7c1f71
github.com/hrfee/mediabrowser v0.3.3
github.com/itchyny/timefmt-go v0.1.2
github.com/jordan-wright/email v4.0.1-0.20200917010138-e1c00e156980+incompatible
github.com/knz/strtime v0.0.0-20200924090105-187c67f2bf5e
github.com/lithammer/shortuuid/v3 v3.0.4
github.com/mailgun/mailgun-go/v4 v4.3.0
github.com/mailru/easyjson v0.7.7 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/smartystreets/goconvey v1.6.4 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14
github.com/swaggo/gin-swagger v1.3.0
github.com/swaggo/swag v1.7.0 // indirect
github.com/ugorji/go v1.2.0 // indirect
github.com/writeas/go-strip-markdown v2.0.1+incompatible
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9 // indirect
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 // indirect
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b // indirect
golang.org/x/net v0.0.0-20210331212208-0fccb6fa2b5c // indirect
golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54 // indirect
golang.org/x/tools v0.1.0 // indirect
google.golang.org/protobuf v1.25.0 // indirect
gopkg.in/ini.v1 v1.62.0

34
go.sum
View File

@@ -17,8 +17,6 @@ github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJ
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w=
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=
@@ -32,8 +30,6 @@ github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473 h1:4
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0 h1:EQciDnbrYxy13PgWoY8AqoxGiPrpgBZ1R8UNe3ddc+A=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/evanw/esbuild v0.8.50 h1:97YxSC9Ni9zu82601vI93cSUS0C+WUcPPNIARuGcQtI=
github.com/evanw/esbuild v0.8.50/go.mod h1:y2AFBAGVelPqPodpdtxWWqe6n2jYf5FrsJbligmRmuw=
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=
@@ -86,8 +82,9 @@ github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/
github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.11/go.mod h1:Uc0gKkdR+ojzsEpjh39QChyu92vPgIr72POcgHMAgSY=
github.com/go-openapi/swag v0.19.14 h1:gm3vOOXfiuw5i9p5N9xJvfjvuofpyvLA9Wr6QfK5Fng=
github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
@@ -130,6 +127,10 @@ github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/hrfee/mediabrowser v0.3.3 h1:7E05uiol8hh2ytKn3WVLrUIvHAyifYEIy3Y5qtuNh8I=
github.com/hrfee/mediabrowser v0.3.3/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/itchyny/timefmt-go v0.1.2 h1:q0Xa4P5it6K6D7ISsbLAMwx1PnWlixDcJL6/sFs93Hs=
github.com/itchyny/timefmt-go v0.1.2/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A=
github.com/jordan-wright/email v4.0.1-0.20200917010138-e1c00e156980+incompatible h1:CL0ooBNfbNyJTJATno+m0h+zM5bW6v7fKlboKUGP/dI=
github.com/jordan-wright/email v4.0.1-0.20200917010138-e1c00e156980+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
@@ -142,8 +143,6 @@ github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/knz/strtime v0.0.0-20200924090105-187c67f2bf5e h1:ViPE0JEOvtw5I0EGUiFSr2VNKGNU+3oBT+oHbDXHbxk=
github.com/knz/strtime v0.0.0-20200924090105-187c67f2bf5e/go.mod h1:4ZxfWkxwtc7dBeifERVVWRy9F9rTU9p0yCDgeCtlius=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -190,8 +189,6 @@ github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCb
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
@@ -200,6 +197,8 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykE
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
@@ -268,10 +267,9 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/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-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210331212208-0fccb6fa2b5c h1:KHUzaHIpjWVlVVNh65G3hhuj3KB1HnjY6Cq5cTvRQT8=
golang.org/x/net v0.0.0-20210331212208-0fccb6fa2b5c/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs=
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=
@@ -291,18 +289,12 @@ golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/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-20200501145240-bc7a7d42d5c3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43 h1:SgQ6LNaYJU0JIuEHv9+s6EbhSCwYeAf5Yvj6lpYlqAE=
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04 h1:cEhElsAv9LUt9ZUUocxzWe05oFLVd+AA2nstydTeI8g=
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b h1:ggRgirZABFolTmi3sn6Ivd9SipZwLedQ5wR0aAKnFxU=
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54 h1:rF3Ohx8DRyl8h2zw9qojyLHLhrJpEMgyPOImREEryf0=
golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@@ -40,11 +40,24 @@
</div>
<div id="modal-about" class="modal">
<div class="modal-content content card">
<span class="heading">{{ .strings.aboutProgram }} <span class="modal-close">&times;</span></span>
<img src="{{ .urlBase }}/banner.svg" class="mt-1" alt="jfa-go banner">
<img src="{{ .urlBase }}/banner.svg" class="banner header" alt="jfa-go banner">
<span class="heading"><span class="modal-close">&times;</span></span>
<p><i class="icon ri-github-fill"></i><a href="https://github.com/hrfee/jfa-go">jfa-go</a></p>
<p>{{ .strings.version }} <span class="code monospace">{{ .version }}</span></p>
<p>{{ .strings.commitNoun }} <span class="code monospace">{{ .commit }}</span></p>
<div class="dropdown" tabindex="0">
<span class="button ~info dropdown-button">
<i class="ri-hand-heart-line mr-half"></i>
{{ .strings.donate }}
<span class="ml-1 chev"></span>
</span>
<div class="dropdown-display">
<div class="card ~neutral !low">
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button input ~neutral field mb-half lang-link">GitHub</a>
<a href="https://ko-fi.com/hrfee" target="_blank" class="button input ~neutral field mb-half lang-link">Ko-fi</a>
</div>
</div>
</div>
<p><a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE">Available under the MIT License.</a></p>
<pre class="monospace">{{ .license }}</pre>
</div>
@@ -99,23 +112,41 @@
<form class="modal-content card" id="form-extend-expiry" href="">
<span class="heading"><span id="header-extend-expiry"></span> <span class="modal-close">&times;</span></span>
<div class="content mt-half">
<label class="label supra" for="extend-expiry-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="extend-expiry-days">
<option>0</option>
</select>
<div class="row">
<div class="col">
<label class="label supra" for="extend-expiry-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<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 !normal mb-1 mt-half">
<select id="extend-expiry-days">
<option>0</option>
</select>
</div>
</div>
</div>
<label class="label supra" for="extend-expiry-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="extend-expiry-hours">
<option>0</option>
</select>
</div>
<label class="label supra" for="extend-expiry-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<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 !normal mb-1 mt-half">
<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 !normal mb-1 mt-half">
<select id="extend-expiry-minutes">
<option>0</option>
</select>
</div>
</div>
</div>
<label>
<input type="submit" class="unfocused">
@@ -125,18 +156,24 @@
</form>
</div>
<div id="modal-announce" class="modal">
<form class="modal-content card" id="form-announce" href="">
<form class="modal-content wide card" id="form-announce" href="">
<span class="heading"><span id="header-announce"></span> <span class="modal-close">&times;</span></span>
<div class="content mt-half">
<label class="label supra" for="announce-subject"> {{ .strings.subject }}</label>
<input type="text" id="announce-subject" class="input ~neutral !normal mb-1 mt-half">
<label class="label supra" for="textarea-announce">{{ .strings.message }}</label>
<textarea id="textarea-announce" class="textarea full-width ~neutral !normal mt-half monospace"></textarea>
<p class="support mt-half mb-1">{{ .strings.markdownSupported }}</p>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge !normal full-width center supra submit">{{ .strings.submit }}</span>
</label>
<div class="row">
<div class="col flex-col content mt-half">
<label class="label supra" for="announce-subject"> {{ .strings.subject }}</label>
<input type="text" id="announce-subject" class="input ~neutral !normal mb-1 mt-half">
<label class="label supra" for="textarea-announce">{{ .strings.message }}</label>
<textarea id="textarea-announce" class="textarea full-width ~neutral !normal mt-half monospace"></textarea>
<p class="support mt-half mb-1">{{ .strings.markdownSupported }}</p>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge !normal full-width center supra submit">{{ .strings.submit }}</span>
</label>
</div>
<div class="col card ~neutral !low">
<span class="subheading supra">{{ .strings.preview }}</span>
<div class="mt-half" id="announce-preview"></div>
</div>
</div>
</form>
</div>
@@ -165,6 +202,8 @@
<div class="col flex-col content mt-half">
<span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span>
<div id="editor-variables"></div>
<span class="label supra" for="editor-conditionals" id="label-editor-conditionals">{{ .strings.conditionals }}</span>
<div id="editor-conditionals"></div>
<label class="label supra" for="textarea-editor">{{ .strings.message }}</label>
<textarea id="textarea-editor" class="textarea full-width flex-auto ~neutral !normal mt-half monospace"></textarea>
<p class="support mt-half mb-1">{{ .strings.markdownSupported }}</p>
@@ -276,7 +315,16 @@
<span class="ml-1 chev"></span>
</span>
<div class="dropdown-display">
<div class="card ~neutral !low" id="lang-list">
<div class="card ~neutral !low">
<label class="switch pb-1">
<input type="radio" name="lang-time" id="lang-12h">
<span>{{ .strings.time12h }}</span>
</label>
<label class="switch pb-1">
<input type="radio" name="lang-time" id="lang-24h">
<span>{{ .strings.time24h }}</span>
</label>
<div id="lang-list"></div>
</div>
</div>
</span>
@@ -316,23 +364,41 @@
</label>
</div>
<div id="inv-duration">
<label class="label supra" for="create-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="create-days">
<option>0</option>
</select>
<div class="row">
<div class="col">
<label class="label supra" for="create-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="create-months">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<label class="label supra" for="create-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="create-days">
<option>0</option>
</select>
</div>
</div>
</div>
<label class="label supra" for="create-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="create-hours">
<option>0</option>
</select>
</div>
<label class="label supra" for="create-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="create-minutes">
<option>0</option>
</select>
<div class="row">
<div class="col">
<label class="label supra" for="create-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="create-hours">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<label class="label supra" for="create-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="create-minutes">
<option>0</option>
</select>
</div>
</div>
</div>
</div>
<div id="user-expiry" class="unfocused">
@@ -343,27 +409,47 @@
<span class="ml-half">{{ .strings.enabled }} </span>
</label>
</div>
<label class="label supra" for="user-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="user-days">
<option>0</option>
</select>
<div class="row">
<div class="col">
<label class="label supra" for="user-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="user-months">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<label class="label supra" for="user-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="user-days">
<option>0</option>
</select>
</div>
</div>
</div>
<label class="label supra" for="user-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="user-hours">
<option>0</option>
</select>
</div>
<label class="label supra" for="user-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="user-minutes">
<option>0</option>
</select>
<div class="row">
<div class="col">
<label class="label supra" for="user-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="user-hours">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<label class="label supra" for="user-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="user-minutes">
<option>0</option>
</select>
</div>
</div>
</div>
</div>
<label class="label supra" for="create-label"> {{ .strings.label }}</label>
<input type="text" id="create-label" class="input ~neutral !normal mb-1 mt-half">
<div class="col">
<label class="label supra" for="create-label"> {{ .strings.label }}</label>
<input type="text" id="create-label" class="input ~neutral !normal mb-1 mt-half">
</div>
</div>
<div class="card ~neutral !normal col">
<label class="label supra" for="create-uses">{{ .strings.inviteNumberOfUses }}</label>
@@ -406,6 +492,7 @@
<span class="col sm button ~info !normal center mb-half" id="accounts-announce">{{ .strings.announce }}</span>
<span class="col sm button ~urge !normal center mb-half" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
<span class="col sm button ~warning !normal center mb-half" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
<span class="col sm button ~positive !normal center mb-half" id="accounts-disable-enable">{{ .strings.disable }}</span>
<span class="col sm button ~critical !normal center mb-half" id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
</div>
</div>

View File

@@ -5,9 +5,11 @@
window.invalidPassword = "{{ .strings.reEnterPasswordInvalid }}";
window.URLBase = "{{ .urlBase }}";
window.code = "{{ .code }}";
window.language = "{{ .langName }}";
window.messages = JSON.parse({{ .notifications }});
window.confirmation = {{ .confirmation }};
window.userExpiryEnabled = {{ .userExpiry }};
window.userExpiryMonths = {{ .userExpiryMonths }};
window.userExpiryDays = {{ .userExpiryDays }};
window.userExpiryHours = {{ .userExpiryHours }};
window.userExpiryMinutes = {{ .userExpiryMinutes }};

45
html/password-reset.html Normal file
View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/bundle.css">
{{ template "header.html" . }}
<title>{{ .strings.passwordReset }} - jfa-go</title>
</head>
<body class="section">
{{ if .success }}
<div id="notification-box">
<span id="copy-notification" class="unfocused">{{ .strings.copied }}</span>
</div>
{{ end }}
<div class="page-container">
<div class="card ~neutral !normal mb-1">
<span class="heading mb-1">
{{ if .success }}
{{ .strings.passwordReset }}
{{ else }}
{{ .strings.resetFailed }}
{{ end }}
</span>
<p class="content mb-1">
{{ if .success }}
{{ if .ombiEnabled }}
{{ .strings.youCanLoginOmbi }}
{{ else }}
{{ .strings.youCanLogin }}
{{ end }}
{{ else }}
{{ .strings.tryAgain }}
{{ end }}
</p>
{{ if .success }}
<aside class="aside ~warning">
{{ .strings.changeYourPassword }}
</aside>
<span class="button ~urge !normal full-width center supra p-1 mt-1" id="pin" title="{{ .strings.copy }}">{{ .pin }}</span>
{{ end }}
</div>
<i class="content">{{ .contactMessage }}</i>
</div>
<script src="{{ .urlBase }}/js/pwr.js" type="module"></script>
</body>
</html>

View File

@@ -249,8 +249,8 @@
</label>
<label class="row switch">
<input type="checkbox" id="email-no_username"><span>{{ .lang.Email.useEmailAsUsername }}</span>
<p class="support mb-1">{{ .lang.Email.useEmailAsUsernameNotice }}</p>
</label>
<p class="support mb-1">{{ .lang.Email.useEmailAsUsernameNotice }}</p>
<label class="label">
<span class="mt-half">{{ .lang.Email.fromAddress }}</span>
<input type="email" class="input ~neutral !normal mt-half mb-1" id="email-address" placeholder="mail@jellyf.in">
@@ -266,10 +266,10 @@
</label>
<div>
<label class="row switch pb-1">
<input type="radio" name="email-24h" value="true" checked><span>{{ .lang.Email.time24h }}</span>
<input type="radio" name="email-24h" value="true" checked><span>{{ .lang.Strings.time24h }}</span>
</label>
<label class="row switch pb-1">
<input type="radio" name="email-24h" value="false"><span>{{ .lang.Email.time12h }}</span>
<input type="radio" name="email-24h" value="false"><span>{{ .lang.Strings.time12h }}</span>
</label>
</div>
</div>
@@ -376,7 +376,18 @@
<input type="text" class="input ~neutral !normal mt-half" id="password_resets-watch_directory" placeholder="/config/jellyfin">
<p class="support mb-1">{{ .lang.PasswordResets.pathToJellyfinNotice }}</p>
</label>
<label class="label">
<label class="switch">
<input type="checkbox" id="password_resets-link_reset"><span>{{ .lang.PasswordResets.resetLinks }}</span>
<p class="support mb-1">{{ .lang.PasswordResets.resetLinksNotice }}</p>
</label>
<label class="row label">
<p class="mt-half">{{ .lang.PasswordResets.resetLinksLanguage }}</p>
<div class="select ~neutral !normal mt-half mb-1">
<select id="password_resets-language">
</select>
</div>
</label>
<label class="row label">
<span class="mt-half">{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral !normal mt-half mb-1" id="password_resets-subject" placeholder="{{ .emailLang.PasswordReset.title }}">
</label>

View File

@@ -1,3 +1,5 @@
// +build !external
package main
import (
@@ -11,7 +13,7 @@ const binaryType = "internal"
//go:embed data data/html data/web data/web/css data/web/js
var loFS embed.FS
//go:embed lang/common lang/admin lang/email lang/form lang/setup
//go:embed lang/common lang/admin lang/email lang/form lang/setup lang/pwreset
var laFS embed.FS
var langFS rewriteFS

73
lang.go
View File

@@ -1,11 +1,9 @@
package main
import (
"strings"
)
type langMeta struct {
Name string `json:"name"`
// Language to fall back on if strings are missing. Defaults to en-us.
Fallback string `json:"fallback,omitempty"`
}
type quantityString struct {
@@ -61,6 +59,23 @@ type formLang struct {
validationStringsJSON string
}
type pwrLangs map[string]pwrLang
func (ls *pwrLangs) getOptions() [][2]string {
opts := make([][2]string, len(*ls))
i := 0
for key, lang := range *ls {
opts[i] = [2]string{key, lang.Meta.Name}
i++
}
return opts
}
type pwrLang struct {
Meta langMeta `json:"meta"`
Strings langSection `json:"strings"`
}
type emailLangs map[string]emailLang
func (ls *emailLangs) getOptions() [][2]string {
@@ -80,6 +95,8 @@ type emailLang struct {
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"`
@@ -123,10 +140,29 @@ type langSection map[string]string
type tmpl map[string]string
func templateString(text string, vals tmpl) string {
for key, val := range vals {
text = strings.ReplaceAll(text, "{"+key+"}", val)
start, previousEnd := -1, -1
out := ""
for i := range text {
if text[i] == '{' {
start = i
continue
}
if start != -1 && text[i] == '}' {
varName := text[start+1 : i]
val, ok := vals[varName]
if !ok {
start = -1
continue
}
out += text[previousEnd+1:start] + val
previousEnd = i
start = -1
}
}
return text
if previousEnd != len(text)-1 {
out += text[previousEnd+1:]
}
return out
}
func (el langSection) template(field string, vals tmpl) string {
@@ -136,10 +172,27 @@ func (el langSection) template(field string, vals tmpl) string {
func (el langSection) format(field string, vals ...string) string {
text := el.get(field)
for _, val := range vals {
text = strings.Replace(text, "{n}", val, 1)
start, previous := -1, -3
out := ""
val := 0
for i := range text {
if i == len(text)-2 { // Check if there's even enough space for a {n}
break
}
if text[i:i+3] == "{n}" {
start = i
out += text[previous+3:start] + vals[val]
previous = start
val++
if val == len(vals) {
break
}
}
}
return text
if previous+2 != len(text)-1 {
out += text[previous+3:]
}
return out
}
func (el langSection) get(field string) string {

View File

@@ -55,7 +55,6 @@
"inviteNoUsersCreated": "Noch keine!",
"inviteUsersCreated": "Erstellte Benutzer",
"inviteNoProfile": "Kein Profil",
"copy": "Kopieren",
"inviteDateCreated": "Erstellt",
"inviteRemainingUses": "Verbleibende Verwendungen",
"inviteNoInvites": "Keine",
@@ -75,7 +74,20 @@
"announce": "Ankündigen",
"subject": "E-Mail-Betreff",
"message": "Nachricht",
"markdownSupported": "Markdown wird unterstützt."
"markdownSupported": "Markdown wird unterstützt.",
"advancedSettings": "Erweiterte Einstellungen",
"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"
},
"notifications": {
"changedEmailAddress": "E-Mail-Adresse von {n} geändert.",
@@ -107,7 +119,12 @@
"errorSendWelcomeEmail": "Fehler beim Senden der Willkommens-E-Mail (überprüfe die Konsole/Logs)",
"saveEmail": "E-Mail gespeichert.",
"errorSaveEmail": "Fehler beim Speichern der E-Mail.",
"sentAnnouncement": "Ankündigung gesendet."
"sentAnnouncement": "Ankündigung gesendet.",
"updateApplied": "Aktualisierung angewendet, bitte neu starten.",
"errorApplyUpdate": "Fehler beim Anwenden der Aktualisierung, versuche es manuell.",
"errorCheckUpdate": "Fehler beim Suchen nach Aktualisierungen.",
"updateAvailable": "Eine neue Aktualisierung ist verfügbar, überprüfe die Einstellungen.",
"noUpdatesAvailable": "Keinen neuen Aktualisierungen verfügbar."
},
"quantityStrings": {
"modifySettingsFor": {
@@ -137,6 +154,14 @@
"announceTo": {
"singular": "{n} Benutzer mitteilen",
"plural": "{n} Benutzern mitteilen"
},
"extendExpiry": {
"singular": "Ablaufdatum für {n} Benutzer verlängern",
"plural": "Ablaufdatum für {n} Benutzer verlängern"
},
"extendedExpiry": {
"singular": "Ablaufdatum für {n} Benutzer verlängern.",
"plural": "Ablaufdatum für {n} Benutzer verlängern."
}
}
}

View File

@@ -58,14 +58,39 @@
"inviteNoUsersCreated": "Τίποτα ακόμα!",
"inviteUsersCreated": "Δημιουργηθέντες χρήστες",
"inviteNoProfile": "Κανένα Προφίλ",
"copy": "Αντιγραφή",
"inviteDateCreated": "Δημιουργηθέντα",
"inviteRemainingUses": "Εναπομείναντες χρήσεις",
"inviteNoInvites": "Καμία",
"inviteExpiresInTime": "Λήγει σε {n}",
"notifyEvent": "Ενημέρωση όταν:",
"notifyInviteExpiry": "Στην λήξη",
"notifyUserCreation": "Στην δημιουργία χρήστη"
"notifyUserCreation": "Στην δημιουργία χρήστη",
"variables": "Μεταβλητές",
"preview": "Προεπισκόπηση",
"reset": "Επαναφορά",
"edit": "Επεξεργασία",
"customizeEmails": "Παραμετροποίηση Emails",
"advancedSettings": "Προχωρημένες Ρυθμίσεις",
"customizeEmailsDescription": "Αν δεν θέλετε να ζρησιμοποιήσετε τα πρότυπα email του jfa-go, μπορείτε να δημιουργήσετε τα δικά σας με χρήση Markdown.",
"updates": "Ενημερώσεις",
"update": "Ενημέρωση",
"download": "Λήψη",
"search": "Αναζήτηση",
"inviteDuration": "Διάρκεια Πρόσκλησης",
"enabled": "Ενεργοποιημένο",
"disabled": "Απενεργοποιημένο",
"admin": "Διαχειριστής",
"expiry": "Λήξη",
"userExpiry": "Λήξη Χρήστη",
"userExpiryDescription": "Μετά απο ένα καθορισμένο χρόνο μετά απο κάθε εγγραφή, το jfa-go θα διαγράφει/απενεργοποιεί τον λογαριασμό. Μπορείτε να αλλάξετε αυτή την συμπεριφορά στις ρυθμίσεις.",
"announce": "Ανακοίνωση",
"subject": "Θέμα Email",
"message": "Μήνυμα",
"extendExpiry": "Παράταση λήξης",
"markdownSupported": "Το Markdown υποστυρίζεται.",
"reEnable": "Επανα-ενεργοποίηση",
"disable": "Απενεργοποίηση",
"inviteMonths": "Μήνες"
},
"notifications": {
"changedEmailAddress": "Αλλαγή {n} διεύθυνσεων email.",
@@ -94,7 +119,15 @@
"errorFailureCheckLogs": "Αποτυχία (ελέγξτε κονσόλα/καταγραφές)",
"errorPartialFailureCheckLogs": "Μερική αποτυχία (ελέγξτε κονσόλα/καταγραφές)",
"errorUserCreated": "Αποτυχία δημιουργίας του χρήστη {n}.",
"errorSendWelcomeEmail": "Αποτυχία αποστολής email καλωσορίσματος (ελέγξτε κονσόλα/καταγραφές)"
"errorSendWelcomeEmail": "Αποτυχία αποστολής email καλωσορίσματος (ελέγξτε κονσόλα/καταγραφές)",
"saveEmail": "Το email αποθηκεύτηκε.",
"sentAnnouncement": "Ανακοίνωση εστάλη.",
"updateApplied": "Η ενημέρωση εφαρμόστηκε, παρακαλώ επανεκκινήστε.",
"errorSaveEmail": "Αποτυχία αποθήκευσης του email.",
"errorApplyUpdate": "Αποτυχία εγκατάστασης ενημέρωσης, προσπαθήστε χειροκίνητα.",
"errorCheckUpdate": "Αποτυχία ελέγχου για ενημερώσεις.",
"updateAvailable": "Μια νέα ενημέρωση είναι διαθέσιμη, ελέγξτε τις ρυθμίσεις.",
"noUpdatesAvailable": "Δεν υπάρχουν διαθέσιμες ενημερώσεις."
},
"quantityStrings": {
"modifySettingsFor": {
@@ -120,6 +153,34 @@
"appliedSettings": {
"singular": "Εφαρμογή ρυθμίσεων σε {n} χρήστη.",
"plural": "Εφαρμογή ρυθμίσεων σε {n} χρήστες."
},
"announceTo": {
"singular": "Ανακοίνωση σε {n} χρήστη",
"plural": "Ανακοίνωση σε {n} χρήστες"
},
"extendExpiry": {
"plural": "Επέκταση λήξης σε {n} χρήστες",
"singular": "Επέκταση λήξης σε {n} χρήστη"
},
"extendedExpiry": {
"singular": "Εκτεταμένη λήξη για {n} χρήστη.",
"plural": "Εκτεταμένη λήξη για {n} χρήστες."
},
"disableUsers": {
"singular": "Απενεργοποίηση {n} χρήστη",
"plural": "Απενεργοποίηση {n} χρηστών"
},
"reEnableUsers": {
"singular": "Εκ νέου ενεργοποίηση {n} χρήστη",
"plural": "Εκ νέου ενεργοποίηση {n} χρηστών"
},
"disabledUser": {
"singular": "Απενεργοποιήθηκε {n} χρήστης.",
"plural": "Απενεργοποιήθηκαν {n} χρήστες."
},
"enabledUser": {
"singular": "Εργοποιήθηκε {n} χρήστης.",
"plural": "Εργοποιήθηκαν {n} χρήστες."
}
}
}

5
lang/admin/en-gb.json Normal file
View File

@@ -0,0 +1,5 @@
{
"meta": {
"name": "English (GB)"
}
}

View File

@@ -6,6 +6,7 @@
"invites": "Invites",
"accounts": "Accounts",
"settings": "Settings",
"inviteMonths": "Months",
"inviteDays": "Days",
"inviteHours": "Hours",
"inviteMinutes": "Minutes",
@@ -23,6 +24,8 @@
"date": "Date",
"enabled": "Enabled",
"disabled": "Disabled",
"reEnable": "Re-enable",
"disable": "Disable",
"admin": "Admin",
"updates": "Updates",
"update": "Update",
@@ -46,9 +49,11 @@
"subject": "Email Subject",
"message": "Message",
"variables": "Variables",
"conditionals": "Conditionals",
"preview": "Preview",
"reset": "Reset",
"edit": "Edit",
"donate": "Donate",
"extendExpiry": "Extend expiry",
"customizeEmails": "Customize Emails",
"customizeEmailsDescription": "If you don't want to use jfa-go's email templates, you can create your own using Markdown.",
@@ -81,7 +86,6 @@
"inviteNoUsersCreated": "None yet!",
"inviteUsersCreated": "Created users",
"inviteNoProfile": "No Profile",
"copy": "Copy",
"inviteDateCreated": "Created",
"inviteRemainingUses": "Remaining uses",
"inviteNoInvites": "None",
@@ -136,6 +140,14 @@
"singular": "Delete {n} user",
"plural": "Delete {n} users"
},
"disableUsers": {
"singular": "Disable {n} user",
"plural": "Disable {n} users"
},
"reEnableUsers": {
"singular": "Re-enable {n} user",
"plural": "Re-enable {n} users"
},
"addUser": {
"singular": "Add user",
"plural": "Add users"
@@ -148,6 +160,14 @@
"singular": "Deleted {n} user.",
"plural": "Deleted {n} users."
},
"disabledUser": {
"singular": "Disabled {n} user.",
"plural": "Disabled {n} users."
},
"enabledUser": {
"singular": "Enabled {n} user.",
"plural": "Enabled {n} users."
},
"announceTo": {
"singular": "Announce to {n} user",
"plural": "Announce to {n} users"

187
lang/admin/es-es.json Normal file
View File

@@ -0,0 +1,187 @@
{
"meta": {
"name": "Español(ES)"
},
"strings": {
"invites": "Invitaciones",
"accounts": "Cuentas",
"settings": "Ajustes",
"inviteMonths": "Meses",
"inviteDays": "Días",
"inviteHours": "Horas",
"inviteMinutes": "Minutos",
"inviteNumberOfUses": "Números de usos",
"inviteDuration": "Duración de invitación",
"warning": "Advertencia",
"inviteInfiniteUsesWarning": "Las invitaciones con usos infinitos pueden usarse abusivamente",
"inviteSendToEmail": "Enviar a",
"login": "Acceso",
"logout": "Cerrar sesión",
"create": "Cerrar sesión",
"apply": "Aplicar",
"delete": "Eliminar",
"name": "Nombre",
"date": "Fecha",
"enabled": "Activado",
"disabled": "Desactivado",
"reEnable": "Reactivar",
"disable": "Desactivar",
"admin": "Administrador",
"updates": "Actualizaciones",
"update": "Actualizar",
"download": "Descargar",
"search": "Buscar",
"advancedSettings": "Ajustes avanzados",
"lastActiveTime": "Último activo",
"from": "De",
"user": "Usuario",
"expiry": "Expiración",
"userExpiry": "Caducidad del usuario",
"userExpiryDescription": "Una cantidad específica de tiempo después de cada registro, jfa-go eliminará/deshabilitará la cuenta. Puede cambiar este comportamiento en la configuración.",
"aboutProgram": "Acerca de",
"version": "Versión",
"commitNoun": "Cometer",
"newUser": "Nuevo usuario",
"profile": "Perfil",
"unknown": "Desconocido",
"label": "Etiqueta",
"announce": "Anunciar",
"subject": "Asunto del email",
"message": "Mensaje",
"variables": "Variables",
"preview": "Previsualizar",
"reset": "Reiniciar",
"edit": "Editar",
"extendExpiry": "Extender el vencimiento",
"customizeEmails": "Personalizar emails",
"customizeEmailsDescription": "Si no desea utilizar las plantillas de correo electrónico de jfa-go, puede crear las suyas propias con Markdown.",
"markdownSupported": "Se admite Markdown.",
"modifySettings": "Modificar configuración",
"modifySettingsDescription": "Aplique la configuración de un perfil existente u obténgalos directamente de un usuario.",
"applyHomescreenLayout": "Aplicar el diseño de la pantalla de inicio",
"sendDeleteNotificationEmail": "Enviar notificación a correo",
"sendDeleteNotifiationExample": "Tu cuenta ha sido eliminada.",
"settingsRestart": "Reiniciar",
"settingsRestarting": "Reiniciando…",
"settingsRestartRequired": "Reinicio necesario",
"settingsRestartRequiredDescription": "Es necesario reiniciar para aplicar algunas configuraciones que cambió. ¿Reiniciar ahora o más tarde?",
"settingsApplyRestartLater": "Aplicar, reiniciar más tarde",
"settingsApplyRestartNow": "Aplicar, reiniciar más tarde",
"settingsApplied": "Se aplicó la configuración.",
"settingsRefreshPage": "Actualiza la página en unos segundos.",
"settingsRequiredOrRestartMessage": "Nota: {n} indica un campo obligatorio, {n} indica que los cambios requieren un reinicio.",
"settingsSave": "Guardar",
"ombiUserDefaults": "Valores predeterminados de usuario de Ombi",
"ombiUserDefaultsDescription": "Cree un usuario Ombi y configúrelo, luego selecciónelo a continuación. Sus configuraciones / permisos se almacenarán y aplicarán a los nuevos usuarios de Ombi creados por jfa-go",
"userProfiles": "Perfiles de usuario",
"userProfilesDescription": "Los perfiles se aplican a los usuarios cuando crean una cuenta. Un perfil incluye los derechos de acceso a la biblioteca y el diseño de la pantalla de inicio.",
"userProfilesIsDefault": "Defecto",
"userProfilesLibraries": "Bibliotecas",
"addProfile": "Agregar perfil",
"addProfileDescription": "Cree un usuario de Jellyfin y configúrelo, luego selecciónelo a continuación. Cuando este perfil se aplica a una invitación, se crearán nuevos usuarios con la configuración.",
"addProfileNameOf": "Nombre de perfil",
"addProfileStoreHomescreenLayout": "Diseño de la pantalla de inicio de la tienda",
"inviteNoUsersCreated": "¡Ninguno todavía!",
"inviteUsersCreated": "Usuarios creados",
"inviteNoProfile": "Sin perfil",
"inviteDateCreated": "Creado",
"inviteRemainingUses": "Usos restantes",
"inviteNoInvites": "Ninguno",
"inviteExpiresInTime": "Caduca en {n}",
"notifyEvent": "Notificar en:",
"notifyInviteExpiry": "Al vencimiento",
"notifyUserCreation": "Sobre la creación de usuarios",
"conditionals": "Condicionales"
},
"notifications": {
"changedEmailAddress": "Se cambió la dirección de correo electrónico de {n}.",
"userCreated": "Usuario {n} creado.",
"createProfile": "Perfil creado {n}.",
"saveSettings": "Se guardaron las configuraciones",
"saveEmail": "Correo electrónico guardado.",
"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 no se haya aplicado la configuración.",
"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}",
"errorLoadProfiles": "No se pudieron cargar los perfiles.",
"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.",
"errorChangedEmailAddress": "No se pudo cambiar la dirección de correo electrónico de {n}.",
"errorFailureCheckLogs": "Fallido (ver consola / registros)",
"errorPartialFailureCheckLogs": "Fallo parcial (ver consola / registros)",
"errorUserCreated": "No se pudo crear el usuario {n}.",
"errorSendWelcomeEmail": "No se pudo enviar el correo electrónico de bienvenida (verifique la consola / registros)",
"errorApplyUpdate": "No se pudo aplicar la actualización, intente manualmente.",
"errorCheckUpdate": "No se pudo comprobar la actualización.",
"updateAvailable": "Hay una nueva actualización disponible, verifique la configuración.",
"noUpdatesAvailable": "No hay nuevas actualizaciones disponibles."
},
"quantityStrings": {
"modifySettingsFor": {
"singular": "Modificar la configuración de {n} usuario",
"plural": "Modificar la configuración de {n} usuarios"
},
"deleteNUsers": {
"singular": "Eliminar {n} usuario",
"plural": "Eliminar {n} usuarios"
},
"disableUsers": {
"singular": "Deshabilitar {n} usuario",
"plural": "Inhabilitar {n} usuarios"
},
"reEnableUsers": {
"singular": "Reactivar {n} usuario",
"plural": "Reactivar {n} usuarios"
},
"addUser": {
"singular": "Agregar usuario",
"plural": "Agregar usuarios"
},
"deleteUser": {
"singular": "Borrar usuario",
"plural": "Borrar usuarios"
},
"deletedUser": {
"singular": "Usuario eliminado {n}.",
"plural": "Usuarios eliminados {n}."
},
"disabledUser": {
"singular": "Usuario deshabilitado {n}.",
"plural": "Usuarios deshabilitados {n}."
},
"enabledUser": {
"singular": "Usuario {n} habilitado.",
"plural": "Usuarios {n} habilitados."
},
"announceTo": {
"singular": "Anunciar al usuario {n}",
"plural": "Anunciar a los usuarios {n}"
},
"appliedSettings": {
"singular": "Se aplicó la configuración al usuario {n}.",
"plural": "Se aplicó la configuración a los usuarios {n}."
},
"extendExpiry": {
"singular": "Extender la expiración para el usuario {n}",
"plural": "Extender la expiración para los usuarios {n}"
},
"extendedExpiry": {
"singular": "Caducidad extendida para el usuario {n}.",
"plural": "Caducidad extendida para los usuarios {n}."
}
}
}

View File

@@ -7,6 +7,7 @@
"invites": "Invitations",
"accounts": "Comptes",
"settings": "Réglages",
"inviteMonths": "Mois",
"inviteDays": "Jours",
"inviteHours": "Heures",
"inviteMinutes": "Minutes",
@@ -56,7 +57,6 @@
"inviteNoUsersCreated": "Aucun pour l'instant !",
"inviteUsersCreated": "Utilisateurs créer",
"inviteNoProfile": "Aucun profil",
"copy": "Copier",
"inviteDateCreated": "Créer",
"inviteRemainingUses": "Utilisations restantes",
"inviteNoInvites": "Aucune",
@@ -76,7 +76,21 @@
"preview": "Aperçu",
"reset": "Réinitialiser",
"edit": "Éditer",
"customizeEmails": "Personnaliser les e-mails"
"customizeEmails": "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",
"update": "Mise à jour",
"download": "Téléchargement",
"search": "Recherche",
"conditionals": "Conditions"
},
"notifications": {
"changedEmailAddress": "Adresse e-mail modifiée de {n}.",
@@ -108,7 +122,8 @@
"errorSendWelcomeEmail": "Echec lors de l'envoi du mail de bienvenue (vérifier la console/les journaux)",
"sentAnnouncement": "Annonce envoyée.",
"saveEmail": "Email enregistré.",
"errorSaveEmail": "Échec de l'enregistrement de l'e-mail."
"errorSaveEmail": "Échec de l'enregistrement de l'e-mail.",
"updateApplied": "Mise à jour appliquée, veuillez redémarrer."
},
"quantityStrings": {
"modifySettingsFor": {

View File

@@ -58,7 +58,6 @@
"inviteNoUsersCreated": "Belum ada!",
"inviteUsersCreated": "Pengguna yang telah dibuat",
"inviteNoProfile": "Tidak ada profil",
"copy": "Salin",
"inviteDateCreated": "Dibuat",
"inviteRemainingUses": "Penggunaan yang tersisa",
"inviteNoInvites": "Tidak ada",

View File

@@ -55,7 +55,6 @@
"inviteNoUsersCreated": "Nog geen!",
"inviteUsersCreated": "Aangemaakte gebruikers",
"inviteNoProfile": "Geen profiel",
"copy": "Kopiëer",
"inviteDateCreated": "Aangemaakt",
"inviteRemainingUses": "Resterend aantal keer te gebruiken",
"inviteNoInvites": "Geen",
@@ -88,7 +87,11 @@
"update": "Bijwerken",
"download": "Download",
"search": "Zoeken",
"advancedSettings": "Geavanceerde instellingen"
"advancedSettings": "Geavanceerde instellingen",
"inviteMonths": "Maanden",
"reEnable": "Opnieuw inschakelen",
"disable": "Uitschakelen",
"conditionals": "Voorwaarden"
},
"notifications": {
"changedEmailAddress": "E-mailadres van {n} gewijzigd.",
@@ -163,6 +166,22 @@
"extendedExpiry": {
"singular": "Verloop uitgesteld voor {n} gebruiker.",
"plural": "Verloop uitgesteld voor {n} gebruikers."
},
"disableUsers": {
"singular": "Schakel {n} gebruiker uit",
"plural": "Schakel {n} gebruikers uit"
},
"reEnableUsers": {
"singular": "Schakel {n} gebruiker opnieuw in",
"plural": "Schakel {n} gebruikers opnieuw in"
},
"disabledUser": {
"singular": "{n} gebruiker uitgeschakeld.",
"plural": "{n} gebruikers uitgeschakeld."
},
"enabledUser": {
"singular": "{n} gebruiker ingeschakeld.",
"plural": "{n} gebruikers ingeschakeld."
}
}
}

View File

@@ -56,7 +56,6 @@
"inviteNoUsersCreated": "Nenhum ainda!",
"inviteUsersCreated": "Usuários criado",
"inviteNoProfile": "Sem Perfil",
"copy": "Copiar",
"inviteDateCreated": "Criado",
"inviteRemainingUses": "Uso restantes",
"inviteNoInvites": "Nenhum",
@@ -81,13 +80,18 @@
"inviteDuration": "Duração do Convite",
"enabled": "Habilitado",
"admin": "Admin",
"expiry": "Expiração",
"expiry": "Expira",
"userExpiry": "Vencimento do Usuário",
"extendExpiry": "Extender o vencimento",
"updates": "Atualizações",
"update": "Atualizar",
"download": "Download",
"search": "Procurar"
"search": "Procurar",
"advancedSettings": "Configurações Avançada",
"inviteMonths": "Meses",
"reEnable": "Reativar",
"disable": "Desativar",
"conditionals": "Condicionais"
},
"notifications": {
"changedEmailAddress": "Endereço de e-mail alterado de {n}.",
@@ -162,6 +166,22 @@
"extendedExpiry": {
"plural": "Extender o vencimento para {n} usuários.",
"singular": "Extender vencimento para {n}."
},
"disableUsers": {
"singular": "Desativar {n} usuário",
"plural": "Desativar {n} usuários"
},
"reEnableUsers": {
"singular": "Reativar {n} usuário",
"plural": "Reativar {n} usuários"
},
"disabledUser": {
"singular": "{n} Usuário desativado.",
"plural": "{n} usuários desativado."
},
"enabledUser": {
"singular": "{n} Usuário habilitado.",
"plural": "{n} Usuários habilitado."
}
}
}

View File

@@ -68,7 +68,6 @@
"inviteNoUsersCreated": "Ingen än!",
"inviteUsersCreated": "Skapade användare",
"inviteNoProfile": "Ingen profil",
"copy": "Kopiera",
"inviteDateCreated": "Skapad",
"inviteRemainingUses": "Återstående användningar",
"inviteNoInvites": "Ingen",

View File

@@ -8,8 +8,12 @@
"password": "Passwort",
"emailAddress": "E-Mail-Adresse",
"submit": "Absenden",
"success": "Erfolg",
"success": "Erfolgreich",
"error": "Fehler",
"theme": "Thema"
"copy": "Kopieren",
"theme": "Thema",
"time24h": "24h-Format",
"time12h": "12h-Format",
"copied": "Kopiert"
}
}

View File

@@ -10,6 +10,10 @@
"submit": "Καταχώρηση",
"success": "Επιτυχία",
"error": "Σφάλμα",
"theme": "Θέμα"
"copy": "Αντιγραφή",
"theme": "Θέμα",
"time24h": "24 Ώρες",
"time12h": "12 Ώρες",
"copied": "Αντιγράφηκε"
}
}

5
lang/common/en-gb.json Normal file
View File

@@ -0,0 +1,5 @@
{
"meta": {
"name": "English (GB)"
}
}

View File

@@ -10,6 +10,10 @@
"submit": "Submit",
"success": "Success",
"error": "Error",
"copy": "Copy",
"copied": "Copied",
"time24h": "24h Time",
"time12h": "12h Time",
"theme": "Theme"
}
}

19
lang/common/es-es.json Normal file
View File

@@ -0,0 +1,19 @@
{
"meta": {
"name": "Español(ES)"
},
"strings": {
"username": "Nombre de usuario",
"password": "Contraseña",
"emailAddress": "Dirección de correo electrónico",
"name": "Nombre",
"submit": "Enviar",
"success": "Éxito",
"error": "Error",
"copy": "Copiar",
"copied": "Copiado",
"time24h": "24 horas",
"time12h": "24 horas",
"theme": "Tema"
}
}

View File

@@ -11,6 +11,9 @@
"submit": "Soumettre",
"success": "Succès",
"error": "Erreur",
"copy": "Copier",
"time24h": "Temps 24h",
"time12h": "Temps 12h",
"theme": "Thème"
}
}

View File

@@ -10,6 +10,9 @@
"submit": "Submit",
"success": "Sukses",
"error": "Error",
"copy": "Salin",
"time24h": "Waktu 24 jam",
"time12h": "Waktu 12 jam",
"theme": "Tema"
}
}

View File

@@ -10,6 +10,10 @@
"submit": "Verstuur",
"success": "Success",
"error": "Fout",
"theme": "Thema"
"copy": "Kopiëer",
"theme": "Thema",
"time24h": "24u-formaat",
"time12h": "12u-formaat",
"copied": "Gekopieerd"
}
}

View File

@@ -10,6 +10,10 @@
"submit": "Enviar",
"success": "Sucesso",
"error": "Erro",
"theme": "Tema"
"copy": "Copiar",
"theme": "Tema",
"time24h": "Horário 24h",
"time12h": "Horário 12h",
"copied": "Copiado"
}
}

View File

@@ -10,6 +10,9 @@
"submit": "Skicka",
"success": "Lyckades",
"error": "Fel",
"copy": "Kopiera",
"time24h": "24 timmarsklocka",
"time12h": "12 timmarsklocka",
"theme": "Tema"
}
}

View File

@@ -4,6 +4,7 @@
},
"strings": {
"ifItWasNotYou": "Wenn du das nicht warst, ignoriere bitte diese E-Mail.",
"reason": "Grund",
"helloUser": "Hallo {username},"
},
"userCreated": {
@@ -31,7 +32,6 @@
"userDeleted": {
"title": "Dein Konto wurde gelöscht - Jellyfin",
"yourAccountWasDeleted": "Dein Jellyfin-Konto wurde gelöscht.",
"reason": "Grund",
"name": "Benutzerlöschung"
},
"inviteEmail": {
@@ -55,5 +55,11 @@
"clickBelow": "Klicke den untenstehenden Link, um deine E-Mail-Adresse zu bestätigen, und fange an, Jellyfin zu benutzen.",
"confirmEmail": "E-Mail bestätigen",
"name": "Bestätigungs-E-Mail"
},
"userExpired": {
"name": "Benutzer Ablaufdatum",
"title": "Dein Konto ist abgelaufen - Jellyfin",
"yourAccountHasExpired": "Dein Konto ist abgelaufen.",
"contactTheAdmin": "Kontaktiere den Administrator für weitere Informationen."
}
}

View File

@@ -4,31 +4,36 @@
},
"strings": {
"ifItWasNotYou": "Αν δεν ήσασταν εσείς, παρακαλώ αγνοήστε αυτό το email.",
"reason": "Λόγος",
"helloUser": "Γεία σου {username},"
},
"userCreated": {
"title": "Σημείωση: Δημιουργήθηκε χρήστης",
"aUserWasCreated": "Δημιουργήθηκε ένας χρήστης χρησιμοποιώντας τον κωδικό {code}.",
"time": "Ώρα",
"notificationNotice": "Σημείωση: Τα email ειδοποιήσεων μπορούν να ενεργοποιηθούν στον πίνακα ελέγχου διαχειριστή."
"notificationNotice": "Σημείωση: Τα email ειδοποιήσεων μπορούν να ενεργοποιηθούν στον πίνακα ελέγχου διαχειριστή.",
"name": "Δημιουργία χρήστη"
},
"inviteExpiry": {
"title": "Σημείωση: Η πρόσκληση έληξε",
"inviteExpired": "Η πρόσκληση έληξε.",
"expiredAt": "Ο κωδικός {code} έληξε στις {time}.",
"notificationNotice": "Σημείωση: Τα email ειδοποιήσεων μπορούν να ενεργοποιηθούν στον πίνακα ελέγχου διαχειριστή."
"notificationNotice": "Σημείωση: Τα email ειδοποιήσεων μπορούν να ενεργοποιηθούν στον πίνακα ελέγχου διαχειριστή.",
"name": "Λήξη πρόσκλησης"
},
"passwordReset": {
"title": "Ζητήθηκε επαναφορά κωδικού πρόσβασης - Jellyfin",
"someoneHasRequestedReset": "Κάποιος ζήτησε πρόσφατα επαναφορά κωδικού πρόσβασης στο Jellyfin.",
"ifItWasYou": "Εάν ήσασταν εσείς, εισαγάγετε το πιν στο πεδίο.",
"codeExpiry": "Ο κωδικός θα λήξει στις {date}, στις {time} UTC, το οποίο είναι σε {expiresInMinutes}.",
"pin": "PIN"
"pin": "PIN",
"name": "Επαναφορά κωδικού πρόσβασης",
"ifItWasYouLink": "Εάν ήσασταν εσείς, κάντε κλικ στον παρακάτω σύνδεσμο."
},
"userDeleted": {
"title": "Ο λογαριασμός σας διαγράφηκε - Jellyfin",
"yourAccountWasDeleted": "Ο λογαριασμός σας Jellyfin διαγράφηκε.",
"reason": "Λόγος"
"name": "Διαγραφή χρήστη"
},
"inviteEmail": {
"title": "Πρόσκληση - Jellyfin",
@@ -36,17 +41,37 @@
"youHaveBeenInvited": "Έχετε προσκληθεί στο Jellyfin.",
"toJoin": "Για να συμμετέχετε, ακολουθήστε τον παρακάτω σύνδεσμο.",
"inviteExpiry": "Αυτή η πρόσκληση θα λήξει στις {date} στις {time}, που είναι σε {expiresInMinutes}, οπότε ενεργήστε γρήγορα.",
"linkButton": "Ρυθμίστε τον λογαριασμό σας"
"linkButton": "Ρυθμίστε τον λογαριασμό σας",
"name": "Email πρόσκλησης"
},
"welcomeEmail": {
"title": "Καλώς ήλθατε στο Jellyfin",
"welcome": "Καλώς ήλθατε στο Jellyfin!",
"youCanLoginWith": "Μπορείτε να συνδεθείτε με τα παρακάτω στοιχεία",
"jellyfinURL": "URL"
"jellyfinURL": "URL",
"name": "Email καλωσορίσματος",
"yourAccountWillExpire": "Ο λογαριασμός σας θα λήξει στις {date}."
},
"emailConfirmation": {
"title": "Επιβεβαιώστε το email σας - Jellyfin",
"clickBelow": "Κάντε κλικ στον παρακάτω σύνδεσμο για να επιβεβαιώσετε τη διεύθυνση email σας και ξεκινήστε να χρησιμοποιείτε το Jellyfin.",
"confirmEmail": "Επιβεβαίωση Email"
"confirmEmail": "Επιβεβαίωση Email",
"name": "Email επιβεβαίωσης"
},
"userExpired": {
"name": "Λήξη Χρήστη",
"title": "Ο λογαριασμός σας έληξε - Jellyfin",
"yourAccountHasExpired": "Ο λογαριασμός σας έχει λήξει.",
"contactTheAdmin": "Επικοινωνήστε με τον διαχειριστή για περισσότερες πληροφορίες."
},
"userDisabled": {
"name": "Ο χρήστης απενεργοποιήθηκε",
"title": "Ο λογαριασμός σας έχει απενεργοποιηθεί - Jellyfin",
"yourAccountWasDisabled": "Ο λογαριασμός σας απενεργοποιήθηκε."
},
"userEnabled": {
"title": "Ο λογαριασμός σας έχει ενεργοποιηθεί ξανά - Jellyfin",
"name": "Ο χρήστης ενεργοποιήθηκε",
"yourAccountWasEnabled": "Ο λογαριασμός σας ενεργοποιήθηκε εκ νέου."
}
}

5
lang/email/en-gb.json Normal file
View File

@@ -0,0 +1,5 @@
{
"meta": {
"name": "English (GB)"
}
}

View File

@@ -4,7 +4,8 @@
},
"strings": {
"ifItWasNotYou": "If this wasn't you, please ignore this email.",
"helloUser": "Hi {username},"
"helloUser": "Hi {username},",
"reason": "Reason"
},
"userCreated": {
"name": "User creation",
@@ -25,14 +26,24 @@
"title": "Password reset requested - Jellyfin",
"someoneHasRequestedReset": "Someone has recently requested a password reset on Jellyfin.",
"ifItWasYou": "If this was you, enter the pin below into the prompt.",
"ifItWasYouLink": "If this was you, click the link below.",
"codeExpiry": "The code will expire on {date}, at {time} UTC, which is in {expiresInMinutes}.",
"pin": "PIN"
},
"userDeleted": {
"name": "User deletion",
"title": "Your account was deleted - Jellyfin",
"yourAccountWasDeleted": "Your Jellyfin account was deleted.",
"reason": "Reason"
"yourAccountWasDeleted": "Your Jellyfin account was deleted."
},
"userDisabled": {
"name": "User disabled",
"title": "Your account has been disabled - Jellyfin",
"yourAccountWasDisabled": "Your account was disabled."
},
"userEnabled": {
"name": "User enabled",
"title": "Your account has been re-enabled - Jellyfin",
"yourAccountWasEnabled": "Your account was re-enabled."
},
"inviteEmail": {
"name": "Invite email",
@@ -48,6 +59,7 @@
"title": "Welcome to Jellyfin",
"welcome": "Welcome to Jellyfin!",
"youCanLoginWith": "You can login with the details below",
"yourAccountWillExpire": "Your account will expire on {date}.",
"jellyfinURL": "URL"
},
"emailConfirmation": {

77
lang/email/es-es.json Normal file
View File

@@ -0,0 +1,77 @@
{
"meta": {
"name": "Español(ES)"
},
"strings": {
"ifItWasNotYou": "Si no fue usted, ignore este correo electrónico.",
"helloUser": "Hola {username},",
"reason": "Razón"
},
"userCreated": {
"name": "Creación de usuarios",
"title": "Noticia: Usuario creado",
"aUserWasCreated": "Se creó un usuario con el código {code}.",
"time": "Hora",
"notificationNotice": "Nota: los correos electrónicos de notificación se pueden alternar en el panel de administración."
},
"inviteExpiry": {
"name": "Vencimiento de la invitación",
"title": "Aviso: Invitación caducada",
"inviteExpired": "Invitación caducada.",
"expiredAt": "El código {code} venció a las {time}.",
"notificationNotice": "Nota: Los correos electrónicos de notificación se pueden alternar en el panel de administración."
},
"passwordReset": {
"name": "Restablecimiento de contraseña",
"title": "Solicitud de restablecimiento de contraseña - Jellyfin",
"someoneHasRequestedReset": "Alguien ha solicitado recientemente un restablecimiento de contraseña en Jellyfin.",
"ifItWasYou": "Si era usted, ingrese el pin a continuación en el mensaje.",
"ifItWasYouLink": "Si fue usted, haga clic en el enlace de abajo.",
"codeExpiry": "El código vencerá el {date}, a las {time} UTC, que está en {expiresInMinutes}.",
"pin": "PIN"
},
"userDeleted": {
"name": "Eliminación de usuario",
"title": "Su cuenta fue eliminada - Jellyfin",
"yourAccountWasDeleted": "Su cuenta de Jellyfin fue eliminada."
},
"userDisabled": {
"name": "Usuario deshabilitado",
"title": "Su cuenta ha sido deshabilitada - Jellyfin",
"yourAccountWasDisabled": "Su cuenta fue inhabilitada."
},
"userEnabled": {
"name": "Usuario habilitado",
"title": "Su cuenta ha sido reactivada - Jellyfin",
"yourAccountWasEnabled": "Su cuenta se volvió a habilitar."
},
"inviteEmail": {
"name": "Correo electrónico",
"title": "Invitar - Jellyfin",
"hello": "Hola",
"youHaveBeenInvited": "Has sido invitado a Jellyfin.",
"toJoin": "Para unirse, siga el enlace a continuación.",
"inviteExpiry": "Esta invitación vencerá el {date} a las {time}, que está en {expiresInMinutes}, así que regístrese cuanto antes.",
"linkButton": "Configurar tu cuenta"
},
"welcomeEmail": {
"name": "Correo de bienvenida",
"title": "Bienvenido a Jellyfin",
"welcome": "¡Bienvenido a Jellyfin!",
"youCanLoginWith": "Puede iniciar sesión con los detalles a continuación",
"yourAccountWillExpire": "Su cuenta vencerá el {date}.",
"jellyfinURL": "URL"
},
"emailConfirmation": {
"name": "Email de confirmación",
"title": "Confirma tu correo electrónico - Jellyfin",
"clickBelow": "Haga clic en el enlace de abajo para confirmar su dirección de correo electrónico y comenzar a usar Jellyfin.",
"confirmEmail": "Confirmar correo electrónico"
},
"userExpired": {
"name": "Caducidad del usuario",
"title": "Tu cuenta ha caducado - Jellyfin",
"yourAccountHasExpired": "Tu cuenta ha expirado.",
"contactTheAdmin": "Comuníquese con el administrador para obtener más información."
}
}

View File

@@ -5,6 +5,7 @@
},
"strings": {
"ifItWasNotYou": "Si ce n'était pas toi, tu peux ignorer ce mail.",
"reason": "Motif",
"helloUser": "Salut {username},"
},
"userCreated": {
@@ -27,12 +28,12 @@
"ifItWasYou": "Si c'était bien toi, renseigne le code PIN en dessous.",
"codeExpiry": "Ce code expirera le {date}, à {time} UTC, soit dans {expiresInMinutes}.",
"pin": "PIN",
"name": "Réinitialisation du mot de passe"
"name": "Réinitialisation du mot de passe",
"ifItWasYouLink": "Si c'était bien toi, clique sur le lien en dessous."
},
"userDeleted": {
"title": "Ton compte a été désactivé - Jellyfin",
"yourAccountWasDeleted": "Ton compte Jellyfin a été supprimé.",
"reason": "Motif",
"name": "Suppression de l'utilisateur"
},
"inviteEmail": {
@@ -40,7 +41,7 @@
"hello": "Salut",
"youHaveBeenInvited": "Tu as été invité à rejoindre Jellyfin.",
"toJoin": "Pour continuer, suis le lien en dessous.",
"inviteExpiry": "L'invitation expirera le {date}, à {time}, soit dans {expiresInMinutes}, alors fais vite !",
"inviteExpiry": "L'invitation expirera le {date}, à {time}, soit dans {expiresInMinutes}, alors fais vite.",
"linkButton": "Lien",
"name": "Courriel d'invitation"
},
@@ -49,12 +50,29 @@
"title": "Bienvenue sur Jellyfin",
"welcome": "Bienvenue sur Jellyfin !",
"jellyfinURL": "URL",
"name": "Courriel de bienvenue"
"name": "Courriel de bienvenue",
"yourAccountWillExpire": "Ton compte expirera le {date}."
},
"emailConfirmation": {
"title": "Confirmez votre adresse e-mail - Jellyfin",
"clickBelow": "Clique sur le lien ci-dessous pour confirmer ton adresse e-mail et commencer à utiliser Jellyfin.",
"confirmEmail": "Confirmer l'adresse e-mail",
"name": "Email de confirmation"
},
"userExpired": {
"contactTheAdmin": "Contacte l'administrateur pour plus d'informations.",
"name": "Utilisateur expiré",
"title": "Ton compte a expiré - Jellyfin",
"yourAccountHasExpired": "Ton compte a expiré."
},
"userDisabled": {
"name": "Utilisateur désactivé",
"title": "Ton compte a été désactivé - Jellyfin",
"yourAccountWasDisabled": "Ton compte a été désactivé."
},
"userEnabled": {
"name": "Utilisateur activé",
"title": "Ton compte a été ré-activé - Jellyfin",
"yourAccountWasEnabled": "Ton compte a été ré-activé."
}
}

View File

@@ -4,6 +4,7 @@
},
"strings": {
"ifItWasNotYou": "Jika ini bukan kamu, silahkan mengabaikan email ini.",
"reason": "Alasan",
"helloUser": "Halo {username},"
},
"userCreated": {
@@ -31,7 +32,6 @@
"userDeleted": {
"title": "Akun anda telah dihapus - Jellyfin",
"yourAccountWasDeleted": "Akun Jellyfin anda telah dihapus.",
"reason": "Alasan",
"name": "Penghapusan pengguna"
},
"inviteEmail": {

View File

@@ -4,7 +4,8 @@
},
"strings": {
"ifItWasNotYou": "Se non sei stato tu, puoi ignorare questa email.",
"helloUser": "Ciao {username},"
"helloUser": "Ciao {username},",
"reason": "Motivo"
},
"userCreated": {
"title": "Nota: Utente creato",
@@ -27,8 +28,7 @@
},
"userDeleted": {
"title": "Il tuo account è stato eliminato - Jellyfin",
"yourAccountWasDeleted": "Il tuo account di Jellyfin è stato eliminato.",
"reason": "Motivo"
"yourAccountWasDeleted": "Il tuo account di Jellyfin è stato eliminato."
},
"inviteEmail": {
"title": "Invita - Jellyfin",

View File

@@ -4,6 +4,7 @@
},
"strings": {
"ifItWasNotYou": "Als jij dit niet was, negeer dan alsjeblieft deze email.",
"reason": "Reden",
"helloUser": "Hoi {username},"
},
"userCreated": {
@@ -23,15 +24,15 @@
"passwordReset": {
"title": "Wachtwoordreset aangevraagd - Jellyfin",
"someoneHasRequestedReset": "Iemand heeft recentelijk een wachtwoordreset aangevraagd in Jellyfin.",
"ifItWasYou": "Als jij dit was, voor dan onderstaande PIN in.",
"ifItWasYou": "Als jij dit was, voer dan onderstaande PIN in.",
"codeExpiry": "De code verloopt op {date}, op {time} UTC, dat is over {expiresInMinutes}.",
"pin": "PIN",
"name": "Wachtwoordreset"
"name": "Wachtwoordreset",
"ifItWasYouLink": "Als jij dit was, klik dan op onderstaande link."
},
"userDeleted": {
"title": "Je account is verwijderd - Jellyfin",
"yourAccountWasDeleted": "Je Jellyfin account is verwijderd.",
"reason": "Reden",
"name": "Gebruiker verwijderd"
},
"inviteEmail": {
@@ -48,7 +49,8 @@
"welcome": "Welkom bij Jellyfin!",
"youCanLoginWith": "Je kunt inloggen met onderstaande gegevens",
"jellyfinURL": "URL",
"name": "Welkomste-mail"
"name": "Welkomste-mail",
"yourAccountWillExpire": "Je account verloopt op {date}."
},
"emailConfirmation": {
"title": "Bevestig je e-mailadres - Jellyfin",
@@ -61,5 +63,15 @@
"title": "Je account is verlopen - Jellyfin",
"yourAccountHasExpired": "Je account is verlopen.",
"contactTheAdmin": "Neem contact op met de beheerder voor meer info."
},
"userDisabled": {
"title": "Je account is uitgeschakeld - Jellyfin",
"name": "Gebruiker uitgeschakeld",
"yourAccountWasDisabled": "Je account is uitgeschakeld."
},
"userEnabled": {
"yourAccountWasEnabled": "Je account is opnieuw ingeschakeld.",
"name": "Gebruiker ingeschakeld",
"title": "Je account is opnieuw ingeschakeld - Jellyfin"
}
}

View File

@@ -4,6 +4,7 @@
},
"strings": {
"ifItWasNotYou": "Se não foi você, ignore este e-mail.",
"reason": "Razão",
"helloUser": "Ola {username},"
},
"userCreated": {
@@ -26,12 +27,12 @@
"ifItWasYou": "Se foi você, insira o PIN abaixo.",
"codeExpiry": "O código irá expirar em {date}, ás {time}, que está em {expiresInMinutes}.",
"pin": "PIN",
"name": "Redefinir senha"
"name": "Redefinir senha",
"ifItWasYouLink": "Se foi você, clique no link abaixo."
},
"userDeleted": {
"title": "Sua conta foi excluída - Jellyfin",
"yourAccountWasDeleted": "Sua conta Jellyfin foi excluída.",
"reason": "Razão",
"name": "Exclusão do usuário"
},
"inviteEmail": {
@@ -48,7 +49,8 @@
"welcome": "Bem vindo ao Jellyfin!",
"youCanLoginWith": "Abaixo está os detalhes para fazer o login",
"jellyfinURL": "URL",
"name": "Email de Boas vindas"
"name": "Email de Boas vindas",
"yourAccountWillExpire": "Sua conta irá expirar em {date}."
},
"emailConfirmation": {
"title": "Confirme seu email - Jellyfin",
@@ -61,5 +63,15 @@
"title": "Sua conta expirou - Jellyfin",
"yourAccountHasExpired": "Sua conta expirou.",
"contactTheAdmin": "Entre em contato com administrador para mais informações."
},
"userDisabled": {
"name": "Usuário desativado",
"title": "Sua conta foi desativada - Jellyfin",
"yourAccountWasDisabled": "Sua conta foi desativada."
},
"userEnabled": {
"title": "Sua conta foi reativada - Jellyfin",
"name": "Usuário ativado",
"yourAccountWasEnabled": "Sua conta foi reativada."
}
}

View File

@@ -4,7 +4,8 @@
},
"strings": {
"ifItWasNotYou": "Om detta inte var du, ignorera det här e-postmeddelandet.",
"helloUser": "Hej {användarnamn},"
"helloUser": "Hej {username},",
"reason": "Anledning"
},
"userCreated": {
"name": "Användarskapande",
@@ -31,8 +32,7 @@
"userDeleted": {
"name": "Radering av användare",
"title": "Ditt konto raderades - Jellyfin",
"yourAccountWasDeleted": "Ditt Jellyfin-konto raderades.",
"reason": "Anledning"
"yourAccountWasDeleted": "Ditt Jellyfin-konto raderades."
},
"inviteEmail": {
"name": "Inbjudnings e-post",

View File

@@ -13,10 +13,11 @@
"reEnterPasswordInvalid": "Passwörter stimmen nicht überein.",
"createAccountButton": "Konto erstellen",
"passwordRequirementsHeader": "Passwortanforderungen",
"successHeader": "Erfolg!",
"successHeader": "Erfolgreich!",
"successContinueButton": "Weiter",
"confirmationRequired": "E-Mail-Bestätigung erforderlich",
"confirmationRequiredMessage": "Bitte überprüfe dein Posteingang und bestätige deine E-Mail-Adresse."
"confirmationRequiredMessage": "Bitte überprüfe dein Posteingang und bestätige deine E-Mail-Adresse.",
"yourAccountIsValidUntil": "Dein Konto wird bis zum {date} gültig sein."
},
"validationStrings": {
"length": {

View File

@@ -16,7 +16,8 @@
"successHeader": "Επιτυχία!",
"successContinueButton": "Συνέχεια",
"confirmationRequired": "Απαιτείται επιβεβαίωση Email",
"confirmationRequiredMessage": "Παρακαλώ ελέγξτε το email σας για να επιβεβαιώσετε την διεύθυνση σας ."
"confirmationRequiredMessage": "Παρακαλώ ελέγξτε το email σας για να επιβεβαιώσετε την διεύθυνση σας .",
"yourAccountIsValidUntil": "Ο λογαριασμός σου θα ισχύει μέχρι {date}."
},
"notifications": {
"errorUserExists": "Ο χρήστης υπάρχει ήδη.",

5
lang/form/en-gb.json Normal file
View File

@@ -0,0 +1,5 @@
{
"meta": {
"name": "English (GB)"
}
}

48
lang/form/es-es.json Normal file
View File

@@ -0,0 +1,48 @@
{
"meta": {
"name": "Español (ES)"
},
"strings": {
"pageTitle": "Crear cuenta de Jellyfin",
"createAccountHeader": "Crear una cuenta",
"accountDetails": "Detalles",
"emailAddress": "Correo electrónico",
"username": "Nombre de usuario",
"password": "Contraseña",
"reEnterPassword": "Rescriba su contraseña",
"reEnterPasswordInvalid": "Las contraseñas no son coincidentes.",
"createAccountButton": "Crear una cuenta",
"passwordRequirementsHeader": "Requisitos de contraseña",
"successHeader": "¡Éxito!",
"successContinueButton": "Continuar",
"confirmationRequired": "Se requiere confirmación por correo electrónico",
"confirmationRequiredMessage": "Revise la bandeja de entrada de su correo electrónico para verificar su dirección.",
"yourAccountIsValidUntil": "Su cuenta será válida hasta el {date}."
},
"notifications": {
"errorUserExists": "El usuario ya existe.",
"errorInvalidCode": "Código de invitación no es válido."
},
"validationStrings": {
"length": {
"singular": "Debe tener al menos {n} carácter",
"plural": "Debe tener al menos {n} caracteres"
},
"uppercase": {
"singular": "Debe tener al menos {n} caracteres en mayúscula",
"plural": "Debe tener al menos {n} caracteres en mayúscula"
},
"lowercase": {
"singular": "Debe tener al menos {n} caracteres en minúscula",
"plural": "Debe tener al menos {n} caracteres en minúscula"
},
"number": {
"singular": "Debe tener al menos {n} número",
"plural": "Debe tener al menos {n} números"
},
"special": {
"singular": "Debe tener al menos {n} carácter especial",
"plural": "Debe tener al menos {n} caracteres especiales"
}
}
}

View File

@@ -17,7 +17,7 @@
"successContinueButton": "Continuar",
"confirmationRequired": "Necessária confirmação de e-mail",
"confirmationRequiredMessage": "Verifique sua caixa de email para finalizar o cadastro.",
"yourAccountIsValidUntil": "Sua conta é válida até {data}."
"yourAccountIsValidUntil": "Sua conta é válida até {date}."
},
"notifications": {
"errorUserExists": "Esse usuário já existe.",

13
lang/pwreset/en-us.json Normal file
View File

@@ -0,0 +1,13 @@
{
"meta": {
"name": "English (US)"
},
"strings": {
"passwordReset": "Password reset",
"resetFailed": "Password reset failed",
"tryAgain": "Please try again.",
"youCanLogin": "You can now log in with the below code as your password.",
"youCanLoginOmbi": "You can now log in to Jellyfin & Ombi with the below code as your password.",
"changeYourPassword": "Make sure to change your password after you log in."
}
}

12
lang/pwreset/es-es.json Normal file
View File

@@ -0,0 +1,12 @@
{
"meta": {
"name": "Español (ES)"
},
"strings": {
"passwordReset": "Cambiar contraseña",
"resetFailed": "Error al cambiar contraseña",
"tryAgain": "Por favor intente nuevamente.",
"youCanLogin": "Ahora puedes logearte con el codigo como contraseña.",
"changeYourPassword": "Recuerda cambiar tu contraseña luego de iniciar sesión."
}
}

View File

@@ -31,9 +31,9 @@
"language": {
"title": "Sprache",
"description": "Gemeinschaftsübersetzungen sind für die meisten Teile von jfa-go verfügbar. Du kannst unten die Standardsprachen auswählen, aber Benutzer können dies immer noch ändern, wenn sie wollen. Wenn du helfen willst zu übersetzen, melde dich bei {n} an, um anzufangen, etwas beizutragen!",
"defaultAdminLang": "Standardsprache Admin",
"defaultFormLang": "Standardsprache Kontoerstellung",
"defaultEmailLang": "Standardsprache E-Mail"
"defaultAdminLang": "Admin Standardsprache",
"defaultFormLang": "Kontoerstellung Standardsprache",
"defaultEmailLang": "E-Mail Standardsprache"
},
"general": {
"title": "Allgemein",
@@ -82,8 +82,6 @@
"senderName": "Absendername",
"dateFormat": "Datumsformat",
"dateFormatNotice": "Datum folgt dem strftime-Format. Für weitere Informationen, besuche {n}.",
"time24h": "24h-Format",
"time12h": "12h-Format",
"encryption": "Verschlüsselung",
"mailgunApiURL": "API-URL"
},
@@ -125,5 +123,12 @@
"successMessageNotice": "Wird angezeigt, wenn ein Benutzer sein Konto erstellt.",
"emailMessage": "E-Mailnachricht",
"emailMessageNotice": "Wird am Ende von E-Mails angezeigt."
},
"updates": {
"updateChannel": "Aktualisierungskanal",
"unstable": "Unstable",
"stable": "Stable",
"title": "Aktualisierungen",
"description": "Aktiviere, um informiert zu werden, wenn neue Aktualisierungen verfügbar sind. jfa-go wird {n} alle 30 Minuten überprüfen. Keine IP-Adressen oder personenbezogene Daten werden gesammelt."
}
}

View File

@@ -82,8 +82,6 @@
"senderName": "Ονομα αποστολέα",
"dateFormat": "Μορφή ημερομηνίας",
"dateFormatNotice": "Η ημερομηνία ακολουθεί τη μορφή strftime. Για περισσότερες πληροφορίες, επισκεφτείτε το {n}.",
"time24h": "24 Ώρες",
"time12h": "12 Ώρες",
"encryption": "Κρυπτογράφηση",
"mailgunApiURL": "Διεύθυνση API"
},
@@ -125,5 +123,12 @@
"successMessageNotice": "Εμφανίζεται όταν ένας χρήστης δημιουργεί τον λογαριασμό του.",
"emailMessage": "Μήνυμα Email",
"emailMessageNotice": "Εμφανίζεται στο κάτω μέρος των email."
},
"updates": {
"title": "Ενημερώσεις",
"description": "Ενεργοποίηση ειδοποίησης όταν είναι διαθέσιμες νέες ενημερώσεις. Το jfa-go θα ελέγχει {n} κάθε 30 λεπτά. Δεν συλλέγονται IP ή προσωπικές πληροφορίες.",
"updateChannel": "Κανάλι Ενημερώσεων",
"stable": "Σταθερό",
"unstable": "Ασταθές"
}
}

5
lang/setup/en-gb.json Normal file
View File

@@ -0,0 +1,5 @@
{
"meta": {
"name": "English (GB)"
}
}

View File

@@ -76,7 +76,7 @@
},
"ombi": {
"title": "Ombi",
"description": "By connecting to Ombi, both a Jellyfin and Ombi account will be created when a user joins through jfa-go. After setup if finished, go to Settings to set a default profile for new ombi users.",
"description": "By connecting to Ombi, both a Jellyfin and Ombi account will be created when a user joins through jfa-go. After setup is finished, go to Settings to set a default profile for new ombi users.",
"apiKeyNotice": "Find this in the first tab of Ombi settings."
},
"email": {
@@ -89,8 +89,6 @@
"senderName": "Sender Name",
"dateFormat": "Date Format",
"dateFormatNotice": "Date follows the strftime format. For more info, visit {n}.",
"time24h": "24h Time",
"time12h": "12h Time",
"encryption": "Encryption",
"mailgunApiURL": "API URL"
},
@@ -110,7 +108,10 @@
"title": "Password Resets",
"description": "When a user tries to reset their password, Jellyfin creates a file named 'passwordreset-*.json' which contains a PIN. jfa-go reads the file and sends the PIN to the user.",
"pathToJellyfin": "Path to Jellyfin configuration directory",
"pathToJellyfinNotice": "If you don't know where this is, try resetting your password in Jellyfin. A popup with '<path to jellyfin>/passwordreset-*.json' will appear."
"pathToJellyfinNotice": "If you don't know where this is, try resetting your password in Jellyfin. A popup with '<path to jellyfin>/passwordreset-*.json' will appear.",
"resetLinks": "Send a link instead of a PIN",
"resetLinksNotice": "If Ombi integration is enabled, use this to sync Jellyfin password resets with Ombi.",
"resetLinksLanguage": "Default reset link language"
},
"passwordValidation": {
"title": "Password Validation",

134
lang/setup/es-es.json Normal file
View File

@@ -0,0 +1,134 @@
{
"meta": {
"name": "Español(ES)"
},
"strings": {
"pageTitle": "Configuración - jfa-go",
"next": "Siguiente",
"back": "Volver",
"optional": "Opcional",
"serverType": "Tipo de servidor",
"disabled": "Desactivado",
"enabled": "Activado",
"port": "Puerto",
"message": "Mensaje",
"serverAddress": "Dirección del servidor",
"emailSubject": "Asunto",
"URL": "URL",
"apiKey": "Llave de autorización (API)"
},
"startPage": {
"welcome": "¡Bienvenido!",
"pressStart": "Deberá hacer algunas cosas para configurar jfa-go. Presione comenzar para continuar.",
"httpsNotice": "Asegúrese de acceder a esta página a través de HTTPS o bien desde una red privada.",
"start": "Empezar"
},
"endPage": {
"finished": "¡Terminado!",
"restartMessage": "Hay más opciones que puede configurar en la página de administración. Haga clic a continuación para reiniciar, luego actualice la página.",
"refreshPage": "Actualizar"
},
"language": {
"title": "Lenguaje",
"description": "Las traducciones de la comunidad están disponibles para la mayor parte de jfa-go. Puede elegir los idiomas predeterminados a continuación, pero los usuarios aún pueden cambiarlo si lo desean. Si quieres ayudar a traducir, regístrate en {n} para empezar a contribuir!",
"defaultAdminLang": "Idioma de administrador predeterminado",
"defaultFormLang": "Idioma de creación de cuenta predeterminado",
"defaultEmailLang": "Idioma de correo electrónico predeterminado"
},
"general": {
"title": "General",
"listenAddress": "Dirección de recibidor (Listen Address)",
"urlBase": "Base de URL",
"urlBaseNotice": "Solo es necesario si se usa un proxy inverso en un subdominio (por ejemplo, 'jellyf.in/accounts').",
"lightTheme": "Claro",
"darkTheme": "Oscuro",
"useHTTPS": "Usar HTTPS (Conexión segura SSL)",
"httpsPort": "Puerto HTTPS",
"useHTTPSNotice": "Solo se recomienda si no está utilizando un proxy inverso.",
"pathToCertificate": "Ruta al certificado",
"pathToKeyFile": "Ruta al archivo de claves"
},
"updates": {
"title": "Actualizaciones",
"description": "Habilite para recibir notificaciones cuando haya nuevas actualizaciones disponibles. jfa-go comprobará {n} cada 30 minutos. No se recopilan IP ni información de identificación personal.",
"updateChannel": "Actualizar canal",
"stable": "Estable",
"unstable": "Inestable"
},
"login": {
"title": "Iniciar sesión",
"description": "Para acceder a la página de administración, debe iniciar sesión con un método a continuación:",
"authorizeWithJellyfin": "Autorizar con Jellyfin/Emby: los detalles de inicio de sesión se comparten con Jellyfin, lo que permite a varios usuarios.",
"authorizeManual": "Nombre de usuario y contraseña: establezca manualmente el nombre de usuario y la contraseña.",
"adminOnly": "Solo usuarios administradores (recomendado)",
"emailNotice": "Su dirección de correo electrónico se puede utilizar para recibir notificaciones."
},
"jellyfinEmby": {
"title": "Jellyfin/Emby",
"description": "Se necesita una cuenta de administrador porque la API no permite la creación de usuarios mediante una clave de API. Debe crear una cuenta separada y marcar 'Permitir que este usuario administre el servidor'. Puede desactivar todo lo demás. Una vez hecho esto, ingrese los detalles de inicio de sesión aquí.",
"embyNotice": "El soporte de Emby es limitado y no admite el restablecimiento de contraseñas.",
"internal": "Interno",
"external": "Externo",
"replaceJellyfin": "Nombre del servidor",
"replaceJellyfinNotice": "Si se proporciona, reemplazará cualquier aparición de 'Jellyfin' en la aplicación.",
"addressExternalNotice": "Déjelo en blanco para usar la misma dirección.",
"testConnection": "Probar conexión"
},
"ombi": {
"title": "Ombi.",
"description": "Al conectarse a Ombi, se creará una cuenta de Jellyfin y Ombi cuando un usuario se una a través de jfa-go. Una vez finalizada la configuración, vaya a Configuración para establecer un perfil predeterminado para los nuevos usuarios de ombi.",
"apiKeyNotice": "Encuentra esto en la primera pestaña de la configuración de Ombi."
},
"email": {
"title": "Correo electrónico",
"description": "jfa-go puede enviar PIN de restablecimiento de contraseña y varias notificaciones por correo electrónico. Puede conectarse a un servidor SMTP o utilizar la {n} API.",
"method": "Método de envío",
"useEmailAsUsername": "Utilice direcciones de correo electrónico como nombre de usuario",
"useEmailAsUsernameNotice": "Si está habilitado, los nuevos usuarios iniciarán sesión en Jellyfin / Emby con su dirección de correo electrónico en lugar de un nombre de usuario.",
"fromAddress": "Dirección de envío",
"senderName": "Nombre del remitente",
"dateFormat": "Formato de fecha",
"dateFormatNotice": "La fecha sigue el formato strftime. Para obtener más información, visite {n}.",
"encryption": "Cifrado",
"mailgunApiURL": "URL de API"
},
"notifications": {
"title": "Notificaciones",
"description": "Si está habilitado, puede elegir (por invitación) recibir un correo electrónico cuando una invitación caduque o se cree un usuario. Si no eligió el método de inicio de sesión de Jellyfin, asegúrese de proporcionar su dirección de correo electrónico."
},
"welcomeEmails": {
"title": "Correos de bienvenida",
"description": "Si está habilitado, se enviará un correo electrónico a los nuevos usuarios con la URL de Jellyfin/Emby y su nombre de usuario."
},
"inviteEmails": {
"title": "Correos de invitación",
"description": "Si está habilitado, puede enviar invitaciones directamente a la dirección de correo electrónico de un usuario. Debido a que es posible que esté utilizando un proxy inverso, debe proporcionar la URL desde la que se accede a las invitaciones. Escriba su base de URL y agregue '/ invite'."
},
"passwordResets": {
"title": "Restablecimiento de contraseña",
"description": "Cuando un usuario intenta restablecer su contraseña, Jellyfin crea un archivo llamado 'passwordreset - *. Json' que contiene un PIN. jfa-go lee el archivo y envía el PIN al usuario.",
"pathToJellyfin": "Ruta al directorio de configuración de Jellyfin",
"pathToJellyfinNotice": "Si no sabe dónde está, intente restablecer su contraseña en Jellyfin. Aparecerá una ventana emergente con '<ruta a jellyfin>/passwordreset-. Json'."
},
"passwordValidation": {
"title": "Validación de contraseña",
"description": "Si está habilitado, se mostrará un conjunto de requisitos de contraseña en la página de creación de la cuenta, como la longitud mínima, caracteres en mayúsculas/minúsculas, etc.",
"length": "Largo",
"uppercase": "Letras mayúsculas",
"lowercase": "Caracteres en minúscula",
"numbers": "Números",
"special": "Caracteres especiales (%, *, etc.)"
},
"helpMessages": {
"title": "Mensajes de ayuda",
"description": "Estos mensajes se mostrarán en la página de creación de la cuenta y en algunos correos electrónicos.",
"contactMessage": "Mensaje de contacto",
"contactMessageNotice": "Aparece en la parte inferior de todas las páginas excepto admin.",
"helpMessage": "Mensaje de ayuda",
"helpMessageNotice": "Aparece en la página de creación de cuenta.",
"successMessage": "Mensaje de éxito",
"successMessageNotice": "Se muestra cuando un usuario crea su cuenta.",
"emailMessage": "Mensaje de correo electrónico",
"emailMessageNotice": "Aparece en la parte inferior de los correos electrónicos."
}
}

View File

@@ -82,8 +82,6 @@
"senderName": "Nom de l'envoyeur",
"dateFormat": "Format de la date",
"dateFormatNotice": "La date suis le format srtftime. Pour plus d'informations, consultez {n}.",
"time24h": "Temps 24h",
"time12h": "Temps 12h",
"encryption": "Chiffrement",
"mailgunApiURL": "URL de l'API"
},

View File

@@ -82,8 +82,6 @@
"senderName": "Nama Pengirim",
"dateFormat": "Format Tanggal",
"dateFormatNotice": "Tanggal mengikuti format strftime. Untuk info lebih lanjut, kunjungi {n}.",
"time24h": "Waktu 24 jam",
"time12h": "Waktu 12 jam",
"encryption": "Enkripsi",
"mailgunApiURL": "URL API"
},

View File

@@ -82,8 +82,6 @@
"senderName": "Naam afzender",
"dateFormat": "Datumformaat",
"dateFormatNotice": "Datum volgend het strftime formaat. Meer info op {n}.",
"time24h": "24u-formaat",
"time12h": "12u-formaat",
"encryption": "Versleuteling",
"mailgunApiURL": "API-URL"
},
@@ -125,5 +123,12 @@
"successMessageNotice": "Getoond wanneer een gebruiker een account aanmaakt.",
"emailMessage": "E-mailtext",
"emailMessageNotice": "Getoond onderaan e-mails."
},
"updates": {
"unstable": "Instabiel",
"updateChannel": "Update kanaal",
"stable": "Stabiel",
"title": "Updates",
"description": "Vink aan om een melding te krijgen wanneer nieuwe updates beschikbaar zijn. jfa-go controleert {n} elke 30 minuten. Er worden geen IPs of persoonsgegevens verzameld."
}
}

View File

@@ -82,8 +82,6 @@
"senderName": "Nome do remetente",
"dateFormat": "Formato da Data",
"dateFormatNotice": "A data segue o formato strftime. Para obter mais informações, visite {n}.",
"time24h": "Horário 24h",
"time12h": "Horário 12h",
"encryption": "Encriptação",
"mailgunApiURL": "API URL"
},
@@ -125,5 +123,12 @@
"successMessageNotice": "Exibido quando um usuário cria sua conta.",
"emailMessage": "Mensagem de Email",
"emailMessageNotice": "Exibido na parte inferior dos emails."
},
"updates": {
"title": "Atualizações",
"description": "Ative para ser notificado quando novas atualizações estiverem disponíveis. jfa-go verificará {n} a cada 30 minutos. Nenhum IP ou informação de identificação pessoal é coletada.",
"updateChannel": "Canal de Atualização",
"stable": "Estável",
"unstable": "Instável"
}
}

View File

@@ -82,8 +82,6 @@
"senderName": "Avsändarens namn",
"dateFormat": "Datumformat",
"dateFormatNotice": "Datum följer strftime-formatet. Mer information finns på {n}.",
"time24h": "24 timmarsklocka",
"time12h": "12 timmarsklocka",
"encryption": "Kryptering",
"mailgunApiURL": "API URL"
},

View File

@@ -1,45 +0,0 @@
package main
import (
"io"
"log"
c "github.com/fatih/color"
)
type Logger interface {
Printf(format string, v ...interface{})
Print(v ...interface{})
Println(v ...interface{})
Fatal(v ...interface{})
Fatalf(format string, v ...interface{})
}
type logger struct {
logger *log.Logger
printer *c.Color
}
func NewLogger(out io.Writer, prefix string, flag int, color c.Attribute) (l logger) {
l.logger = log.New(out, prefix, flag)
l.printer = c.New(color)
return l
}
func (l logger) Printf(format string, v ...interface{}) {
l.logger.Print(l.printer.Sprintf(format, v...))
}
func (l logger) Print(v ...interface{}) { l.logger.Print(l.printer.Sprint(v...)) }
func (l logger) Println(v ...interface{}) { l.logger.Print(l.printer.Sprintln(v...)) }
func (l logger) Fatal(v ...interface{}) { l.logger.Fatal(l.printer.Sprint(v...)) }
func (l logger) Fatalf(format string, v ...interface{}) {
l.logger.Fatal(l.printer.Sprintf(format, v...))
}
type emptyLogger bool
func (l emptyLogger) Printf(format string, v ...interface{}) {}
func (l emptyLogger) Print(v ...interface{}) {}
func (l emptyLogger) Println(v ...interface{}) {}
func (l emptyLogger) Fatal(v ...interface{}) {}
func (l emptyLogger) Fatalf(format string, v ...interface{}) {}

5
logger/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module github.com/hrfee/jfa-go/logger
go 1.16
require github.com/fatih/color v1.10.0

9
logger/go.sum Normal file
View File

@@ -0,0 +1,9 @@
github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg=
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

109
logger/logger.go Normal file
View File

@@ -0,0 +1,109 @@
// Package logger provides a wrapper around log that adds color support with github.com/fatih/color.
package logger
import (
"io"
"log"
"runtime"
"strconv"
c "github.com/fatih/color"
)
type Logger interface {
Printf(format string, v ...interface{})
Print(v ...interface{})
Println(v ...interface{})
Fatal(v ...interface{})
Fatalf(format string, v ...interface{})
}
type logger struct {
logger *log.Logger
shortfile bool
printer *c.Color
}
func Lshortfile() string {
// 0 = This function, 1 = Print/Printf/Println, 2 = Caller of Print/Printf/Println
_, file, line, ok := runtime.Caller(2)
lineString := strconv.Itoa(line)
if !ok {
return ""
}
if file == "" {
return lineString
}
for i := len(file) - 1; i > 0; i-- {
if file[i] == '/' || file[i] == '\\' {
file = file[i+1:]
break
}
}
return file + ":" + lineString + ":"
}
func NewLogger(out io.Writer, prefix string, flag int, color c.Attribute) (l logger) {
// Use reimplemented Lshortfile since wrapping the log functions messes them up
if flag&log.Lshortfile != 0 {
flag -= log.Lshortfile
l.shortfile = true
}
l.logger = log.New(out, prefix, flag)
l.printer = c.New(color)
return l
}
func (l logger) Printf(format string, v ...interface{}) {
var out string
if l.shortfile {
out = Lshortfile()
}
out += " " + l.printer.Sprintf(format, v...)
l.logger.Print(out)
}
func (l logger) Print(v ...interface{}) {
var out string
if l.shortfile {
out = Lshortfile()
}
out += " " + l.printer.Sprint(v...)
l.logger.Print(out)
}
func (l logger) Println(v ...interface{}) {
var out string
if l.shortfile {
out = Lshortfile()
}
out += " " + l.printer.Sprintln(v...)
l.logger.Print(out)
}
func (l logger) Fatal(v ...interface{}) {
var out string
if l.shortfile {
out = Lshortfile()
}
out += " " + l.printer.Sprint(v...)
l.logger.Fatal(out)
}
func (l logger) Fatalf(format string, v ...interface{}) {
var out string
if l.shortfile {
out = Lshortfile()
}
out += " " + l.printer.Sprintf(format, v...)
l.logger.Fatal(out)
}
type EmptyLogger bool
func (l EmptyLogger) Printf(format string, v ...interface{}) {}
func (l EmptyLogger) Print(v ...interface{}) {}
func (l EmptyLogger) Println(v ...interface{}) {}
func (l EmptyLogger) Fatal(v ...interface{}) {}
func (l EmptyLogger) Fatalf(format string, v ...interface{}) {}

View File

@@ -60,7 +60,7 @@
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<h3>{{ .yourAccountWasDeleted }}</h3>
<h3>{{ .yourAccountWas }}</h3>
<p>{{ .reasonString }}: <i>{{ .reason }}</i></p>
</mj-text>
</mj-column>

View File

@@ -1,4 +1,4 @@
{{ .yourAccountWasDeleted }}
{{ .yourAccountWas }}
{{ .reasonString }}: {{ .reason }}

View File

@@ -66,7 +66,11 @@
<p>{{ .codeExpiry }}</p>
<p>{{ .ifItWasNotYou }}</p>
</mj-text>
<mj-raw>{{ if .link_reset }}</mj-raw>
<mj-button mj-class="blue bold" href="{{ .pin }}"><mj-raw>{{ .pin_code }}</mj-raw></mj-button>
<mj-raw>{{ else }}</mj-raw>
<mj-button mj-class="blue bold"><mj-raw>{{ .pin }}</mj-raw></mj-button>
<mj-raw>{{ end }}</mj-raw>
</mj-column>
</mj-section>
<mj-section mj-class="bg2">

View File

@@ -62,8 +62,9 @@
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<h3>{{ .welcome }}</h3>
<p>{{ .youCanLoginWith }}:</p>
{{ .jellyfinURLString }}: <a href="{{ .jellyfinURLVal }}">{{ .jellyfinURL }}</a>
{{ .jellyfinURLString }}: <a href="{{ .jellyfinURL }}">{{ .jellyfinURL }}</a>
<p>{{ .usernameString }}: <i>{{ .username }}</i></p>
<p>{{ .yourAccountWillExpire }}</p>
</mj-text>
</mj-column>
</mj-section>

View File

@@ -3,7 +3,9 @@
{{ .youCanLoginWith }}:
{{ .jellyfinURLString }}: {{ .jellyfinURL }}
{{ .usernameString }}: {{ .username }}
{{ .yourAccountWillExpire }}
{{ .message }}

197
main.go
View File

@@ -5,7 +5,6 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io/fs"
"log"
@@ -24,8 +23,9 @@ import (
"github.com/fatih/color"
"github.com/hrfee/jfa-go/common"
_ "github.com/hrfee/jfa-go/docs"
"github.com/hrfee/jfa-go/mediabrowser"
"github.com/hrfee/jfa-go/logger"
"github.com/hrfee/jfa-go/ombi"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
)
@@ -38,6 +38,7 @@ var (
DATA, CONFIG, HOST *string
PORT *int
DEBUG *bool
PPROF *bool
TEST bool
SWAGGER *bool
warning = color.New(color.FgYellow).SprintfFunc()
@@ -79,7 +80,7 @@ type appContext struct {
configBase settings
dataPath string
webFS httpFS
cssClass string
cssClass string // Default theme, "light-theme"|"dark-theme".
jellyfinLogin bool
users []User
invalidTokens []string
@@ -92,7 +93,7 @@ type appContext struct {
storage Storage
validator Validator
email *Emailer
info, debug, err Logger
info, debug, err logger.Logger
host string
port int
version string
@@ -164,53 +165,10 @@ func start(asDaemon, firstCall bool) {
fs: localFS,
}
app.info = NewLogger(os.Stdout, "[INFO] ", log.Ltime, color.FgHiWhite)
app.err = NewLogger(os.Stdout, "[ERROR] ", log.Ltime, color.FgRed)
app.info = logger.NewLogger(os.Stdout, "[INFO] ", log.Ltime, color.FgHiWhite)
app.err = logger.NewLogger(os.Stdout, "[ERROR] ", log.Ltime|log.Lshortfile, color.FgRed)
if firstCall {
DATA = flag.String("data", app.dataPath, "alternate path to data directory.")
CONFIG = flag.String("config", app.configPath, "alternate path to config file.")
HOST = flag.String("host", "", "alternate address to host web ui on.")
PORT = flag.Int("port", 0, "alternate port to host web ui on.")
DEBUG = flag.Bool("debug", false, "Enables debug logging and exposes pprof.")
SWAGGER = flag.Bool("swagger", false, "Enable swagger at /swagger/index.html")
flag.Parse()
if *SWAGGER {
os.Setenv("SWAGGER", "1")
}
if *DEBUG {
os.Setenv("DEBUG", "1")
}
}
if os.Getenv("SWAGGER") == "1" {
*SWAGGER = true
}
if os.Getenv("DEBUG") == "1" {
*DEBUG = true
}
// attempt to apply command line flags correctly
if app.configPath == *CONFIG && app.dataPath != *DATA {
app.dataPath = *DATA
app.configPath = filepath.Join(app.dataPath, "config.ini")
} else if app.configPath != *CONFIG && app.dataPath == *DATA {
app.configPath = *CONFIG
} else {
app.configPath = *CONFIG
app.dataPath = *DATA
}
// Previously used for self-restarts but leaving them here as they might be useful.
if v := os.Getenv("JFA_CONFIGPATH"); v != "" {
app.configPath = v
}
if v := os.Getenv("JFA_DATAPATH"); v != "" {
app.dataPath = v
}
os.Setenv("JFA_CONFIGPATH", app.configPath)
os.Setenv("JFA_DATAPATH", app.dataPath)
app.loadArgs(firstCall)
var firstRun bool
if _, err := os.Stat(app.dataPath); os.IsNotExist(err) {
@@ -237,8 +195,8 @@ func start(asDaemon, firstCall bool) {
var debugMode bool
var address string
if app.loadConfig() != nil {
app.err.Fatalf("Failed to load config file \"%s\"", app.configPath)
if err := app.loadConfig(); err != nil {
app.err.Fatalf("Failed to load config file \"%s\": %v", app.configPath, err)
}
app.version = app.config.Section("jellyfin").Key("version").String()
// read from config...
@@ -248,18 +206,19 @@ func start(asDaemon, firstCall bool) {
debugMode = true
}
if debugMode {
app.info.Print(warning("\n\nWARNING: Don't use debug mode in production, as it exposes pprof on the network.\n\n"))
app.debug = NewLogger(os.Stdout, "[DEBUG] ", log.Ltime|log.Lshortfile, color.FgYellow)
app.debug = logger.NewLogger(os.Stdout, "[DEBUG] ", log.Ltime|log.Lshortfile, color.FgYellow)
} else {
app.debug = emptyLogger(false)
app.debug = logger.EmptyLogger(false)
}
if *PPROF {
app.info.Print(warning("\n\nWARNING: Don't use pprof in production.\n\n"))
}
// Starts listener to receive commands over a unix socket. Use with 'jfa-go start/stop'
if asDaemon {
go func() {
socket := SOCK
os.Remove(socket)
listener, err := net.Listen("unix", socket)
os.Remove(SOCK)
listener, err := net.Listen("unix", SOCK)
if err != nil {
app.err.Fatalf("Couldn't establish socket connection at %s\n", SOCK)
}
@@ -267,7 +226,7 @@ func start(asDaemon, firstCall bool) {
signal.Notify(c, os.Interrupt)
go func() {
<-c
os.Remove(socket)
os.Remove(SOCK)
os.Exit(1)
}()
defer func() {
@@ -277,13 +236,13 @@ func start(asDaemon, firstCall bool) {
for {
con, err := listener.Accept()
if err != nil {
app.err.Printf("Couldn't read message on %s: %s", socket, err)
app.err.Printf("Couldn't read message on %s: %s", SOCK, err)
continue
}
buf := make([]byte, 512)
nr, err := con.Read(buf)
if err != nil {
app.err.Printf("Couldn't read message on %s: %s", socket, err)
app.err.Printf("Couldn't read message on %s: %s", SOCK, err)
continue
}
command := string(buf[0:nr])
@@ -298,6 +257,7 @@ func start(asDaemon, firstCall bool) {
app.storage.lang.FormPath = "form"
app.storage.lang.AdminPath = "admin"
app.storage.lang.EmailPath = "email"
app.storage.lang.PasswordResetPath = "pwreset"
externalLang := app.config.Section("files").Key("lang_files").MustString("")
var err error
if externalLang == "" {
@@ -342,20 +302,33 @@ func start(asDaemon, firstCall bool) {
app.debug.Println("Loading storage")
app.storage.invite_path = app.config.Section("files").Key("invites").String()
app.storage.loadInvites()
if err := app.storage.loadInvites(); err != nil {
app.err.Printf("Failed to load Invites: %v", err)
}
app.storage.emails_path = app.config.Section("files").Key("emails").String()
app.storage.loadEmails()
if err := app.storage.loadEmails(); err != nil {
app.err.Printf("Failed to load Emails: %v", err)
}
app.storage.policy_path = app.config.Section("files").Key("user_template").String()
app.storage.loadPolicy()
if err := app.storage.loadPolicy(); err != nil {
app.err.Printf("Failed to load Policy: %v", err)
}
app.storage.configuration_path = app.config.Section("files").Key("user_configuration").String()
app.storage.loadConfiguration()
if err := app.storage.loadConfiguration(); err != nil {
app.err.Printf("Failed to load Configuration: %v", err)
}
app.storage.displayprefs_path = app.config.Section("files").Key("user_displayprefs").String()
app.storage.loadDisplayprefs()
if err := app.storage.loadDisplayprefs(); err != nil {
app.err.Printf("Failed to load Displayprefs: %v", err)
}
app.storage.users_path = app.config.Section("files").Key("users").String()
app.storage.loadUsers()
if err := app.storage.loadUsers(); err != nil {
app.err.Printf("Failed to load Users: %v", err)
}
app.storage.profiles_path = app.config.Section("files").Key("user_profiles").String()
app.storage.loadProfiles()
// Migrate from pre-0.2.0 user templates to profiles
if !(app.storage.policy.BlockedTags == nil && app.storage.configuration.GroupedFolders == nil && len(app.storage.displayprefs) == 0) {
app.info.Println("Migrating user template files to new profile format")
app.storage.migrateToProfile()
@@ -394,7 +367,7 @@ func start(asDaemon, firstCall bool) {
"Jellyfin (Dark)": "dark-theme",
"Default (Light)": "light-theme",
}
// For move from Bootstrap to a17t
// For move from Bootstrap to a17t (0.2.5)
if app.config.Section("ui").Key("theme").String() == "Bootstrap (Light)" {
app.config.Section("ui").Key("theme").SetValue("Default (Light)")
}
@@ -411,10 +384,10 @@ func start(asDaemon, firstCall bool) {
server := app.config.Section("jellyfin").Key("server").String()
cacheTimeout := int(app.config.Section("jellyfin").Key("cache_timeout").MustUint(30))
stringServerType := app.config.Section("jellyfin").Key("type").String()
timeoutHandler := common.NewTimeoutHandler("Jellyfin", server, true)
timeoutHandler := mediabrowser.NewNamedTimeoutHandler("Jellyfin", server, true)
if stringServerType == "emby" {
serverType = mediabrowser.EmbyServer
timeoutHandler = common.NewTimeoutHandler("Emby", server, true)
timeoutHandler = mediabrowser.NewNamedTimeoutHandler("Emby", server, true)
app.info.Println("Using Emby server type")
fmt.Println(warning("WARNING: Emby compatibility is experimental, and support is limited.\nPassword resets are not available."))
} else {
@@ -431,10 +404,13 @@ func start(asDaemon, firstCall bool) {
timeoutHandler,
cacheTimeout,
)
if debugMode {
app.jf.Verbose = true
}
var status int
_, status, err = app.jf.Authenticate(app.config.Section("jellyfin").Key("username").String(), app.config.Section("jellyfin").Key("password").String())
if status != 200 || err != nil {
app.err.Fatalf("Failed to authenticate with Jellyfin @ %s: Code %d", server, status)
app.err.Fatalf("Failed to authenticate with Jellyfin @ %s (%d): %v", server, status, err)
}
app.info.Printf("Authenticated with %s", server)
/* A couple of unstable Jellyfin 10.7.0 releases decided to hyphenate user IDs.
@@ -463,31 +439,47 @@ func start(asDaemon, firstCall bool) {
}
if noHyphens == app.jf.Hyphens {
var newEmails map[string]interface{}
var status int
var err error
var newUsers map[string]time.Time
var status, status2 int
var err, err2 error
if app.jf.Hyphens {
app.info.Println(info("Your build of Jellyfin appears to hypenate user IDs. Your emails.json file will be modified to match."))
app.info.Println(info("Your build of Jellyfin appears to hypenate user IDs. Your emails.json/users.json file will be modified to match."))
time.Sleep(time.Second * time.Duration(3))
newEmails, status, err = app.hyphenateEmailStorage(app.storage.emails)
newUsers, status2, err2 = app.hyphenateUserStorage(app.storage.users)
} else {
app.info.Println(info("Your emails.json file uses hyphens, but the Jellyfin server no longer does. It will be modified."))
app.info.Println(info("Your emails.json/users.json file uses hyphens, but the Jellyfin server no longer does. It will be modified."))
time.Sleep(time.Second * time.Duration(3))
newEmails, status, err = app.deHyphenateEmailStorage(app.storage.emails)
newUsers, status2, err2 = app.deHyphenateUserStorage(app.storage.users)
}
if status != 200 || err != nil {
app.err.Printf("Failed to get users from Jellyfin: Code %d", status)
app.debug.Printf("Error: %s", err)
app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err)
app.err.Fatalf("Couldn't upgrade emails.json")
}
bakFile := app.storage.emails_path + ".bak"
err = storeJSON(bakFile, app.storage.emails)
if status2 != 200 || err2 != nil {
app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err)
app.err.Fatalf("Couldn't upgrade users.json")
}
emailBakFile := app.storage.emails_path + ".bak"
usersBakFile := app.storage.users_path + ".bak"
err = storeJSON(emailBakFile, app.storage.emails)
err2 = storeJSON(usersBakFile, app.storage.users)
if err != nil {
app.err.Fatalf("couldn't store emails.json backup: %s", err)
app.err.Fatalf("couldn't store emails.json backup: %v", err)
}
if err2 != nil {
app.err.Fatalf("couldn't store users.json backup: %v", err)
}
app.storage.emails = newEmails
app.storage.users = newUsers
err = app.storage.storeEmails()
err2 = app.storage.storeUsers()
if err != nil {
app.err.Fatalf("couldn't store emails.json: %s", err)
app.err.Fatalf("couldn't store emails.json: %v", err)
}
if err2 != nil {
app.err.Fatalf("couldn't store users.json: %v", err)
}
}
}
@@ -504,6 +496,9 @@ func start(asDaemon, firstCall bool) {
} else {
app.debug.Println("Using Jellyfin for authentication")
app.authJf, _ = mediabrowser.NewServer(serverType, server, "jfa-go", app.version, "auth", "auth", timeoutHandler, cacheTimeout)
if debugMode {
app.authJf.Verbose = true
}
}
// Since email depends on language, the email reload in loadConfig won't work first time.
@@ -531,11 +526,13 @@ func start(asDaemon, firstCall bool) {
os.Exit(0)
}
inviteDaemon := newInviteDaemon(time.Duration(60*time.Second), app)
go inviteDaemon.run()
invDaemon := newInviteDaemon(time.Duration(60*time.Second), app)
go invDaemon.run()
defer invDaemon.shutdown()
userDaemon := newUserDaemon(time.Duration(60*time.Second), app)
go userDaemon.run()
defer userDaemon.shutdown()
if app.config.Section("password_resets").Key("enabled").MustBool(false) && serverType == mediabrowser.JellyfinServer {
go app.StartPWR()
@@ -692,6 +689,40 @@ func main() {
fmt.Println("Sent.")
} else if flagPassed("daemon") {
start(true, true)
} else if flagPassed("systemd") {
service, err := fs.ReadFile(localFS, "jfa-go.service")
if err != nil {
fmt.Printf("Couldn't read jfa-go.service: %v\n", err)
os.Exit(1)
}
absPath, err := filepath.Abs(os.Args[0])
if err != nil {
absPath = os.Args[0]
}
command := absPath
for i, v := range os.Args {
if i != 0 && v != "systemd" {
command += " " + v
}
}
service = []byte(strings.Replace(string(service), "{executable}", command, 1))
err = os.WriteFile("jfa-go.service", service, 0666)
if err != nil {
fmt.Printf("Couldn't write jfa-go.service: %v\n", err)
os.Exit(1)
}
fmt.Print(info(`If you want to execute jfa-go with special arguments, re-run this command with them.
Move the newly created "jfa-go.service" file to ~/.config/systemd/user (Creating it if necessary).
Then run "systemctl --user daemon-reload".
You can then run:
`))
color.New(color.FgGreen).PrintFunc()("To start: ")
fmt.Print(info("systemctl --user start jfa-go\n\n"))
color.New(color.FgRed).PrintFunc()("To stop: ")
fmt.Print(info("systemctl --user stop jfa-go\n\n"))
color.New(color.FgYellow).PrintFunc()("To restart: ")
fmt.Print(info("systemctl --user stop jfa-go\n"))
} else {
RESTART = make(chan bool, 1)
start(false, true)

View File

@@ -1,173 +0,0 @@
package mediabrowser
// Almost identical to jfapi, with the most notable change being the password workaround.
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
func embyDeleteUser(emby *MediaBrowser, userID string) (int, error) {
url := fmt.Sprintf("%s/Users/%s", emby.Server, userID)
req, _ := http.NewRequest("DELETE", url, nil)
for name, value := range emby.header {
req.Header.Add(name, value)
}
resp, err := emby.httpClient.Do(req)
defer emby.timeoutHandler()
return resp.StatusCode, err
}
func embyGetUsers(emby *MediaBrowser, public bool) ([]User, int, error) {
var result []User
var data string
var status int
var err error
if time.Now().After(emby.CacheExpiry) {
if public {
url := fmt.Sprintf("%s/users/public", emby.Server)
data, status, err = emby.get(url, nil)
} else {
url := fmt.Sprintf("%s/users", emby.Server)
data, status, err = emby.get(url, emby.loginParams)
}
if err != nil || status != 200 {
return nil, status, err
}
json.Unmarshal([]byte(data), &result)
emby.userCache = result
emby.CacheExpiry = time.Now().Add(time.Minute * time.Duration(emby.cacheLength))
if result[0].ID[8] == '-' {
emby.Hyphens = true
}
return result, status, nil
}
return emby.userCache, 200, nil
}
func embyUserByName(emby *MediaBrowser, username string, public bool) (User, int, error) {
var match User
find := func() (User, int, error) {
users, status, err := emby.GetUsers(public)
if err != nil || status != 200 {
return User{}, status, err
}
for _, user := range users {
if user.Name == username {
return user, status, err
}
}
return User{}, status, err
}
match, status, err := find()
if match.Name == "" {
emby.CacheExpiry = time.Now()
match, status, err = find()
}
return match, status, err
}
func embyUserByID(emby *MediaBrowser, userID string, public bool) (User, int, error) {
if emby.CacheExpiry.After(time.Now()) {
for _, user := range emby.userCache {
if user.ID == userID {
return user, 200, nil
}
}
}
if public {
users, status, err := emby.GetUsers(public)
if err != nil || status != 200 {
return User{}, status, err
}
for _, user := range users {
if user.ID == userID {
return user, status, nil
}
}
return User{}, status, err
}
var result User
var data string
var status int
var err error
url := fmt.Sprintf("%s/users/%s", emby.Server, userID)
data, status, err = emby.get(url, emby.loginParams)
if err != nil || status != 200 {
return User{}, status, err
}
json.Unmarshal([]byte(data), &result)
return result, status, nil
}
// Since emby doesn't allow one to specify a password on user creation, we:
// Create the account
// Immediately disable it
// Set password
// Reeenable it
func embyNewUser(emby *MediaBrowser, username, password string) (User, int, error) {
url := fmt.Sprintf("%s/Users/New", emby.Server)
data := map[string]interface{}{
"Name": username,
}
response, status, err := emby.post(url, data, true)
var recv User
json.Unmarshal([]byte(response), &recv)
if err != nil || !(status == 200 || status == 204) {
return User{}, status, err
}
// Step 2: Set password
id := recv.ID
url = fmt.Sprintf("%s/Users/%s/Password", emby.Server, id)
data = map[string]interface{}{
"Id": id,
"CurrentPw": "",
"NewPw": password,
}
_, status, err = emby.post(url, data, false)
// Step 3: If setting password errored, try to delete the account
if err != nil || !(status == 200 || status == 204) {
_, err = emby.DeleteUser(id)
}
return recv, status, nil
}
func embySetPolicy(emby *MediaBrowser, userID string, policy Policy) (int, error) {
url := fmt.Sprintf("%s/Users/%s/Policy", emby.Server, userID)
_, status, err := emby.post(url, policy, false)
if err != nil || status != 200 {
return status, err
}
return status, nil
}
func embySetConfiguration(emby *MediaBrowser, userID string, configuration Configuration) (int, error) {
url := fmt.Sprintf("%s/Users/%s/Configuration", emby.Server, userID)
_, status, err := emby.post(url, configuration, false)
return status, err
}
func embyGetDisplayPreferences(emby *MediaBrowser, userID string) (map[string]interface{}, int, error) {
url := fmt.Sprintf("%s/DisplayPreferences/usersettings?userId=%s&client=emby", emby.Server, userID)
data, status, err := emby.get(url, nil)
if err != nil || !(status == 204 || status == 200) {
return nil, status, err
}
var displayprefs map[string]interface{}
err = json.Unmarshal([]byte(data), &displayprefs)
if err != nil {
return nil, status, err
}
return displayprefs, status, nil
}
func embySetDisplayPreferences(emby *MediaBrowser, userID string, displayprefs map[string]interface{}) (int, error) {
url := fmt.Sprintf("%s/DisplayPreferences/usersettings?userId=%s&client=emby", emby.Server, userID)
_, status, err := emby.post(url, displayprefs, false)
if err != nil || !(status == 204 || status == 200) {
return status, err
}
return status, nil
}

View File

@@ -1,7 +0,0 @@
module github.com/hrfee/jfa-go/mediabrowser
go 1.15
replace github.com/hrfee/jfa-go/common => ../common
require github.com/hrfee/jfa-go/common v0.0.0-20210105184019-fdc97b4e86cc

View File

@@ -1,162 +0,0 @@
package mediabrowser
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
func jfDeleteUser(jf *MediaBrowser, userID string) (int, error) {
url := fmt.Sprintf("%s/Users/%s", jf.Server, userID)
req, _ := http.NewRequest("DELETE", url, nil)
for name, value := range jf.header {
req.Header.Add(name, value)
}
resp, err := jf.httpClient.Do(req)
defer jf.timeoutHandler()
return resp.StatusCode, err
}
func jfGetUsers(jf *MediaBrowser, public bool) ([]User, int, error) {
var result []User
var data string
var status int
var err error
if time.Now().After(jf.CacheExpiry) {
if public {
url := fmt.Sprintf("%s/users/public", jf.Server)
data, status, err = jf.get(url, nil)
} else {
url := fmt.Sprintf("%s/users", jf.Server)
data, status, err = jf.get(url, jf.loginParams)
}
if err != nil || status != 200 {
return nil, status, err
}
err := json.Unmarshal([]byte(data), &result)
if err != nil {
fmt.Println(err)
return nil, status, err
}
jf.userCache = result
jf.CacheExpiry = time.Now().Add(time.Minute * time.Duration(jf.cacheLength))
if result[0].ID[8] == '-' {
jf.Hyphens = true
}
return result, status, nil
}
return jf.userCache, 200, nil
}
func jfUserByName(jf *MediaBrowser, username string, public bool) (User, int, error) {
var match User
find := func() (User, int, error) {
users, status, err := jf.GetUsers(public)
if err != nil || status != 200 {
return User{}, status, err
}
for _, user := range users {
if user.Name == username {
return user, status, err
}
}
return User{}, status, err
}
match, status, err := find()
if match.Name == "" {
jf.CacheExpiry = time.Now()
match, status, err = find()
}
return match, status, err
}
func jfUserByID(jf *MediaBrowser, userID string, public bool) (User, int, error) {
if jf.CacheExpiry.After(time.Now()) {
for _, user := range jf.userCache {
if user.ID == userID {
return user, 200, nil
}
}
}
if public {
users, status, err := jf.GetUsers(public)
if err != nil || status != 200 {
return User{}, status, err
}
for _, user := range users {
if user.ID == userID {
return user, status, nil
}
}
return User{}, status, err
}
var result User
var data string
var status int
var err error
url := fmt.Sprintf("%s/users/%s", jf.Server, userID)
data, status, err = jf.get(url, jf.loginParams)
if err != nil || status != 200 {
return User{}, status, err
}
json.Unmarshal([]byte(data), &result)
return result, status, nil
}
func jfNewUser(jf *MediaBrowser, username, password string) (User, int, error) {
url := fmt.Sprintf("%s/Users/New", jf.Server)
stringData := map[string]string{
"Name": username,
"Password": password,
}
data := make(map[string]interface{})
for key, value := range stringData {
data[key] = value
}
response, status, err := jf.post(url, data, true)
var recv User
json.Unmarshal([]byte(response), &recv)
if err != nil || !(status == 200 || status == 204) {
return User{}, status, err
}
return recv, status, nil
}
func jfSetPolicy(jf *MediaBrowser, userID string, policy Policy) (int, error) {
url := fmt.Sprintf("%s/Users/%s/Policy", jf.Server, userID)
_, status, err := jf.post(url, policy, false)
if err != nil || status != 200 {
return status, err
}
return status, nil
}
func jfSetConfiguration(jf *MediaBrowser, userID string, configuration Configuration) (int, error) {
url := fmt.Sprintf("%s/Users/%s/Configuration", jf.Server, userID)
_, status, err := jf.post(url, configuration, false)
return status, err
}
func jfGetDisplayPreferences(jf *MediaBrowser, userID string) (map[string]interface{}, int, error) {
url := fmt.Sprintf("%s/DisplayPreferences/usersettings?userId=%s&client=emby", jf.Server, userID)
data, status, err := jf.get(url, nil)
if err != nil || !(status == 204 || status == 200) {
return nil, status, err
}
var displayprefs map[string]interface{}
err = json.Unmarshal([]byte(data), &displayprefs)
if err != nil {
return nil, status, err
}
return displayprefs, status, nil
}
func jfSetDisplayPreferences(jf *MediaBrowser, userID string, displayprefs map[string]interface{}) (int, error) {
url := fmt.Sprintf("%s/DisplayPreferences/usersettings?userId=%s&client=emby", jf.Server, userID)
_, status, err := jf.post(url, displayprefs, false)
if err != nil || !(status == 204 || status == 200) {
return status, err
}
return status, nil
}

View File

@@ -1,299 +0,0 @@
package mediabrowser
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"strings"
"time"
"github.com/hrfee/jfa-go/common"
)
type serverType int
const (
JellyfinServer serverType = iota
EmbyServer
)
type serverInfo struct {
LocalAddress string `json:"LocalAddress"`
Name string `json:"ServerName"`
Version string `json:"Version"`
OS string `json:"OperatingSystem"`
ID string `json:"Id"`
}
// MediaBrowser is an api instance of Jellyfin/Emby.
type MediaBrowser struct {
Server string
client string
version string
device string
deviceID string
useragent string
auth string
header map[string]string
ServerInfo serverInfo
Username string
password string
Authenticated bool
AccessToken string
userID string
httpClient *http.Client
loginParams map[string]string
userCache []User
CacheExpiry time.Time
cacheLength int
noFail bool
Hyphens bool
serverType serverType
timeoutHandler common.TimeoutHandler
}
// NewServer returns a new Jellyfin object.
func NewServer(st serverType, server, client, version, device, deviceID string, timeoutHandler common.TimeoutHandler, cacheTimeout int) (*MediaBrowser, error) {
mb := &MediaBrowser{}
mb.serverType = st
mb.Server = server
mb.client = client
mb.version = version
mb.device = device
mb.deviceID = deviceID
mb.useragent = fmt.Sprintf("%s/%s", client, version)
mb.timeoutHandler = timeoutHandler
mb.auth = fmt.Sprintf("MediaBrowser Client=\"%s\", Device=\"%s\", DeviceId=\"%s\", Version=\"%s\"", client, device, deviceID, version)
mb.header = map[string]string{
"Accept": "application/json",
"Content-type": "application/json; charset=UTF-8",
"X-Application": mb.useragent,
"Accept-Charset": "UTF-8,*",
"Accept-Encoding": "gzip",
"User-Agent": mb.useragent,
"X-Emby-Authorization": mb.auth,
}
mb.httpClient = &http.Client{
Timeout: 10 * time.Second,
}
infoURL := fmt.Sprintf("%s/System/Info/Public", server)
req, _ := http.NewRequest("GET", infoURL, nil)
resp, err := mb.httpClient.Do(req)
defer mb.timeoutHandler()
if err == nil {
data, _ := ioutil.ReadAll(resp.Body)
json.Unmarshal(data, &mb.ServerInfo)
}
mb.cacheLength = cacheTimeout
mb.CacheExpiry = time.Now()
return mb, nil
}
func (mb *MediaBrowser) get(url string, params map[string]string) (string, int, error) {
var req *http.Request
if params != nil {
jsonParams, _ := json.Marshal(params)
req, _ = http.NewRequest("GET", url, bytes.NewBuffer(jsonParams))
} else {
req, _ = http.NewRequest("GET", url, nil)
}
for name, value := range mb.header {
req.Header.Add(name, value)
}
resp, err := mb.httpClient.Do(req)
defer mb.timeoutHandler()
if err != nil || resp.StatusCode != 200 {
if resp.StatusCode == 401 && mb.Authenticated {
mb.Authenticated = false
_, _, authErr := mb.Authenticate(mb.Username, mb.password)
if authErr == nil {
v1, v2, v3 := mb.get(url, params)
return v1, v2, v3
}
}
return "", resp.StatusCode, err
}
defer resp.Body.Close()
var data io.Reader
encoding := resp.Header.Get("Content-Encoding")
switch encoding {
case "gzip":
data, _ = gzip.NewReader(resp.Body)
default:
data = resp.Body
}
buf := new(strings.Builder)
io.Copy(buf, data)
//var respData map[string]interface{}
//json.NewDecoder(data).Decode(&respData)
return buf.String(), resp.StatusCode, nil
}
func (mb *MediaBrowser) post(url string, data interface{}, response bool) (string, int, error) {
params, _ := json.Marshal(data)
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(params))
for name, value := range mb.header {
req.Header.Add(name, value)
}
resp, err := mb.httpClient.Do(req)
defer mb.timeoutHandler()
if err != nil || resp.StatusCode != 200 {
if resp.StatusCode == 401 && mb.Authenticated {
mb.Authenticated = false
_, _, authErr := mb.Authenticate(mb.Username, mb.password)
if authErr == nil {
v1, v2, v3 := mb.post(url, data, response)
return v1, v2, v3
}
}
return "", resp.StatusCode, err
}
if response {
defer resp.Body.Close()
var outData io.Reader
switch resp.Header.Get("Content-Encoding") {
case "gzip":
outData, _ = gzip.NewReader(resp.Body)
default:
outData = resp.Body
}
buf := new(strings.Builder)
io.Copy(buf, outData)
return buf.String(), resp.StatusCode, nil
}
return "", resp.StatusCode, nil
}
// Authenticate attempts to authenticate using a username & password
func (mb *MediaBrowser) Authenticate(username, password string) (User, int, error) {
mb.Username = username
mb.password = password
mb.loginParams = map[string]string{
"Username": username,
"Pw": password,
"Password": password,
}
buffer := &bytes.Buffer{}
encoder := json.NewEncoder(buffer)
encoder.SetEscapeHTML(false)
err := encoder.Encode(mb.loginParams)
if err != nil {
return User{}, 0, err
}
// loginParams, _ := json.Marshal(jf.loginParams)
url := fmt.Sprintf("%s/Users/authenticatebyname", mb.Server)
req, err := http.NewRequest("POST", url, buffer)
defer mb.timeoutHandler()
if err != nil {
return User{}, 0, err
}
for name, value := range mb.header {
req.Header.Add(name, value)
}
resp, err := mb.httpClient.Do(req)
if err != nil || resp.StatusCode != 200 {
return User{}, resp.StatusCode, err
}
defer resp.Body.Close()
var d io.Reader
switch resp.Header.Get("Content-Encoding") {
case "gzip":
d, _ = gzip.NewReader(resp.Body)
default:
d = resp.Body
}
data, err := io.ReadAll(d)
if err != nil {
return User{}, 0, err
}
var respData map[string]interface{}
json.Unmarshal(data, &respData)
mb.AccessToken = respData["AccessToken"].(string)
var user User
ju, err := json.Marshal(respData["User"])
if err != nil {
return User{}, 0, err
}
json.Unmarshal(ju, &user)
mb.userID = user.ID
mb.auth = fmt.Sprintf("MediaBrowser Client=\"%s\", Device=\"%s\", DeviceId=\"%s\", Version=\"%s\", Token=\"%s\"", mb.client, mb.device, mb.deviceID, mb.version, mb.AccessToken)
mb.header["X-Emby-Authorization"] = mb.auth
mb.Authenticated = true
return user, resp.StatusCode, nil
}
// DeleteUser deletes the user corresponding to the provided ID.
func (mb *MediaBrowser) DeleteUser(userID string) (int, error) {
if mb.serverType == JellyfinServer {
return jfDeleteUser(mb, userID)
}
return embyDeleteUser(mb, userID)
}
// GetUsers returns all (visible) users on the Emby instance.
func (mb *MediaBrowser) GetUsers(public bool) ([]User, int, error) {
if mb.serverType == JellyfinServer {
return jfGetUsers(mb, public)
}
return embyGetUsers(mb, public)
}
// UserByName returns the user corresponding to the provided username.
func (mb *MediaBrowser) UserByName(username string, public bool) (User, int, error) {
if mb.serverType == JellyfinServer {
return jfUserByName(mb, username, public)
}
return embyUserByName(mb, username, public)
}
// UserByID returns the user corresponding to the provided ID.
func (mb *MediaBrowser) UserByID(userID string, public bool) (User, int, error) {
if mb.serverType == JellyfinServer {
return jfUserByID(mb, userID, public)
}
return embyUserByID(mb, userID, public)
}
// NewUser creates a new user with the provided username and password.
func (mb *MediaBrowser) NewUser(username, password string) (User, int, error) {
if mb.serverType == JellyfinServer {
return jfNewUser(mb, username, password)
}
return embyNewUser(mb, username, password)
}
// SetPolicy sets the access policy for the user corresponding to the provided ID.
func (mb *MediaBrowser) SetPolicy(userID string, policy Policy) (int, error) {
if mb.serverType == JellyfinServer {
return jfSetPolicy(mb, userID, policy)
}
return embySetPolicy(mb, userID, policy)
}
// SetConfiguration sets the configuration (part of homescreen layout) for the user corresponding to the provided ID.
func (mb *MediaBrowser) SetConfiguration(userID string, configuration Configuration) (int, error) {
if mb.serverType == JellyfinServer {
return jfSetConfiguration(mb, userID, configuration)
}
return embySetConfiguration(mb, userID, configuration)
}
// GetDisplayPreferences gets the displayPreferences (part of homescreen layout) for the user corresponding to the provided ID.
func (mb *MediaBrowser) GetDisplayPreferences(userID string) (map[string]interface{}, int, error) {
if mb.serverType == JellyfinServer {
return jfGetDisplayPreferences(mb, userID)
}
return embyGetDisplayPreferences(mb, userID)
}
// SetDisplayPreferences sets the displayPreferences (part of homescreen layout) for the user corresponding to the provided ID.
func (mb *MediaBrowser) SetDisplayPreferences(userID string, displayprefs map[string]interface{}) (int, error) {
if mb.serverType == JellyfinServer {
return jfSetDisplayPreferences(mb, userID, displayprefs)
}
return embySetDisplayPreferences(mb, userID, displayprefs)
}

View File

@@ -1,135 +0,0 @@
package mediabrowser
import (
"encoding/json"
"fmt"
"strings"
"time"
)
type magicParse struct {
Parsed time.Time `json:"parseme"`
}
type Time struct {
time.Time
}
func (t *Time) UnmarshalJSON(b []byte) (err error) {
str := strings.TrimSuffix(strings.TrimPrefix(string(b), "\""), "\"")
// Trim nanoseconds to always have 6 digits, so overall length is always the same.
if str[len(str)-1] == 'Z' {
str = str[:26] + "Z"
} else {
str = str[:26]
}
// decent method
t.Time, err = time.Parse("2006-01-02T15:04:05.000000Z", str)
if err == nil {
return
}
t.Time, err = time.Parse("2006-01-02T15:04:05.000000", str)
if err == nil {
return
}
// emby method
t.Time, err = time.Parse("2006-01-02T15:04:05.0000000+00:00", str)
if err == nil {
return
}
fmt.Println("THIRDERR", err)
// magic method
// some stored dates from jellyfin have no timezone at the end, if not we assume UTC
if str[len(str)-1] != 'Z' {
str += "Z"
}
timeJSON := []byte("{ \"parseme\": \"" + str + "\" }")
var parsed magicParse
// Magically turn it into a time.Time
err = json.Unmarshal(timeJSON, &parsed)
t.Time = parsed.Parsed
return
}
type User struct {
Name string `json:"Name"`
ServerID string `json:"ServerId"`
ID string `json:"Id"`
HasPassword bool `json:"HasPassword"`
HasConfiguredPassword bool `json:"HasConfiguredPassword"`
HasConfiguredEasyPassword bool `json:"HasConfiguredEasyPassword"`
EnableAutoLogin bool `json:"EnableAutoLogin"`
LastLoginDate Time `json:"LastLoginDate"`
LastActivityDate Time `json:"LastActivityDate"`
Configuration Configuration `json:"Configuration"`
Policy Policy `json:"Policy"`
}
type SessionInfo struct {
RemoteEndpoint string `json:"RemoteEndPoint"`
UserID string `json:"UserId"`
}
type AuthenticationResult struct {
User User `json:"User"`
AccessToken string `json:"AccessToken"`
ServerID string `json:"ServerId"`
SessionInfo SessionInfo `json:"SessionInfo"`
}
type Configuration struct {
PlayDefaultAudioTrack bool `json:"PlayDefaultAudioTrack"`
SubtitleLanguagePreference string `json:"SubtitleLanguagePreference"`
DisplayMissingEpisodes bool `json:"DisplayMissingEpisodes"`
GroupedFolders []interface{} `json:"GroupedFolders"`
SubtitleMode string `json:"SubtitleMode"`
DisplayCollectionsView bool `json:"DisplayCollectionsView"`
EnableLocalPassword bool `json:"EnableLocalPassword"`
OrderedViews []interface{} `json:"OrderedViews"`
LatestItemsExcludes []interface{} `json:"LatestItemsExcludes"`
MyMediaExcludes []interface{} `json:"MyMediaExcludes"`
HidePlayedInLatest bool `json:"HidePlayedInLatest"`
RememberAudioSelections bool `json:"RememberAudioSelections"`
RememberSubtitleSelections bool `json:"RememberSubtitleSelections"`
EnableNextEpisodeAutoPlay bool `json:"EnableNextEpisodeAutoPlay"`
}
type Policy struct {
IsAdministrator bool `json:"IsAdministrator"`
IsHidden bool `json:"IsHidden"`
IsDisabled bool `json:"IsDisabled"`
BlockedTags []interface{} `json:"BlockedTags"`
EnableUserPreferenceAccess bool `json:"EnableUserPreferenceAccess"`
AccessSchedules []interface{} `json:"AccessSchedules"`
BlockUnratedItems []interface{} `json:"BlockUnratedItems"`
EnableRemoteControlOfOtherUsers bool `json:"EnableRemoteControlOfOtherUsers"`
EnableSharedDeviceControl bool `json:"EnableSharedDeviceControl"`
EnableRemoteAccess bool `json:"EnableRemoteAccess"`
EnableLiveTvManagement bool `json:"EnableLiveTvManagement"`
EnableLiveTvAccess bool `json:"EnableLiveTvAccess"`
EnableMediaPlayback bool `json:"EnableMediaPlayback"`
EnableAudioPlaybackTranscoding bool `json:"EnableAudioPlaybackTranscoding"`
EnableVideoPlaybackTranscoding bool `json:"EnableVideoPlaybackTranscoding"`
EnablePlaybackRemuxing bool `json:"EnablePlaybackRemuxing"`
ForceRemoteSourceTranscoding bool `json:"ForceRemoteSourceTranscoding"`
EnableContentDeletion bool `json:"EnableContentDeletion"`
EnableContentDeletionFromFolders []interface{} `json:"EnableContentDeletionFromFolders"`
EnableContentDownloading bool `json:"EnableContentDownloading"`
EnableSyncTranscoding bool `json:"EnableSyncTranscoding"`
EnableMediaConversion bool `json:"EnableMediaConversion"`
EnabledDevices []interface{} `json:"EnabledDevices"`
EnableAllDevices bool `json:"EnableAllDevices"`
EnabledChannels []interface{} `json:"EnabledChannels"`
EnableAllChannels bool `json:"EnableAllChannels"`
EnabledFolders []string `json:"EnabledFolders"`
EnableAllFolders bool `json:"EnableAllFolders"`
InvalidLoginAttemptCount int `json:"InvalidLoginAttemptCount"`
LoginAttemptsBeforeLockout int `json:"LoginAttemptsBeforeLockout"`
MaxActiveSessions int `json:"MaxActiveSessions"`
EnablePublicSharing bool `json:"EnablePublicSharing"`
BlockedMediaFolders []interface{} `json:"BlockedMediaFolders"`
BlockedChannels []interface{} `json:"BlockedChannels"`
RemoteClientBitrateLimit int `json:"RemoteClientBitrateLimit"`
AuthenticationProviderID string `json:"AuthenticationProviderId"`
PasswordResetProviderID string `json:"PasswordResetProviderId"`
SyncPlayAccess string `json:"SyncPlayAccess"`
}

View File

@@ -29,11 +29,20 @@ type deleteUserDTO struct {
Reason string `json:"reason"` // Account deletion reason (for notification)
}
type enableDisableUserDTO struct {
Users []string `json:"users" binding:"required"` // List of usernames to delete
Enabled bool `json:"enabled"` // True = enable users, False = disable.
Notify bool `json:"notify"` // Whether to notify users of deletion
Reason string `json:"reason"` // Account deletion reason (for notification)
}
type generateInviteDTO struct {
Months int `json:"months" example:"0"` // Number of months
Days int `json:"days" example:"1"` // Number of days
Hours int `json:"hours" example:"2"` // Number of hours
Minutes int `json:"minutes" example:"3"` // Number of minutes
UserExpiry bool `json:"user-expiry"` // Whether or not user expiry is enabled
UserMonths int `json:"user-months,omitempty" example:"1"` // Number of months till user expiry
UserDays int `json:"user-days,omitempty" example:"1"` // Number of days till user expiry
UserHours int `json:"user-hours,omitempty" example:"2"` // Number of hours till user expiry
UserMinutes int `json:"user-minutes,omitempty" example:"3"` // Number of minutes till user expiry
@@ -72,23 +81,25 @@ type newProfileDTO struct {
}
type inviteDTO struct {
Code string `json:"code" example:"sajdlj23423j23"` // Invite code
Days int `json:"days" example:"1"` // Number of days till expiry
Hours int `json:"hours" example:"2"` // Number of hours till expiry
Minutes int `json:"minutes" example:"3"` // Number of minutes till expiry
UserExpiry bool `json:"user-expiry"` // Whether or not user expiry is enabled
UserDays int `json:"user-days,omitempty" example:"1"` // Number of days till user expiry
UserHours int `json:"user-hours,omitempty" example:"2"` // Number of hours till user expiry
UserMinutes int `json:"user-minutes,omitempty" example:"3"` // Number of minutes till user expiry
Created string `json:"created" example:"01/01/20 12:00"` // Date of creation
Profile string `json:"profile" example:"DefaultProfile"` // Profile used on this invite
UsedBy [][]string `json:"used-by,omitempty"` // Users who have used this invite
NoLimit bool `json:"no-limit,omitempty"` // If true, invite can be used any number of times
RemainingUses int `json:"remaining-uses,omitempty"` // Remaining number of uses (if applicable)
Email string `json:"email,omitempty"` // Email the invite was sent to (if applicable)
NotifyExpiry bool `json:"notify-expiry,omitempty"` // Whether to notify the requesting user of expiry or not
NotifyCreation bool `json:"notify-creation,omitempty"` // Whether to notify the requesting user of account creation or not
Label string `json:"label,omitempty" example:"For Friends"` // Optional label for the invite
Code string `json:"code" example:"sajdlj23423j23"` // Invite code
Months int `json:"months" example:"1"` // Number of months till expiry
Days int `json:"days" example:"1"` // Number of days till expiry
Hours int `json:"hours" example:"2"` // Number of hours till expiry
Minutes int `json:"minutes" example:"3"` // Number of minutes till expiry
UserExpiry bool `json:"user-expiry"` // Whether or not user expiry is enabled
UserMonths int `json:"user-months,omitempty" example:"1"` // Number of months till user expiry
UserDays int `json:"user-days,omitempty" example:"1"` // Number of days till user expiry
UserHours int `json:"user-hours,omitempty" example:"2"` // Number of hours till user expiry
UserMinutes int `json:"user-minutes,omitempty" example:"3"` // Number of minutes till user expiry
Created int64 `json:"created" example:"1617737207510"` // Date of creation
Profile string `json:"profile" example:"DefaultProfile"` // Profile used on this invite
UsedBy map[string]int64 `json:"used-by,omitempty"` // Users who have used this invite mapped to their creation time in Epoch/Unix time
NoLimit bool `json:"no-limit,omitempty"` // If true, invite can be used any number of times
RemainingUses int `json:"remaining-uses,omitempty"` // Remaining number of uses (if applicable)
Email string `json:"email,omitempty"` // Email the invite was sent to (if applicable)
NotifyExpiry bool `json:"notify-expiry,omitempty"` // Whether to notify the requesting user of expiry or not
NotifyCreation bool `json:"notify-creation,omitempty"` // Whether to notify the requesting user of account creation or not
Label string `json:"label,omitempty" example:"For Friends"` // Optional label for the invite
}
type getInvitesDTO struct {
@@ -112,9 +123,9 @@ type respUser struct {
ID string `json:"id" example:"fdgsdfg45534fa"` // userID of user
Name string `json:"name" example:"jeff"` // Username of user
Email string `json:"email,omitempty" example:"jeff@jellyf.in"` // Email address of user (if available)
LastActive string `json:"last_active"` // Time of last activity on Jellyfin
LastActive int64 `json:"last_active" example:"1617737207510"` // Time of last activity on Jellyfin
Admin bool `json:"admin" example:"false"` // Whether or not the user is Administrator
Expiry string `json:"expiry" example:"01/02/21 12:00"` // Expiry time of user, if applicable.
Expiry int64 `json:"expiry" example:"1617737207510"` // Expiry time of user as Epoch/Unix time.
Disabled bool `json:"disabled"` // Whether or not the user is disabled.
}
@@ -203,15 +214,17 @@ type emailTestDTO struct {
}
type customEmailDTO struct {
Content string `json:"content"`
Variables []string `json:"variables"`
Values map[string]interface{} `json:"values"`
HTML string `json:"html"`
Plaintext string `json:"plaintext"`
Content string `json:"content"`
Variables []string `json:"variables"`
Conditionals []string `json:"conditionals"`
Values map[string]interface{} `json:"values"`
HTML string `json:"html"`
Plaintext string `json:"plaintext"`
}
type extendExpiryDTO struct {
Users []string `json:"users"` // List of user IDs to apply to.
Months int `json:"months" example:"1"` // Number of months to add.
Days int `json:"days" example:"1"` // Number of days to add.
Hours int `json:"hours" example:"2"` // Number of hours to add.
Minutes int `json:"minutes" example:"3"` // Number of minutes to add.

View File

@@ -130,7 +130,7 @@ func (ombi *Ombi) ModifyUser(user map[string]interface{}) (status int, err error
err = fmt.Errorf("No ID provided")
return
}
_, status, err = ombi.put(ombi.server+"/api/v1/Identity", user, false)
_, status, err = ombi.put(ombi.server+"/api/v1/Identity/", user, false)
return
}

13
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"license": "ISC",
"dependencies": {
"@ts-stack/markdown": "^1.3.0",
"@types/node": "^15.0.1",
"a17t": "^0.4.0",
"esbuild": "^0.8.57",
"lodash": "^4.17.19",
@@ -35,9 +36,9 @@
}
},
"node_modules/@types/node": {
"version": "14.14.16",
"resolved": "https://registry.npm.taobao.org/@types/node/download/@types/node-14.14.16.tgz?cache=0&sync_timestamp=1608756036972&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40types%2Fnode%2Fdownload%2F%40types%2Fnode-14.14.16.tgz",
"integrity": "sha1-PMNR+NSBAd6t/tTJ5PEWBI1De0s="
"version": "15.0.1",
"resolved": "https://registry.nlark.com/@types/node/download/@types/node-15.0.1.tgz?cache=0&sync_timestamp=1619534647758&other_urls=https%3A%2F%2Fregistry.nlark.com%2F%40types%2Fnode%2Fdownload%2F%40types%2Fnode-15.0.1.tgz",
"integrity": "sha1-7zTeoIgQKNETmL5b9OhWdD49w1o="
},
"node_modules/a17t": {
"version": "0.4.0",
@@ -1835,9 +1836,9 @@
}
},
"@types/node": {
"version": "14.14.16",
"resolved": "https://registry.npm.taobao.org/@types/node/download/@types/node-14.14.16.tgz?cache=0&sync_timestamp=1608756036972&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40types%2Fnode%2Fdownload%2F%40types%2Fnode-14.14.16.tgz",
"integrity": "sha1-PMNR+NSBAd6t/tTJ5PEWBI1De0s="
"version": "15.0.1",
"resolved": "https://registry.nlark.com/@types/node/download/@types/node-15.0.1.tgz?cache=0&sync_timestamp=1619534647758&other_urls=https%3A%2F%2Fregistry.nlark.com%2F%40types%2Fnode%2Fdownload%2F%40types%2Fnode-15.0.1.tgz",
"integrity": "sha1-7zTeoIgQKNETmL5b9OhWdD49w1o="
},
"a17t": {
"version": "0.4.0",

View File

@@ -18,6 +18,7 @@
"homepage": "https://github.com/hrfee/jfa-go#readme",
"dependencies": {
"@ts-stack/markdown": "^1.3.0",
"@types/node": "^15.0.1",
"a17t": "^0.4.0",
"esbuild": "^0.8.57",
"lodash": "^4.17.19",

View File

@@ -85,7 +85,7 @@ func (app *appContext) loadRouter(address string, debug bool) *gin.Engine {
app.loadHTML(router)
router.Use(static.Serve("/", app.webFS))
router.NoRoute(app.NoRouteHandler)
if debug {
if *PPROF {
app.debug.Println("Loading pprof")
pprof.Register(router)
}
@@ -105,6 +105,11 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
router.GET(p+"/lang/:page", app.GetLanguages)
router.Use(static.Serve(p+"/", app.webFS))
router.GET(p+"/", app.AdminPage)
if app.config.Section("password_resets").Key("link_reset").MustBool(false) {
router.GET(p+"/reset", app.ResetPassword)
}
router.GET(p+"/accounts", app.AdminPage)
router.GET(p+"/settings", app.AdminPage)
router.GET(p+"/lang/:page/:file", app.ServeLang)
@@ -127,6 +132,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.GET(p+"/users", app.GetUsers)
api.POST(p+"/users", app.NewUserAdmin)
api.POST(p+"/users/extend", app.ExtendExpiry)
api.POST(p+"/users/enable", app.EnableDisableUsers)
api.POST(p+"/invites", app.GenerateInvite)
api.GET(p+"/invites", app.GetInvites)
api.DELETE(p+"/invites", app.DeleteInvite)
@@ -142,10 +148,10 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.POST(p+"/users/announce", app.Announce)
api.GET(p+"/config/update", app.CheckUpdate)
api.POST(p+"/config/update", app.ApplyUpdate)
api.GET(p+"/config/emails", app.GetEmails)
api.GET(p+"/config/emails/:id", app.GetEmail)
api.POST(p+"/config/emails/:id", app.SetEmail)
api.POST(p+"/config/emails/:id/state/:state", app.SetEmailState)
api.GET(p+"/config/emails", app.GetCustomEmails)
api.GET(p+"/config/emails/:id", app.GetCustomEmailTemplate)
api.POST(p+"/config/emails/:id", app.SetCustomEmail)
api.POST(p+"/config/emails/:id/state/:state", app.SetCustomEmailState)
api.GET(p+"/config", app.GetConfig)
api.POST(p+"/config", app.ModifyConfig)
api.POST(p+"/restart", app.restart)

View File

@@ -1,22 +0,0 @@
#!/usr/bin/python
import shutil
import sys
from pathlib import Path
external = ["false", "external", "no", "n"]
with open("embed.go", "w") as f:
choice = ""
try:
choice = sys.argv[1]
except IndexError:
pass
folder = Path("embed")
if choice in external:
embed = False
shutil.copy(folder / "external.go", "embed.go")
print("Embedding disabled. \"data\" must be placed alongside the executable.")
else:
shutil.copy(folder / "internal.go", "embed.go")
print("Embedding enabled.")

View File

@@ -7,8 +7,7 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/common"
"github.com/hrfee/jfa-go/mediabrowser"
"github.com/hrfee/mediabrowser"
)
func (app *appContext) ServeSetup(gc *gin.Context) {
@@ -60,7 +59,7 @@ func (app *appContext) TestJF(gc *gin.Context) {
if req.ServerType == "emby" {
serverType = mediabrowser.EmbyServer
}
tempjf, _ := mediabrowser.NewServer(serverType, req.Server, "jfa-go-setup", app.version, "auth", "auth", common.NewTimeoutHandler("authJF", req.Server, true), 30)
tempjf, _ := mediabrowser.NewServer(serverType, req.Server, "jfa-go-setup", app.version, "auth", "auth", mediabrowser.NewNamedTimeoutHandler("authJF", req.Server, true), 30)
_, status, err := tempjf.Authenticate(req.Username, req.Password)
if !(status == 200 || status == 204) || err != nil {
app.info.Printf("Auth failed with code %d (%s)", status, err)
@@ -74,7 +73,10 @@ func (app *appContext) TestJF(gc *gin.Context) {
func (st *Storage) loadLangSetup(filesystems ...fs.FS) error {
st.lang.Setup = map[string]setupLang{}
var english setupLang
load := func(filesystem fs.FS, fname string) error {
loadedLangs := make([]map[string]bool, len(filesystems))
var load loadLangFunc
load = func(fsIndex int, fname string) error {
filesystem := filesystems[fsIndex]
index := strings.TrimSuffix(fname, filepath.Ext(fname))
lang := setupLang{}
f, err := fs.ReadFile(filesystem, FSJoin(st.lang.SetupPath, fname))
@@ -85,21 +87,47 @@ func (st *Storage) loadLangSetup(filesystems ...fs.FS) error {
if err != nil {
return err
}
st.lang.Common.patchCommon(index, &lang.Strings)
st.lang.Common.patchCommon(&lang.Strings, index)
if fname != "en-us.json" {
patchLang(&english.Strings, &lang.Strings)
patchLang(&english.StartPage, &lang.StartPage)
patchLang(&english.Updates, &lang.Updates)
patchLang(&english.EndPage, &lang.EndPage)
patchLang(&english.Language, &lang.Language)
patchLang(&english.Login, &lang.Login)
patchLang(&english.JellyfinEmby, &lang.JellyfinEmby)
patchLang(&english.Email, &lang.Email)
patchLang(&english.Notifications, &lang.Notifications)
patchLang(&english.PasswordResets, &lang.PasswordResets)
patchLang(&english.InviteEmails, &lang.InviteEmails)
patchLang(&english.PasswordValidation, &lang.PasswordValidation)
patchLang(&english.HelpMessages, &lang.HelpMessages)
if lang.Meta.Fallback != "" {
fallback, ok := st.lang.Setup[lang.Meta.Fallback]
err = nil
if !ok {
err = load(fsIndex, lang.Meta.Fallback+".json")
fallback = st.lang.Setup[lang.Meta.Fallback]
}
if err == nil {
loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true
patchLang(&lang.Strings, &fallback.Strings, &english.Strings)
patchLang(&lang.StartPage, &fallback.StartPage, &english.StartPage)
patchLang(&lang.Updates, &fallback.Updates, &english.Updates)
patchLang(&lang.EndPage, &fallback.EndPage, &english.EndPage)
patchLang(&lang.Language, &fallback.Language, &english.Language)
patchLang(&lang.Login, &fallback.Login, &english.Login)
patchLang(&lang.JellyfinEmby, &fallback.JellyfinEmby, &english.JellyfinEmby)
patchLang(&lang.Email, &fallback.Email, &english.Email)
patchLang(&lang.Notifications, &fallback.Notifications, &english.Notifications)
patchLang(&lang.PasswordResets, &fallback.PasswordResets, &english.PasswordResets)
patchLang(&lang.InviteEmails, &fallback.InviteEmails, &english.InviteEmails)
patchLang(&lang.PasswordValidation, &fallback.PasswordValidation, &english.PasswordValidation)
patchLang(&lang.HelpMessages, &fallback.HelpMessages, &english.HelpMessages)
}
}
if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" {
patchLang(&lang.Strings, &english.Strings)
patchLang(&lang.StartPage, &english.StartPage)
patchLang(&lang.Updates, &english.Updates)
patchLang(&lang.EndPage, &english.EndPage)
patchLang(&lang.Language, &english.Language)
patchLang(&lang.Login, &english.Login)
patchLang(&lang.JellyfinEmby, &english.JellyfinEmby)
patchLang(&lang.Email, &english.Email)
patchLang(&lang.Notifications, &english.Notifications)
patchLang(&lang.PasswordResets, &english.PasswordResets)
patchLang(&lang.InviteEmails, &english.InviteEmails)
patchLang(&lang.PasswordValidation, &english.PasswordValidation)
patchLang(&lang.HelpMessages, &english.HelpMessages)
}
}
stringSettings, err := json.Marshal(lang)
if err != nil {
@@ -111,27 +139,30 @@ func (st *Storage) loadLangSetup(filesystems ...fs.FS) error {
}
engFound := false
var err error
for _, filesystem := range filesystems {
err = load(filesystem, "en-us.json")
for i := range filesystems {
loadedLangs[i] = map[string]bool{}
err = load(i, "en-us.json")
if err == nil {
engFound = true
}
loadedLangs[i]["en-us.json"] = true
}
if !engFound {
return err
}
english = st.lang.Setup["en-us"]
setupLoaded := false
for _, filesystem := range filesystems {
files, err := fs.ReadDir(filesystem, st.lang.SetupPath)
for i := range filesystems {
files, err := fs.ReadDir(filesystems[i], st.lang.SetupPath)
if err != nil {
return err
}
for _, f := range files {
if f.Name() != "en-us.json" {
err = load(filesystem, f.Name())
if !loadedLangs[i][f.Name()] {
err = load(i, f.Name())
if err == nil {
setupLoaded = true
loadedLangs[i][f.Name()] = true
}
}
}

View File

@@ -11,7 +11,7 @@ import (
"sync"
"time"
"github.com/hrfee/jfa-go/mediabrowser"
"github.com/hrfee/mediabrowser"
)
type Storage struct {
@@ -26,7 +26,7 @@ type Storage struct {
policy mediabrowser.Policy
configuration mediabrowser.Configuration
lang Lang
invitesLock sync.Mutex
invitesLock, usersLock sync.Mutex
}
type customEmails struct {
@@ -34,6 +34,8 @@ type customEmails struct {
InviteExpiry customEmail `json:"inviteExpiry"`
PasswordReset customEmail `json:"passwordReset"`
UserDeleted customEmail `json:"userDeleted"`
UserDisabled customEmail `json:"userDisabled"`
UserEnabled customEmail `json:"userEnabled"`
InviteEmail customEmail `json:"inviteEmail"`
WelcomeEmail customEmail `json:"welcomeEmail"`
EmailConfirmation customEmail `json:"emailConfirmation"`
@@ -41,9 +43,10 @@ type customEmails struct {
}
type customEmail struct {
Enabled bool `json:"enabled,omitempty"`
Content string `json:"content"`
Variables []string `json:"variables,omitempty"`
Enabled bool `json:"enabled,omitempty"`
Content string `json:"content"`
Variables []string `json:"variables,omitempty"`
Conditionals []string `json:"conditionals,omitempty"`
}
// timePattern: %Y-%m-%dT%H:%M:%S.%f
@@ -59,37 +62,42 @@ type Profile struct {
}
type Invite struct {
Created time.Time `json:"created"`
NoLimit bool `json:"no-limit"`
RemainingUses int `json:"remaining-uses"`
ValidTill time.Time `json:"valid_till"`
UserExpiry bool `json:"user-duration"`
UserDays int `json:"user-days,omitempty"`
UserHours int `json:"user-hours,omitempty"`
UserMinutes int `json:"user-minutes,omitempty"`
Email string `json:"email"`
UsedBy [][]string `json:"used-by"`
Notify map[string]map[string]bool `json:"notify"`
Profile string `json:"profile"`
Label string `json:"label,omitempty"`
Keys []string `json"keys,omitempty"`
Created time.Time `json:"created"`
NoLimit bool `json:"no-limit"`
RemainingUses int `json:"remaining-uses"`
ValidTill time.Time `json:"valid_till"`
UserExpiry bool `json:"user-duration"`
UserMonths int `json:"user-months,omitempty"`
UserDays int `json:"user-days,omitempty"`
UserHours int `json:"user-hours,omitempty"`
UserMinutes int `json:"user-minutes,omitempty"`
Email string `json:"email"`
// Used to be stored as formatted time, now as Unix.
UsedBy [][]string `json:"used-by"`
Notify map[string]map[string]bool `json:"notify"`
Profile string `json:"profile"`
Label string `json:"label,omitempty"`
Keys []string `json:"keys,omitempty"`
}
type Lang struct {
chosenFormLang string
chosenAdminLang string
chosenEmailLang string
AdminPath string
Admin adminLangs
AdminJSON map[string]string
FormPath string
Form formLangs
EmailPath string
Email emailLangs
CommonPath string
Common commonLangs
SetupPath string
Setup setupLangs
AdminPath string
chosenAdminLang string
Admin adminLangs
AdminJSON map[string]string
FormPath string
chosenFormLang string
Form formLangs
PasswordResetPath string
chosenPWRLang string
PasswordReset pwrLangs
EmailPath string
chosenEmailLang string
Email emailLangs
CommonPath string
Common commonLangs
SetupPath string
Setup setupLangs
}
func (st *Storage) loadLang(filesystems ...fs.FS) (err error) {
@@ -105,55 +113,94 @@ func (st *Storage) loadLang(filesystems ...fs.FS) (err error) {
if err != nil {
return
}
err = st.loadLangPWR(filesystems...)
if err != nil {
return
}
err = st.loadLangEmail(filesystems...)
return
}
func (common *commonLangs) patchCommon(lang string, other *langSection) {
if *other == nil {
*other = langSection{}
// The following patch* functions fill in a language with missing values
// from a list of other sources in a preferred order.
// languages to patch from should be in decreasing priority,
// E.g: If to = fr-be, from = [fr-fr, en-us].
func (common *commonLangs) patchCommon(to *langSection, from ...string) {
if *to == nil {
*to = langSection{}
}
if _, ok := (*common)[lang]; !ok {
lang = "en-us"
}
for n, ev := range (*common)[lang].Strings {
if v, ok := (*other)[n]; !ok || v == "" {
(*other)[n] = ev
for n, ev := range (*common)[from[len(from)-1]].Strings {
if v, ok := (*to)[n]; !ok || v == "" {
i := 0
for i < len(from)-1 {
ev, ok = (*common)[from[i]].Strings[n]
if ok && ev != "" {
break
}
i++
}
(*to)[n] = ev
}
}
}
// If a given language has missing values, fill it in with the english value.
func patchLang(english, other *langSection) {
if *other == nil {
*other = langSection{}
func patchLang(to *langSection, from ...*langSection) {
if *to == nil {
*to = langSection{}
}
for n, ev := range *english {
if v, ok := (*other)[n]; !ok || v == "" {
(*other)[n] = ev
for n, ev := range *from[len(from)-1] {
if v, ok := (*to)[n]; !ok || v == "" {
i := 0
for i < len(from)-1 {
ev, ok = (*from[i])[n]
if ok && ev != "" {
break
}
i++
}
(*to)[n] = ev
}
}
}
func patchQuantityStrings(english, other *map[string]quantityString) {
for n, ev := range *english {
qs, ok := (*other)[n]
if !ok {
(*other)[n] = ev
return
} else if qs.Singular == "" {
qs.Singular = ev.Singular
} else if (*other)[n].Plural == "" {
qs.Plural = ev.Plural
func patchQuantityStrings(to *map[string]quantityString, from ...*map[string]quantityString) {
if *to == nil {
*to = map[string]quantityString{}
}
for n, ev := range *from[len(from)-1] {
qs, ok := (*to)[n]
if !ok || qs.Singular == "" || qs.Plural == "" {
i := 0
subOk := false
for i < len(from)-1 {
ev, subOk = (*from[i])[n]
if subOk && ev.Singular != "" && ev.Plural != "" {
break
}
i++
}
if !ok {
(*to)[n] = ev
continue
} else if qs.Singular == "" {
qs.Singular = ev.Singular
} else if qs.Plural == "" {
qs.Plural = ev.Plural
}
(*to)[n] = qs
}
(*other)[n] = qs
}
}
type loadLangFunc func(fsIndex int, name string) error
func (st *Storage) loadLangCommon(filesystems ...fs.FS) error {
st.lang.Common = map[string]commonLang{}
var english commonLang
load := func(filesystem fs.FS, fname string) error {
loadedLangs := make([]map[string]bool, len(filesystems))
var load loadLangFunc
load = func(fsIndex int, fname string) error {
filesystem := filesystems[fsIndex]
index := strings.TrimSuffix(fname, filepath.Ext(fname))
lang := commonLang{}
f, err := fs.ReadFile(filesystem, FSJoin(st.lang.CommonPath, fname))
@@ -168,34 +215,51 @@ func (st *Storage) loadLangCommon(filesystems ...fs.FS) error {
return err
}
if fname != "en-us.json" {
patchLang(&english.Strings, &lang.Strings)
if lang.Meta.Fallback != "" {
fallback, ok := st.lang.Common[lang.Meta.Fallback]
err = nil
if !ok {
err = load(fsIndex, lang.Meta.Fallback+".json")
fallback = st.lang.Common[lang.Meta.Fallback]
}
if err == nil {
loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true
patchLang(&lang.Strings, &fallback.Strings, &english.Strings)
}
}
if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" {
patchLang(&lang.Strings, &english.Strings)
}
}
st.lang.Common[index] = lang
return nil
}
engFound := false
var err error
for _, filesystem := range filesystems {
err = load(filesystem, "en-us.json")
for i := range filesystems {
loadedLangs[i] = map[string]bool{}
err = load(i, "en-us.json")
if err == nil {
engFound = true
}
loadedLangs[i]["en-us.json"] = true
}
if !engFound {
return err
}
english = st.lang.Common["en-us"]
commonLoaded := false
for _, filesystem := range filesystems {
files, err := fs.ReadDir(filesystem, st.lang.CommonPath)
for i := range filesystems {
files, err := fs.ReadDir(filesystems[i], st.lang.CommonPath)
if err != nil {
continue
}
for _, f := range files {
if f.Name() != "en-us.json" {
err = load(filesystem, f.Name())
if !loadedLangs[i][f.Name()] {
err = load(i, f.Name())
if err == nil {
commonLoaded = true
loadedLangs[i][f.Name()] = true
}
}
}
@@ -209,7 +273,10 @@ func (st *Storage) loadLangCommon(filesystems ...fs.FS) error {
func (st *Storage) loadLangAdmin(filesystems ...fs.FS) error {
st.lang.Admin = map[string]adminLang{}
var english adminLang
load := func(filesystem fs.FS, fname string) error {
loadedLangs := make([]map[string]bool, len(filesystems))
var load loadLangFunc
load = func(fsIndex int, fname string) error {
filesystem := filesystems[fsIndex]
index := strings.TrimSuffix(fname, filepath.Ext(fname))
lang := adminLang{}
f, err := fs.ReadFile(filesystem, FSJoin(st.lang.AdminPath, fname))
@@ -223,11 +290,27 @@ func (st *Storage) loadLangAdmin(filesystems ...fs.FS) error {
if err != nil {
return err
}
st.lang.Common.patchCommon(index, &lang.Strings)
st.lang.Common.patchCommon(&lang.Strings, index)
if fname != "en-us.json" {
patchLang(&english.Strings, &lang.Strings)
patchLang(&english.Notifications, &lang.Notifications)
patchQuantityStrings(&english.QuantityStrings, &lang.QuantityStrings)
if lang.Meta.Fallback != "" {
fallback, ok := st.lang.Admin[lang.Meta.Fallback]
err = nil
if !ok {
err = load(fsIndex, lang.Meta.Fallback+".json")
fallback = st.lang.Admin[lang.Meta.Fallback]
}
if err == nil {
loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true
patchLang(&lang.Strings, &fallback.Strings, &english.Strings)
patchLang(&lang.Notifications, &fallback.Notifications, &english.Notifications)
patchQuantityStrings(&lang.QuantityStrings, &fallback.QuantityStrings, &english.QuantityStrings)
}
}
if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" {
patchLang(&lang.Strings, &english.Strings)
patchLang(&lang.Notifications, &english.Notifications)
patchQuantityStrings(&lang.QuantityStrings, &english.QuantityStrings)
}
}
stringAdmin, err := json.Marshal(lang)
if err != nil {
@@ -239,27 +322,30 @@ func (st *Storage) loadLangAdmin(filesystems ...fs.FS) error {
}
engFound := false
var err error
for _, filesystem := range filesystems {
err = load(filesystem, "en-us.json")
for i := range filesystems {
loadedLangs[i] = map[string]bool{}
err = load(i, "en-us.json")
if err == nil {
engFound = true
}
loadedLangs[i]["en-us.json"] = true
}
if !engFound {
return err
}
english = st.lang.Admin["en-us"]
adminLoaded := false
for _, filesystem := range filesystems {
files, err := fs.ReadDir(filesystem, st.lang.AdminPath)
for i := range filesystems {
files, err := fs.ReadDir(filesystems[i], st.lang.AdminPath)
if err != nil {
continue
}
for _, f := range files {
if f.Name() != "en-us.json" {
err = load(filesystem, f.Name())
if !loadedLangs[i][f.Name()] {
err = load(i, f.Name())
if err == nil {
adminLoaded = true
loadedLangs[i][f.Name()] = true
}
}
}
@@ -273,7 +359,10 @@ func (st *Storage) loadLangAdmin(filesystems ...fs.FS) error {
func (st *Storage) loadLangForm(filesystems ...fs.FS) error {
st.lang.Form = map[string]formLang{}
var english formLang
load := func(filesystem fs.FS, fname string) error {
loadedLangs := make([]map[string]bool, len(filesystems))
var load loadLangFunc
load = func(fsIndex int, fname string) error {
filesystem := filesystems[fsIndex]
index := strings.TrimSuffix(fname, filepath.Ext(fname))
lang := formLang{}
f, err := fs.ReadFile(filesystem, FSJoin(st.lang.FormPath, fname))
@@ -287,11 +376,27 @@ func (st *Storage) loadLangForm(filesystems ...fs.FS) error {
if err != nil {
return err
}
st.lang.Common.patchCommon(index, &lang.Strings)
st.lang.Common.patchCommon(&lang.Strings, index)
if fname != "en-us.json" {
patchLang(&english.Strings, &lang.Strings)
patchLang(&english.Notifications, &lang.Notifications)
patchQuantityStrings(&english.ValidationStrings, &lang.ValidationStrings)
if lang.Meta.Fallback != "" {
fallback, ok := st.lang.Form[lang.Meta.Fallback]
err = nil
if !ok {
err = load(fsIndex, lang.Meta.Fallback+".json")
fallback = st.lang.Form[lang.Meta.Fallback]
}
if err == nil {
loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true
patchLang(&lang.Strings, &fallback.Strings, &english.Strings)
patchLang(&lang.Notifications, &fallback.Notifications, &english.Notifications)
patchQuantityStrings(&lang.ValidationStrings, &fallback.ValidationStrings, &english.ValidationStrings)
}
}
if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" {
patchLang(&lang.Strings, &english.Strings)
patchLang(&lang.Notifications, &english.Notifications)
patchQuantityStrings(&lang.ValidationStrings, &english.ValidationStrings)
}
}
notifications, err := json.Marshal(lang.Notifications)
if err != nil {
@@ -308,27 +413,106 @@ func (st *Storage) loadLangForm(filesystems ...fs.FS) error {
}
engFound := false
var err error
for _, filesystem := range filesystems {
err = load(filesystem, "en-us.json")
for i := range filesystems {
loadedLangs[i] = map[string]bool{}
err = load(i, "en-us.json")
if err == nil {
engFound = true
}
loadedLangs[i]["en-us.json"] = true
}
if !engFound {
return err
}
english = st.lang.Form["en-us"]
formLoaded := false
for _, filesystem := range filesystems {
files, err := fs.ReadDir(filesystem, st.lang.FormPath)
for i := range filesystems {
files, err := fs.ReadDir(filesystems[i], st.lang.FormPath)
if err != nil {
continue
}
for _, f := range files {
if f.Name() != "en-us.json" {
err = load(filesystem, f.Name())
if !loadedLangs[i][f.Name()] {
err = load(i, f.Name())
if err == nil {
formLoaded = true
loadedLangs[i][f.Name()] = true
}
}
}
}
if !formLoaded {
return err
}
return nil
}
func (st *Storage) loadLangPWR(filesystems ...fs.FS) error {
st.lang.PasswordReset = map[string]pwrLang{}
var english pwrLang
loadedLangs := make([]map[string]bool, len(filesystems))
var load loadLangFunc
load = func(fsIndex int, fname string) error {
filesystem := filesystems[fsIndex]
index := strings.TrimSuffix(fname, filepath.Ext(fname))
lang := pwrLang{}
f, err := fs.ReadFile(filesystem, FSJoin(st.lang.PasswordResetPath, fname))
if err != nil {
return err
}
if substituteStrings != "" {
f = []byte(strings.ReplaceAll(string(f), "Jellyfin", substituteStrings))
}
err = json.Unmarshal(f, &lang)
if err != nil {
return err
}
st.lang.Common.patchCommon(&lang.Strings, index)
if fname != "en-us.json" {
if lang.Meta.Fallback != "" {
fallback, ok := st.lang.PasswordReset[lang.Meta.Fallback]
err = nil
if !ok {
err = load(fsIndex, lang.Meta.Fallback+".json")
fallback = st.lang.PasswordReset[lang.Meta.Fallback]
}
if err == nil {
patchLang(&lang.Strings, &fallback.Strings, &english.Strings)
}
}
if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" {
patchLang(&lang.Strings, &english.Strings)
}
}
st.lang.PasswordReset[index] = lang
return nil
}
engFound := false
var err error
for i := range filesystems {
loadedLangs[i] = map[string]bool{}
err = load(i, "en-us.json")
if err == nil {
engFound = true
}
loadedLangs[i]["en-us.json"] = true
}
if !engFound {
return err
}
english = st.lang.PasswordReset["en-us"]
formLoaded := false
for i := range filesystems {
files, err := fs.ReadDir(filesystems[i], st.lang.PasswordResetPath)
if err != nil {
continue
}
for _, f := range files {
if !loadedLangs[i][f.Name()] {
err = load(i, f.Name())
if err == nil {
formLoaded = true
loadedLangs[i][f.Name()] = true
}
}
}
@@ -342,7 +526,10 @@ func (st *Storage) loadLangForm(filesystems ...fs.FS) error {
func (st *Storage) loadLangEmail(filesystems ...fs.FS) error {
st.lang.Email = map[string]emailLang{}
var english emailLang
load := func(filesystem fs.FS, fname string) error {
loadedLangs := make([]map[string]bool, len(filesystems))
var load loadLangFunc
load = func(fsIndex int, fname string) error {
filesystem := filesystems[fsIndex]
index := strings.TrimSuffix(fname, filepath.Ext(fname))
lang := emailLang{}
f, err := fs.ReadFile(filesystem, FSJoin(st.lang.EmailPath, fname))
@@ -356,41 +543,73 @@ func (st *Storage) loadLangEmail(filesystems ...fs.FS) error {
if err != nil {
return err
}
st.lang.Common.patchCommon(index, &lang.Strings)
st.lang.Common.patchCommon(&lang.Strings, index)
if fname != "en-us.json" {
patchLang(&english.UserCreated, &lang.UserCreated)
patchLang(&english.InviteExpiry, &lang.InviteExpiry)
patchLang(&english.PasswordReset, &lang.PasswordReset)
patchLang(&english.UserDeleted, &lang.UserDeleted)
patchLang(&english.InviteEmail, &lang.InviteEmail)
patchLang(&english.WelcomeEmail, &lang.WelcomeEmail)
if lang.Meta.Fallback != "" {
fallback, ok := st.lang.Email[lang.Meta.Fallback]
err = nil
if !ok {
err = load(fsIndex, lang.Meta.Fallback+".json")
fallback = st.lang.Email[lang.Meta.Fallback]
}
if err == nil {
loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true
patchLang(&lang.UserCreated, &fallback.UserCreated, &english.UserCreated)
patchLang(&lang.InviteExpiry, &fallback.InviteExpiry, &english.InviteExpiry)
patchLang(&lang.PasswordReset, &fallback.PasswordReset, &english.PasswordReset)
patchLang(&lang.UserDeleted, &fallback.UserDeleted, &english.UserDeleted)
patchLang(&lang.UserDisabled, &fallback.UserDisabled, &english.UserDisabled)
patchLang(&lang.UserEnabled, &fallback.UserEnabled, &english.UserEnabled)
patchLang(&lang.InviteEmail, &fallback.InviteEmail, &english.InviteEmail)
patchLang(&lang.WelcomeEmail, &fallback.WelcomeEmail, &english.WelcomeEmail)
patchLang(&lang.EmailConfirmation, &fallback.EmailConfirmation, &english.EmailConfirmation)
patchLang(&lang.UserExpired, &fallback.UserExpired, &english.UserExpired)
patchLang(&lang.Strings, &fallback.Strings, &english.Strings)
}
}
if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" {
patchLang(&lang.UserCreated, &english.UserCreated)
patchLang(&lang.InviteExpiry, &english.InviteExpiry)
patchLang(&lang.PasswordReset, &english.PasswordReset)
patchLang(&lang.UserDeleted, &english.UserDeleted)
patchLang(&lang.UserDisabled, &english.UserDisabled)
patchLang(&lang.UserEnabled, &english.UserEnabled)
patchLang(&lang.InviteEmail, &english.InviteEmail)
patchLang(&lang.WelcomeEmail, &english.WelcomeEmail)
patchLang(&lang.EmailConfirmation, &english.EmailConfirmation)
patchLang(&lang.UserExpired, &english.UserExpired)
patchLang(&lang.Strings, &english.Strings)
}
}
st.lang.Email[index] = lang
return nil
}
engFound := false
var err error
for _, filesystem := range filesystems {
err = load(filesystem, "en-us.json")
for i := range filesystems {
loadedLangs[i] = map[string]bool{}
err = load(i, "en-us.json")
if err == nil {
engFound = true
}
loadedLangs[i]["en-us.json"] = true
}
if !engFound {
return err
}
english = st.lang.Email["en-us"]
emailLoaded := false
for _, filesystem := range filesystems {
files, err := fs.ReadDir(filesystem, st.lang.EmailPath)
for i := range filesystems {
files, err := fs.ReadDir(filesystems[i], st.lang.EmailPath)
if err != nil {
continue
}
for _, f := range files {
if f.Name() != "en-us.json" {
err = load(filesystem, f.Name())
if !loadedLangs[i][f.Name()] {
err = load(i, f.Name())
if err == nil {
emailLoaded = true
loadedLangs[i][f.Name()] = true
}
}
}
@@ -416,7 +635,22 @@ func (st *Storage) storeInvites() error {
}
func (st *Storage) loadUsers() error {
return loadJSON(st.users_path, &st.users)
st.usersLock.Lock()
defer st.usersLock.Unlock()
if st.users == nil {
st.users = map[string]time.Time{}
}
temp := map[string]time.Time{}
err := loadJSON(st.users_path, &temp)
if err != nil {
return err
}
for id, t1 := range temp {
if _, ok := st.users[id]; !ok {
st.users[id] = t1
}
}
return nil
}
func (st *Storage) storeUsers() error {
@@ -559,7 +793,7 @@ func hyphenate(userID string) string {
return userID[:8] + "-" + userID[8:12] + "-" + userID[12:16] + "-" + userID[16:20] + "-" + userID[20:]
}
func (app *appContext) deHyphenateEmailStorage(old map[string]interface{}) (map[string]interface{}, int, error) {
func (app *appContext) deHyphenateStorage(old map[string]interface{}) (map[string]interface{}, int, error) {
jfUsers, status, err := app.jf.GetUsers(false)
if status != 200 || err != nil {
return nil, status, err
@@ -568,15 +802,15 @@ func (app *appContext) deHyphenateEmailStorage(old map[string]interface{}) (map[
for _, user := range jfUsers {
unHyphenated := user.ID
hyphenated := hyphenate(unHyphenated)
email, ok := old[hyphenated]
val, ok := old[hyphenated]
if ok {
newEmails[unHyphenated] = email
newEmails[unHyphenated] = val
}
}
return newEmails, status, err
}
func (app *appContext) hyphenateEmailStorage(old map[string]interface{}) (map[string]interface{}, int, error) {
func (app *appContext) hyphenateStorage(old map[string]interface{}) (map[string]interface{}, int, error) {
jfUsers, status, err := app.jf.GetUsers(false)
if status != 200 || err != nil {
return nil, status, err
@@ -585,10 +819,50 @@ func (app *appContext) hyphenateEmailStorage(old map[string]interface{}) (map[st
for _, user := range jfUsers {
unstripped := user.ID
stripped := strings.ReplaceAll(unstripped, "-", "")
email, ok := old[stripped]
val, ok := old[stripped]
if ok {
newEmails[unstripped] = email
newEmails[unstripped] = val
}
}
return newEmails, status, err
}
func (app *appContext) hyphenateEmailStorage(old map[string]interface{}) (map[string]interface{}, int, error) {
return app.hyphenateStorage(old)
}
func (app *appContext) deHyphenateEmailStorage(old map[string]interface{}) (map[string]interface{}, int, error) {
return app.deHyphenateStorage(old)
}
func (app *appContext) hyphenateUserStorage(old map[string]time.Time) (map[string]time.Time, int, error) {
asInterface := map[string]interface{}{}
for k, v := range old {
asInterface[k] = v
}
fixed, status, err := app.hyphenateStorage(asInterface)
if err != nil {
return nil, status, err
}
out := map[string]time.Time{}
for k, v := range fixed {
out[k] = v.(time.Time)
}
return out, status, err
}
func (app *appContext) deHyphenateUserStorage(old map[string]time.Time) (map[string]time.Time, int, error) {
asInterface := map[string]interface{}{}
for k, v := range old {
asInterface[k] = v
}
fixed, status, err := app.deHyphenateStorage(asInterface)
if err != nil {
return nil, status, err
}
out := map[string]time.Time{}
for k, v := range fixed {
out[k] = v.(time.Time)
}
return out, status, err
}

View File

@@ -6,50 +6,41 @@ import (
stripmd "github.com/writeas/go-strip-markdown"
)
func stripMarkdown(md string) string {
// Search for markdown-formatted urls, and replace them with just the url, then use a library to strip any traces of markdown. You'll need some eyebleach after this.
foundOpenSquare := false
openSquare := -1
openBracket := -1
closeBracket := -1
openSquares := []int{}
closeBrackets := []int{}
links := []string{}
foundOpen := false
for i, c := range md {
if !foundOpenSquare && !foundOpen && c != '[' && c != ']' {
// StripAltText removes Markdown alt text from links and images and replaces them with just the URL.
// Currently uses the deepest alt text when links/images are nested.
func StripAltText(md string) string {
altTextStart := -1 // Start of alt text (between '[' & ']')
URLStart := -1 // Start of url (between '(' & ')')
URLEnd := -1
previousURLEnd := -2
out := ""
for i := range md {
if altTextStart != -1 && URLStart != -1 && md[i] == ')' {
URLEnd = i - 1
out += md[previousURLEnd+2:altTextStart-1] + md[URLStart:URLEnd+1]
previousURLEnd = URLEnd
altTextStart, URLStart, URLEnd = -1, -1, -1
continue
}
if c == '[' && md[i-1] != '!' {
foundOpenSquare = true
openSquare = i
} else if c == ']' {
if md[i+1] == '(' {
foundOpenSquare = false
foundOpen = true
openBracket = i + 1
continue
if md[i] == '[' && altTextStart == -1 {
altTextStart = i + 1
if i > 0 && md[i-1] == '!' {
altTextStart--
}
} else if c == ')' {
closeBracket = i
openSquares = append(openSquares, openSquare)
closeBrackets = append(closeBrackets, closeBracket)
links = append(links, md[openBracket+1:closeBracket])
openBracket = -1
closeBracket = -1
openSquare = -1
foundOpenSquare = false
foundOpen = false
}
if i > 0 && md[i-1] == ']' && md[i] == '(' && URLStart == -1 {
URLStart = i + 1
}
}
fullLinks := make([]string, len(openSquares))
for i := range openSquares {
if openSquares[i] != -1 && closeBrackets[i] != -1 {
fullLinks[i] = md[openSquares[i] : closeBrackets[i]+1]
}
if previousURLEnd+1 != len(md)-1 {
out += md[previousURLEnd+2:]
}
for i, _ := range openSquares {
md = strings.Replace(md, fullLinks[i], links[i], 1)
if out == "" {
return md
}
return strings.TrimPrefix(strings.TrimSuffix(stripmd.Strip(md), "</p>"), "<p>")
return out
}
func stripMarkdown(md string) string {
return strings.TrimPrefix(strings.TrimSuffix(stripmd.Strip(StripAltText(md)), "</p>"), "<p>")
}

117
template.go Normal file
View File

@@ -0,0 +1,117 @@
package main
import "fmt"
func truthy(val interface{}) bool {
switch v := val.(type) {
case string:
return v != ""
case bool:
return v
case int:
return v != 0
}
return false
}
// Templater for custom emails.
// Variables should be written as {varName}.
// If statements should be written as {if (!)varName}...{endif}.
// Strings are true if != "", ints are true if != 0.
func templateEmail(content string, variables []string, conditionals []string, values map[string]interface{}) string {
ifStart, ifEnd := -1, -1
ifTrue := false
invalidIf := false
previousEnd := -2
cStart, cEnd := -1, -1
varStart, varEnd := -1, -1
varName := ""
out := ""
for i, c := range content {
if c == '{' {
cStart = i + 1
for content[cStart] == ' ' {
cStart++
}
if content[cStart:cStart+3] == "if " {
varStart = cStart + 3
for content[varStart] == ' ' {
varStart++
}
}
if ifStart == -1 {
out += content[previousEnd+2 : i]
}
if content[cStart:cStart+5] != "endif" || invalidIf {
continue
}
ifEnd = i - 1
if ifTrue {
out += templateEmail(content[ifStart:ifEnd+1], variables, conditionals, values)
ifTrue = false
}
} else if c == '}' {
if varStart != -1 {
ifStart = i + 1
varEnd = i - 1
for content[varEnd] == ' ' {
varEnd--
}
varName = content[varStart : varEnd+1]
positive := true
if varName[0] == '!' {
positive = false
varName = varName[1:]
}
validVar := false
wrappedVarName := "{" + varName + "}"
for _, v := range conditionals {
if v == wrappedVarName {
validVar = true
break
}
}
if validVar {
ifTrue = positive == truthy(values[varName])
} else {
invalidIf = true
ifStart, ifEnd = -1, -1
}
varStart, varEnd = -1, -1
}
cEnd = i - 1
for content[cEnd] == ' ' {
cEnd--
}
previousEnd = i - 1
if content[cEnd-4:cEnd+1] == "endif" && !invalidIf {
continue
}
validVar := false
varName = content[cStart : cEnd+1]
cStart, cEnd = -1, -1
if ifStart != -1 {
continue
}
wrappedVarName := "{" + varName + "}"
for _, v := range variables {
if v == wrappedVarName {
validVar = true
break
}
}
if !validVar {
out += wrappedVarName
continue
}
out += fmt.Sprint(values[varName])
}
}
if previousEnd+1 != len(content)-1 {
out += content[previousEnd+2:]
}
if out == "" {
return content
}
return out
}

View File

@@ -1,5 +1,5 @@
import { Modal } from "./modules/modal.js";
import { _get, _post, toggleLoader } from "./modules/common.js";
import { _get, _post, toggleLoader, toDateString } from "./modules/common.js";
import { loadLangSelector } from "./modules/lang.js";
interface formWindow extends Window {
@@ -11,6 +11,7 @@ interface formWindow extends Window {
confirmation: boolean;
confirmationModal: Modal
userExpiryEnabled: boolean;
userExpiryMonths: number;
userExpiryDays: number;
userExpiryHours: number;
userExpiryMinutes: number;
@@ -43,10 +44,11 @@ if (window.userExpiryEnabled) {
const messageEl = document.getElementById("user-expiry-message") as HTMLElement;
const calculateTime = () => {
let time = new Date()
time.setMonth(time.getMonth() + window.userExpiryMonths);
time.setDate(time.getDate() + window.userExpiryDays);
time.setHours(time.getHours() + window.userExpiryHours);
time.setMinutes(time.getMinutes() + window.userExpiryMinutes);
messageEl.textContent = window.userExpiryMessage.replace("{date}", time.toDateString() + " " + time.toLocaleTimeString());
messageEl.textContent = window.userExpiryMessage.replace("{date}", toDateString(time));
setTimeout(calculateTime, 1000);
};
calculateTime();

Some files were not shown because too many files have changed in this diff Show More