Compare commits

...

351 Commits

Author SHA1 Message Date
Harvey Tindall
d60dea61db accounts: make all components of profile application optional
Basically, added the ability to -not- apply the profile's policy.
2024-07-31 15:54:05 +01:00
Harvey Tindall
a136800ff2 jellyseerr: set appropriate daemon period
was 30s for testing, is now every 10 minutes.
2024-07-31 15:32:49 +01:00
Harvey Tindall
db1c62cc46 jellyseerr: cleanup requests method, read proper errors
single req() function is wrapped by methods for each http method, and
error messages are parsed and returned if given by the server.

also added note about Jellyseerr's enforcement of unique email addresses
in settings.
2024-07-31 15:31:11 +01:00
Harvey Tindall
1fa340f096 jellyseerr: add option to auto-import users
"import_existing" option in settings enables an every 5-minute daemon
which loops through users and imports them to Jellyseerr and copies
contact info, if necessary. Also sets new API client flag
AutoImportUsers, which decides whether to automatically import non-existent users in
it's various methods.

also cleaned up the various daemons in the software, most now using the
GenericDaemon struct and just providing a new constructor.

broken page loop in jellyseerr client also fixed.
2024-07-31 15:02:25 +01:00
Harvey Tindall
2a6937228c accounts: make ombi/jellyseerr appliction optional on "modify settings"
Checkboxes added when applying from a profile.
2024-07-30 21:36:06 +01:00
Harvey Tindall
785395dd20 disable request logging 2024-07-30 20:56:48 +01:00
Harvey Tindall
385953b0cb jellyseerr: fix email setting, cover all contact adjustment areas
hopefully all places where contact methods can be adjusted should sync
with jellyseerr.
2024-07-30 20:55:45 +01:00
Harvey Tindall
35f8337a36 jellyseer: revert auto-nulling of NotifTypes
All-zeros in NotifTypes means the user shouldn't receive any
communication, which is a state we'd like to store in/apply from a profile.
2024-07-30 16:53:40 +01:00
Harvey Tindall
769a7c45da jellyseer: auto-null NotifTypes field
changed to a pointer so it can be nil-ed, and an Empty() receiver method
is used to check if it needs it in ApplyNotificationsTemplateToUser.
2024-07-30 16:49:35 +01:00
Harvey Tindall
a97bccc88f jellyseerr: use in profiles, apply on user creation and modification
added in the same way as ombi profiles. Most code is copy-pasted and
adjusted from ombi (especially on web), so maybe this can be merged in
the future. Also, profile names are url-escaped like announcement
template names were not too long ago. API client has "LogRequestBodies"
option which just dumps the request body when enabled (useful for
recreating reqs in the jellyseerr swagger UI). User.Name() helper
returns a name from all three possible values in the struct.
2024-07-30 16:44:46 +01:00
Harvey Tindall
7b9cdf385a jellyseerr: add notifications related methods
similar in style to User, with Notifications/NotificationsTemplate, and
named fields for modifying discord and telegram IDs, and two modify
methods.
2024-07-29 17:58:42 +01:00
Harvey Tindall
73e985c45c jellyseerr: add user modification methods
addded permissions get/set before realizing it already comes as part of
the User object. Split User attributes that will be templated into
UserTemplate struct, which User inherits. ApplyTemplateToUser takes a
UserTemplate, while ModifyUser takes a plain map with some typed fields
(display name and email, for now).
2024-07-29 17:36:29 +01:00
Harvey Tindall
9c34192b4f jellyseerr: start API client
Currently uses an API key (Seems simpler for the user than importing the
jfa-go user and granting perms). Strategy as follows:
* MustGetUser(jfID) function checks the cache for user, if not, calls
  Jellyseerr's importer passing jfID. From either, the user object is
  returned, which (in later commits) can be used to update the user's
  email (and potentially other info).

My API key is in there rn but its for a local testing instance, who
cares.
2024-07-29 16:46:37 +01:00
Harvey Tindall
dabef831d7 form: add more customizable success card
Success card could be customized simply with the "Success message"
setting, but a new "Post sign-up help card" in the Message editor
supports full markdown.
2024-07-28 20:05:13 +01:00
Harvey Tindall
e44d11c58c html: add noindex tag to header.html
should stop instances being indexed by search engines.
2024-07-28 17:10:06 +01:00
Harvey Tindall
48a2058e81 accounts: notify users of expiry adjustment
"Send notification message" in the extend expiry dialog will send a
message to the user with their new expiry. For #345.
2024-07-28 16:53:27 +01:00
Harvey Tindall
cd98e51ea9 adjust wording in expiry adjusted email
previously stated your account would be enabled, however the admin can
adjust expiry without re-enabling the user, so wording has been changed
to "your account may have been re-enabled.".
2024-07-28 16:52:35 +01:00
Harvey Tindall
fbbb03a47d email: add new "expiry adjusted email"
just the email at this point. Also wrote up a little guide on adding new
emails on wiki.jfa-go.com.
2024-07-28 16:02:47 +01:00
Harvey Tindall
6f5fc0948a css: fix inv creation card width on mobile
for #339. Cards were fixed at half-width, even when wrapping. Instead of
    fixing with breakpoints, remove the width specification and set each to
    "flex-grow: 1".
2024-07-28 13:49:25 +01:00
Harvey Tindall
05d473dc97 mediabrowser: bump version for JF 10.9.8
Adds missing field(s). For #349.
2024-07-28 13:41:48 +01:00
Harvey Tindall
e9e361ae60 ci: full clone for valid rev-list
so that deb packages have the correct epoch value.
2024-07-26 19:44:23 +01:00
Harvey Tindall
98d9bc62ff ci: attempt to fix deb repo processing
by calling `repo-process-deb` with trusty-unstable` rather than
`trusty` for unstable builds, because that seems to make sense.
To make sure, both are called. Same applies to stable.
2024-07-26 19:22:31 +01:00
Harvey Tindall
6f0f6e6901 ci: switch deb repo address 2024-07-26 18:22:47 +01:00
Harvey Tindall
c5d45355a8 attempt 2 at BUILT_BY in Docker CI 2024-07-26 16:25:38 +01:00
Harvey Tindall
1a85feb344 woodpecker: add "built by" for docker builds
hopefully works? but untested since it doesn't really matter.
2024-07-26 13:07:41 +01:00
Harvey Tindall
c75418db67 woodpecker: fix FIXME'd docker tags
forgot to change them from the private test repo.
2024-07-26 12:57:43 +01:00
Harvey Tindall
7c8e463929 ci: migrate to woodpecker on arm64
as part of migrating most of my services to a new server,
I've switched CI to woodpecker, a fork of drone. CI configs are now in
.woodpecker/, and are neater. The server runs on arm64, so the configs
and prerequisite jfa-go-build-docker are built to run there primarily.
They should work on other platforms still.

New CI is at ci.hrfee.dev.
2024-07-26 12:45:57 +01:00
Harvey Tindall
8f04f49086 docker: use jfa-go-build-docker for quicker builds
we already make the prerequisite container, why not use it for docker
builds as well?
2024-07-26 12:45:24 +01:00
Harvey Tindall
ec2f826dec goreleaser: support building on non-x86-64 host
jfa-go-build-docker is now built for an arm64 host primarily,
and the manually specified x86-64 (cross)compiler and pkg-config means
compilation will work on it (or another architecture in future).
2024-07-26 12:42:38 +01:00
Harvey Tindall
6b576f2ffe announce: URL encode/decode template labels
Fixes #340, allowing slashes (/) in label names which would break the
URL otherwise.
2024-07-21 17:45:36 +01:00
Harvey Tindall
7c989fda08 tls: don't "crash" on server close
TLS server section called Fatalf, while the normal section called Printf
on server close. Fatalf is now only called if the server wasn't shutdown
manually, e.g. when certificates are wrong. Same change was applied to
non-tls section, so crashes will actually occur when things like ports are occupied.

Fixes #343.
2024-07-21 17:27:41 +01:00
Harvey Tindall
a4d436b16b build: increase goreleaser build timeout
CI server is quite slow, so the 30m timeout of goreleaser was being
exceeded. Bumped to 60m, as defined by "TIMEOUT" in version.sh.
2024-07-21 14:29:35 +01:00
Harvey Tindall
9339992693 Merge branch 'main' of github.com:hrfee/jfa-go 2024-07-14 00:32:25 +01:00
Harvey Tindall
214d16cf0e goreleaser: increment version
no actual changes.
2024-07-14 00:30:01 +01:00
Harvey Tindall
a085e91cc6 Merge pull request #347 from jeppevinkel/patch-1
Fix referral url when subdomain contains `account`
2024-07-13 23:33:46 +01:00
Harvey Tindall
272c38e0c5 user: url split on pathname only 2024-07-13 14:22:05 +01:00
jeppevinkel
6052329c0b Fix deprecated georeleaser flag
Updated the `--skip-publish` flag to the new `--skip=publish` format.
2024-07-10 09:07:21 +02:00
jeppevinkel
acfdcdbc63 Fix referral url when subdomain contains account
This fix should work to grab the base url more consistently when the sub domain includes `account` in the name.
2024-07-10 01:22:12 +02:00
SimplyJanDE
a7529c7498 Translated using Weblate (German)
Currently translated at 93.6% (117 of 125 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/de/
2024-07-05 02:47:06 +02:00
gentertain
c85a7843d0 translation from Weblate (German)
Currently translated at 73.9% (196 of 265 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/de/
2024-07-05 02:47:06 +02:00
Sophia Walker
186bf30eca translation from Weblate (Chinese (Simplified))
Currently translated at 100.0% (265 of 265 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/zh_Hans/
2024-07-05 02:47:06 +02:00
Sophia Walker
45e74f6e33 translation from Weblate (Chinese (Simplified))
Currently translated at 100.0% (68 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/zh_Hans/
2024-07-05 02:47:06 +02:00
FLAV1N
59654b72e6 translation from Weblate (Indonesian)
Currently translated at 40.7% (108 of 265 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/id/
2024-07-05 02:47:06 +02:00
alison2033
d5531ed73e Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (12 of 12 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/pt_BR/
2024-07-05 02:47:06 +02:00
alison2033
ae208a87e0 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/pt_BR/
2024-07-05 02:47:06 +02:00
alison2033
0a56f7ceed Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (125 of 125 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/pt_BR/
2024-07-05 02:47:06 +02:00
alison2033
9678e5cc1a translation from Weblate (Portuguese (Brazil))
Currently translated at 100.0% (68 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/pt_BR/
2024-07-05 02:47:06 +02:00
alison2033
e4b335f4f6 translation from Weblate (Portuguese (Brazil))
Currently translated at 100.0% (265 of 265 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/pt_BR/
2024-07-05 02:47:06 +02:00
alison2033
b5ae5f94fd translation from Weblate (Portuguese (Brazil))
Currently translated at 100.0% (68 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/pt_BR/
2024-07-05 02:47:06 +02:00
alison2033
867aad7896 translation from Weblate (Portuguese (Brazil))
Currently translated at 68.3% (181 of 265 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/pt_BR/
2024-07-05 02:47:06 +02:00
alison2033
97f42b2f37 Translated using Weblate (Portuguese (Brazil))
Currently translated at 86.4% (108 of 125 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/pt_BR/
2024-07-05 02:47:06 +02:00
alison2033
59fbfdc8f3 translation from Weblate (Portuguese (Brazil))
Currently translated at 100.0% (68 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/pt_BR/
2024-07-05 02:47:06 +02:00
johndu30160
c8b89f412b Translated using Weblate (French)
Currently translated at 100.0% (12 of 12 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/fr/
2024-07-05 02:47:06 +02:00
johndu30160
f4038f00ed Translated using Weblate (French)
Currently translated at 99.2% (124 of 125 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/fr/
2024-07-05 02:47:06 +02:00
johndu30160
8091d4cba6 Translated using Weblate (French)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/fr/
2024-07-05 02:47:06 +02:00
johndu30160
189b1055e1 translation from Weblate (French)
Currently translated at 100.0% (68 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/fr/
2024-07-05 02:47:06 +02:00
johndu30160
2c00f7e5e6 translation from Weblate (French)
Currently translated at 100.0% (265 of 265 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/fr/
2024-07-05 02:47:06 +02:00
matsob0123
c2f592272d translation from Weblate (Polish)
Currently translated at 17.7% (47 of 265 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/pl/
2024-07-05 02:47:06 +02:00
greyluked
3fedc42a4a translation from Weblate (Spanish)
Currently translated at 67.5% (179 of 265 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/es/
2024-07-05 02:47:06 +02:00
greyluked
3c5826ae2f Translated using Weblate (Spanish)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/es/
2024-07-05 02:47:06 +02:00
greyluked
45d90f7459 translation from Weblate (Spanish)
Currently translated at 95.5% (65 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/es/
2024-07-05 02:47:06 +02:00
greyluked
d40acc855a Translated using Weblate (Spanish)
Currently translated at 100.0% (125 of 125 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/es/
2024-07-05 02:47:06 +02:00
Richard de Boer
8ee5377910 translation from Weblate (Dutch)
Currently translated at 100.0% (68 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/nl/
2024-07-05 02:47:06 +02:00
Richard de Boer
78c07aad3e translation from Weblate (Dutch)
Currently translated at 100.0% (265 of 265 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/nl/
2024-07-05 02:47:06 +02:00
Harvey Tindall
9df2a82b6d router: correctly use local FS for custom HTML 2024-05-26 18:08:48 +01:00
Harvey Tindall
11eae035d9 readme: cleanup and up-to-dateness 2023-12-28 14:24:21 +00:00
Harvey Tindall
66e6b68b8c userpage: make it clearer access control settings dont apply to it 2023-12-28 13:47:45 +00:00
Harvey Tindall
37156979d1 ui: fix layout when userpage card not shown on login 2023-12-28 13:45:00 +00:00
Harvey Tindall
d7c94edc61 bump api version 2023-12-26 22:46:12 +00:00
Harvey Tindall
46566fb98c fixup drone.yml once more for docker 2023-12-26 18:58:03 +00:00
Harvey Tindall
010b95a2f6 fixup drone.yml for release 2023-12-26 18:46:30 +00:00
undone37
8f2a28e650 translation from Weblate (German)
Currently translated at 71.7% (175 of 244 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/de/
2023-12-26 16:33:01 +01:00
nomadics9
8a6102b7b9 translation from Weblate (Arabic)
Currently translated at 29.9% (73 of 244 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2023-12-26 16:33:01 +01:00
nionionping
0ce5c9923d translation from Weblate (Chinese (Simplified))
Currently translated at 100.0% (244 of 244 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/zh_Hans/
2023-12-26 16:33:01 +01:00
nionionping
4073ebe534 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (12 of 12 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/zh_Hans/
2023-12-26 16:33:01 +01:00
nionionping
387fe082ef Translated using Weblate (Chinese (Simplified))
Currently translated at 97.6% (122 of 125 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/zh_Hans/
2023-12-26 16:33:01 +01:00
nionionping
ddc36ae897 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/zh_Hans/
2023-12-26 16:33:01 +01:00
nionionping
c62876ff3a translation from Weblate (Chinese (Simplified))
Currently translated at 100.0% (62 of 62 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/zh_Hans/
2023-12-26 16:33:01 +01:00
Richard de Boer
2fd71acbb2 Translated using Weblate (Dutch)
Currently translated at 99.2% (124 of 125 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/nl/
2023-12-26 16:33:01 +01:00
Richard de Boer
4c1d8ed2a1 translation from Weblate (Dutch)
Currently translated at 100.0% (244 of 244 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/nl/
2023-12-26 16:33:01 +01:00
Harvey Tindall
7223981280 done: use sshkey from secret, not file
update to appleboy/drone-ssh requires special ownership of the ssh key
file, which I can't be bothered with, so we'll source it from a secret
instead. Probably better anyway, that's how the other key was already.
2023-12-26 15:01:33 +00:00
Harvey Tindall
47536f3e63 readme: update latest compatible version 2023-12-26 14:51:53 +00:00
Harvey Tindall
ac4fecd819 site: fix for new font 2023-12-26 14:36:13 +00:00
Harvey Tindall
b75bd4d6c5 Crash on SSL cert/key error, describe issue in log
if serving ssl/tls fails, the cert/key files are checked to see if they
    are accessible, and any errors logged.
2023-12-26 14:19:22 +00:00
Harvey Tindall
2be7baea4a trim base css of most redundant classes 2023-12-24 18:55:58 +00:00
Harvey Tindall
d56d45a404 userpage: rework dynamic layout, finally 2023-12-24 18:26:35 +00:00
Harvey Tindall
b50d66d265 ui: more modal fixes 2023-12-24 15:16:11 +00:00
Harvey Tindall
aec0a5349a ui: fix remaining few modal sizes on mobile 2023-12-24 15:04:58 +00:00
Harvey Tindall
20560332ed invites: improve inv dropdown wrapping 2023-12-24 14:53:37 +00:00
Harvey Tindall
202ee0977e invites: improve inv dropdown layout 2023-12-24 14:34:04 +00:00
Harvey Tindall
f460bfcfc6 logip: fix user logging 2023-12-24 13:24:18 +00:00
Harvey Tindall
4f5d12f800 invites: ui adjustments, fix duration display > 1y 2023-12-24 02:29:14 +00:00
Harvey Tindall
9092b98b28 accounts: hide previous date example in extend expiry 2023-12-24 01:52:16 +00:00
Harvey Tindall
0f72a85724 accounts: allow extending expiry of more than one user 2023-12-24 01:45:11 +00:00
Harvey Tindall
0840931fed Merge (optional) IP logging 2023-12-24 01:06:07 +00:00
Harvey Tindall
00379824df Merge branch 'main' into kimboslice99-main 2023-12-23 21:53:39 +00:00
Harvey Tindall
f823705e40 ips: log on activities, show on card 2023-12-23 21:47:41 +00:00
Harvey Tindall
269836fc99 ips: add advanced settings for ip logging 2023-12-23 21:00:32 +00:00
Harvey Tindall
49d8c6f8e4 pwr: add captcha daemon 2023-12-23 20:18:16 +00:00
Harvey Tindall
278588ca39 pwr: functioning captcha/recaptcha 2023-12-23 20:10:48 +00:00
Harvey Tindall
ab05c07469 form: modularize captcha somewhat 2023-12-23 18:20:09 +00:00
kimboslice99
04c94ba55a Log IPs 2023-12-23 13:09:49 -05:00
Harvey Tindall
6e205760c3 ui: more invites page improvements/cleanup, fix tooltips on mobile 2023-12-23 17:45:18 +00:00
Harvey Tindall
82032b98a8 invites: improve invite wrapping on mobile 2023-12-23 15:36:28 +00:00
Harvey Tindall
e8666d5bf2 ui: general adjustments 2023-12-22 21:40:56 +00:00
Harvey Tindall
d1affe271c ui: wrap settings header 2023-12-22 18:36:21 +00:00
Harvey Tindall
ea109c7b63 ui: wrap accounts/activity headers 2023-12-22 18:06:12 +00:00
Harvey Tindall
cb5a8c1c23 accounts: position filter dropdown better for mobile 2023-12-22 17:46:57 +00:00
Harvey Tindall
7f518f55b2 Merge backups
Backups, manual & scheduled
2023-12-21 21:53:50 +00:00
Harvey Tindall
ca4fbc0ad5 backups: change update button wording 2023-12-21 21:40:24 +00:00
Harvey Tindall
b259dd7b00 backups: add wiki link 2023-12-21 21:38:42 +00:00
Harvey Tindall
dc2c2f1164 backups: show uploaded backups on-page 2023-12-21 21:11:40 +00:00
Harvey Tindall
bc2e9cffda backups: move code to own files 2023-12-21 18:17:03 +00:00
Harvey Tindall
ade032241a backups: upload and restore backup in-app 2023-12-21 18:12:58 +00:00
Harvey Tindall
eff313be41 backups: restore local backups in-app 2023-12-21 17:42:07 +00:00
Harvey Tindall
ff73c72b0e backups: add -restore cli argument 2023-12-21 17:27:28 +00:00
Harvey Tindall
1bb83c88d9 backups: add filesize to list 2023-12-21 16:51:33 +00:00
Harvey Tindall
195813c058 backups: triggerable in ui, viewable, downloadable
new "Backups" menu in settings lists all available backups, lets you
trigger a new one, and lets you download them.
2023-12-21 16:47:17 +00:00
Harvey Tindall
733ab37539 backups: add backup daemon to run every n minutes, keep x most recent backups 2023-12-21 13:03:16 +00:00
Harvey Tindall
c0c91b4aad drone: source buildrone key from drone in docker build 2023-12-20 20:06:44 +00:00
Harvey Tindall
83712a6937 pwr: fix set password for jellyfin PWRs 2023-12-20 19:04:40 +00:00
Harvey Tindall
290d02d248 pwr: include pwr-pin in build process, whoops
copying the PIN on the external PWR link page wasn't working since the
code's typescript wasn't being compiled.
2023-12-20 18:40:18 +00:00
Harvey Tindall
9cd402a15d logs: fix file identifier 2023-12-20 18:28:42 +00:00
Harvey Tindall
1a6897637f userpage: allow manual disable of pwr through username/email/contact
Checkboxes added to userpage settings allowing enabling/disabling of
specific ways of starting a PWR. For #312.
2023-12-20 18:18:39 +00:00
Harvey Tindall
213b1e7f9e accounts: allow setting exact expiry date
set with a text input field which uses the same date parsing library as
the search function. Parsed expiry date will appear once you've typed
something in, so you can make sure it's right.
2023-12-20 17:20:59 +00:00
Harvey Tindall
10c8d4ad2f accounts: add "remove expiry" 2023-11-16 11:19:49 +00:00
Harvey Tindall
4fcb58aefa userpage: fix referral card when no message set 2023-11-11 16:02:01 +00:00
Harvey Tindall
8c2a35f755 userpage: fix messages reset buttons 2023-11-11 15:59:05 +00:00
Harvey Tindall
a66c522b73 referrals: add "use expiry" option
adds an option when enabling referrals to use the duration of the source
invited (i.e., months, days, hours) for the referral invite. If enabled,
the user won't be able to make a new referral link after it expires. For
referrals enabled for new users via a profile, the clock starts ticking
as soon as the account is created.
2023-11-10 15:07:29 +00:00
mLgz0rn
d0de1142ae translation from Weblate (Danish)
Currently translated at 100.0% (62 of 62 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/da/
2023-10-27 01:54:24 +02:00
Richard de Boer
8d6ad7e3c8 Translated using Weblate (Dutch)
Currently translated at 100.0% (12 of 12 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/nl/
2023-10-27 01:54:24 +02:00
Richard de Boer
8ae5dd97b2 Translated using Weblate (Dutch)
Currently translated at 100.0% (120 of 120 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/nl/
2023-10-27 01:54:24 +02:00
Richard de Boer
cf747c1ddb Translated using Weblate (Dutch)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/nl/
2023-10-27 01:54:24 +02:00
Richard de Boer
8cb53d1c6f translation from Weblate (Dutch)
Currently translated at 100.0% (62 of 62 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/nl/
2023-10-27 01:54:24 +02:00
Richard de Boer
bd8ecebf89 translation from Weblate (Dutch)
Currently translated at 100.0% (194 of 194 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/nl/
2023-10-27 01:54:24 +02:00
mLgz0rn
09158b5bb5 translation from Weblate (Danish)
Currently translated at 100.0% (194 of 194 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/da/
2023-10-27 01:54:24 +02:00
mLgz0rn
aa30f1c392 Translated using Weblate (Danish)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/da/
2023-10-27 01:54:24 +02:00
mLgz0rn
4a2fc6d418 Translated using Weblate (Danish)
Currently translated at 100.0% (120 of 120 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/da/
2023-10-27 01:54:24 +02:00
mLgz0rn
1846e31bf5 Translated using Weblate (Danish)
Currently translated at 100.0% (12 of 12 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/da/
2023-10-27 01:54:23 +02:00
Harvey Tindall
1be20d471d Merge activity log
Activity log
2023-10-23 19:19:47 +01:00
Harvey Tindall
3739634b63 activity: fix "shown" counter when not in search 2023-10-23 18:36:32 +01:00
Harvey Tindall
3951116bdc activity: reload invites on link click 2023-10-23 18:18:08 +01:00
Harvey Tindall
a288ba4461 Merge remote-tracking branch 'origin/main' into activity-log 2023-10-23 18:00:56 +01:00
Harvey Tindall
f34ba5df18 invites: fix sending invite to @username discord format
whether something was an email or not was being decided by checking for
an "@", so the new format didn't work.
2023-10-23 17:59:18 +01:00
Harvey Tindall
44d7e173e3 activity: add limiting settings
limit to keeping n most recent logs, and/or logs younger than {n} days
in settings > Activity Log.
2023-10-23 12:50:42 +01:00
Harvey Tindall
663389693f activity: add counter for total, loaded and shown
total: number of activities in the DB
loaded: How many the web UI has loaded
shown: How many are shown (differs when in a search).
2023-10-23 11:34:04 +01:00
Harvey Tindall
591b843148 activity: add a "load all" button 2023-10-22 16:22:25 +01:00
Harvey Tindall
de3c06129d activity: pseudo links work on refresh 2023-10-22 15:02:03 +01:00
Harvey Tindall
0238c6778c activity: pseudo links work on click 2023-10-22 14:02:22 +01:00
Harvey Tindall
d00f3fcfbc admin: /activity pseudo-page now works 2023-10-22 12:31:06 +01:00
Harvey Tindall
47ce8a9ec4 activity: refresh, load more buttons, ui adjustments 2023-10-22 01:03:48 +01:00
Harvey Tindall
2d83718f81 activity: sort, load more, compromises for client-side search
my initial intent before starting search was for it to be server-sided,
considering this activity log could rack up 100s or 1000s of entries,
and then I forgot and did it client-sided.

this commit adds a feature to load more results when scrolled to the
bottom, and when a search returns few or no results (this is limited, so
it wont loop infinitely). Also finally got rid of the useless left
column, since my ideas didn't match my implementation.

also, sorting is only by date, can't be bothered with anything else.
2023-10-22 00:31:30 +01:00
Harvey Tindall
a0db685af2 activity: functional search (client-side)
search with filters for each type of card, and all the info in them.
Gonna somehow need to figure out what to do about pagination.
2023-10-21 16:24:14 +01:00
Harvey Tindall
4fa0630aef accounts: modularize search
now part of ts/modules/search.ts, UI of the activity page is gonna be
very similar so it made sense to.
2023-10-21 14:33:09 +01:00
Harvey Tindall
3cad30a8e5 activity: add delete button 2023-10-21 13:38:11 +01:00
Harvey Tindall
44172074b9 activity: render all activities correctly
the activity type, usernames, time, referrer, and invite code are
displayed correctly for all types of activity.
2023-10-21 13:00:06 +01:00
Harvey Tindall
1032e4e747 activity: more presentable cards, fixes
fixed some missing data (being stored and being shown), improved layout,
also usernames are now injected by the route.
2023-10-20 22:16:40 +01:00
Harvey Tindall
a73dfddd3f activity: partially functional frontend code
doesn't fill in all the blanks yet, but almost there ish. Filters &
stuff not done yet, just loads everything.
2023-10-20 18:14:32 +01:00
Harvey Tindall
274324557c activity: start stubbed out example card, beginning frontend code
completely broken, just need to commit so I can move between devices.
2023-10-20 00:06:10 +01:00
Harvey Tindall
5a0677bac8 activity: allow multiple types in route filter 2023-10-19 22:44:27 +01:00
Harvey Tindall
df1581d48e activity: route to show activity activity log
filterable by type, sortable by time, and paginated.
2023-10-19 22:10:42 +01:00
Harvey Tindall
9d1c7bba6f activity: log account link/unlinks 2023-10-19 21:17:03 +01:00
Harvey Tindall
b620c0d9ae activity: implement most initial logging
resetPassword, changePassword, delete/createInvite, enable/disable,
creation/deletion of invites & users are all done, only remaining one is
account linking.
2023-10-19 18:56:35 +01:00
Harvey Tindall
2c787b4d46 activity: log creations 2023-10-19 18:14:40 +01:00
Harvey Tindall
69dcaf3797 activity: Add initial data structure 2023-10-19 17:59:34 +01:00
Harvey Tindall
43e36ee6fc setup: Include proxy, test JF with it
Found on the 2nd page.
2023-10-19 17:19:52 +01:00
Harvey Tindall
53c9569a37 build: add notray windows build
better for daemonization with stuff like nssm.
2023-10-19 16:25:05 +01:00
Harvey Tindall
c39a9e80e7 daemon: ensure correct error before wiping user data
ensure the error is specifically "User not found", rather than a
connection error or such. For #303.
2023-10-19 15:04:31 +01:00
Harvey Tindall
3d0f756264 Merge SMTP Auth Option from @SquaredPotato
feat: Add SMTP authentication types to settings
2023-10-14 13:43:37 +01:00
Stefan Schokker
85de1c97ff feat: Add SMTP authentication types to settings 2023-10-14 14:29:34 +02:00
Harvey Tindall
2c8afecfbb lowercase lang 2023-10-14 13:19:05 +01:00
Harvey Tindall
4924700c52 Merge settings-search
Adds searchbox to settings
2023-10-14 13:17:50 +01:00
Harvey Tindall
e2c24a2593 accounts: add "not results found" screen 2023-10-14 13:07:30 +01:00
Harvey Tindall
31b7ede665 accounts: fix search button (again) 2023-10-14 12:52:10 +01:00
Harvey Tindall
dba7d0bd4e admin: improve searchboxes appearance
"Clear search" button is now fully over the search box, so the
focus/click effects fully wrap round it. Rounded edges of the button are
now only on the right edge.
2023-10-14 12:46:39 +01:00
Harvey Tindall
73cfa5bef2 settings: "no results found", section matching
No results found screen added, nd when a section name matches the
search, all settings in the section are shown normally.
2023-10-14 12:33:48 +01:00
Harvey Tindall
6909477f45 settings: hidden items in search explained
if a matched setting is hidden, an aside card will show explaining why,
    eitherbecause advanced settings is not enabled or because it depends
    on another setting.
2023-10-13 19:07:41 +01:00
Harvey Tindall
701d1305d3 settings: non-match search result have transparency
Matches have 100% opacity, non-matches have 50. Looks better than the
aside thing, doesn't break anything.
2023-10-13 15:52:53 +01:00
Harvey Tindall
08498074ed settings: funtioning search functionality
Search box and clear button work, curently matching settings are changed
to "aside"s for the border effect. Not super happy with how it looks
yet, and it messes up tooltips slightly.
2023-10-13 14:51:42 +01:00
brixik1
28d321986a Translated using Weblate (Czech)
Currently translated at 100.0% (120 of 120 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/cs/
2023-10-13 15:23:45 +02:00
brixik1
943d523f3f Translated using Weblate (Czech)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/cs/
2023-10-13 15:23:45 +02:00
brixik1
8f88b6aaa2 Translated using Weblate (Czech)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/cs/
2023-10-13 15:23:45 +02:00
brixik1
7f60598d4a Translated using Weblate (Czech)
Currently translated at 99.1% (119 of 120 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/cs/
2023-10-13 15:23:45 +02:00
brixik1
18e82fd04b translation from Weblate (Czech)
Currently translated at 100.0% (189 of 189 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/cs/
2023-10-13 15:23:45 +02:00
brixik1
d7d7146e12 Translated using Weblate (Czech)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/cs/
2023-10-13 15:23:45 +02:00
brixik1
aaa5217398 translation from Weblate (Czech)
Currently translated at 100.0% (62 of 62 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/cs/
2023-10-13 15:23:45 +02:00
brixik1
9610b89fa5 Translated using Weblate (Czech)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/cs/
2023-10-13 15:23:45 +02:00
brixik1
9809611d0d add translation from Weblate (Czech) 2023-10-13 15:23:45 +02:00
brixik1
b1e38ba15d Added translation using Weblate (Czech) 2023-10-13 15:23:45 +02:00
brixik1
35a765aa01 Added translation using Weblate (Czech) 2023-10-13 15:23:45 +02:00
brixik1
82411f1868 Added translation using Weblate (Czech) 2023-10-13 15:23:45 +02:00
brixik1
b0e01144f4 Added translation using Weblate (Czech) 2023-10-13 15:23:45 +02:00
brixik1
04f354b3d1 Added translation using Weblate (Czech) 2023-10-13 15:23:45 +02:00
brixik1
918f3ad588 add translation from Weblate (Czech) 2023-10-13 15:23:45 +02:00
Harvey Tindall
635c2be32c settings: initial search function
not really plugged into anything yet.
2023-10-13 10:30:59 +01:00
Harvey Tindall
3143d32b45 log: include caller in debug storage logging
includes the location where Set*Key/Delete*Key was called.
2023-10-12 18:21:47 +01:00
Harvey Tindall
742f5c095a log: add basic database write debug logging
A series of settings can be found in Settings > Advanced for logging
writes to the database, for each main storage object. "All" logs all
writes, "Deletion" logs Delete* Calls and Write* calls where the
principal data in the object (e.g. address in an EmailAddress object) is
set to "".
2023-10-12 18:12:18 +01:00
Harvey Tindall
7b2a6cdf74 discord: merge /inv from @VioletLeporid
Adds the /inv command to send an invite directly to a Discord user.
2023-10-12 09:25:33 +01:00
Harvey Tindall
2f3d5e4e3a discord: update profile list when changes occur 2023-10-11 12:00:38 +01:00
Harvey Tindall
2fb2f3ee74 discord: send error message when inv construction fails 2023-10-11 11:38:55 +01:00
Harvey Tindall
7813c8c68b discord: Use GenerateInviteCode in /inv 2023-10-11 11:35:08 +01:00
Harvey Tindall
e528f7c348 Merge latest changes 2023-10-11 11:33:51 +01:00
Harvey Tindall
77f6b1042e invites: move code gen to function
code to generate an invite code w/ a non-integer first character was
reused a bunch, so it's now function GenerateInviteCode().
2023-10-11 11:30:28 +01:00
Harvey Tindall
7db94dcebf Merge /inv command additions
Merge branch 'main' of github.com:VioletLeporid/jfa-go
2023-10-11 11:25:00 +01:00
Violet Scheen
70afc21217 Merge branch 'hrfee:main' into main 2023-10-10 13:51:51 -04:00
Violet Scheen
525c13ff6a Update discord.go 2023-10-10 12:55:56 -04:00
Violet Scheen
0366e5116d Update discord.go
Cleaning up a bit
2023-10-10 11:14:57 -04:00
Harvey Tindall
62923d5e45 discord: register available profiles for /inv
profiles are registered as options for /inv as startup. Note in
description added to restart jfa-go to reload them.
2023-10-10 15:15:25 +01:00
Harvey Tindall
10a32ad1ae discord: re-add optional args 2023-10-10 14:52:54 +01:00
Harvey Tindall
e52e21a54b discord: fix up /inv basic functionality
sending now succeeds, and a reponse of "Invite sent." is given to the
requester. Also some formatting changes.
2023-10-10 13:45:29 +01:00
Harvey Tindall
7c861e5763 lang: fix the usual mistakes
someone directly translating "English (US)", and lowercasing lang files.
2023-10-10 10:36:57 +01:00
Harvey Tindall
9c771e193e lang: fix typo in french
`{n]` instead of `{n}` meant expiry times on the user page weren't being
rendered.
2023-10-10 10:22:22 +01:00
Killianbe
f37451021f translation from Weblate (French)
Currently translated at 100.0% (188 of 188 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/fr/
2023-10-10 11:18:21 +02:00
Killianbe
4aa095d466 Translated using Weblate (French)
Currently translated at 92.5% (111 of 120 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/fr/
2023-10-10 11:18:21 +02:00
Killianbe
638be18ea8 Translated using Weblate (French)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/fr/
2023-10-10 11:18:21 +02:00
Killianbe
42264f0547 translation from Weblate (French)
Currently translated at 100.0% (62 of 62 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/fr/
2023-10-10 11:18:21 +02:00
Killianbe
07d738006f translation from Weblate (French)
Currently translated at 96.8% (182 of 188 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/fr/
2023-10-10 11:18:21 +02:00
Anton B
4bc51570c2 Translated using Weblate (Swedish)
Currently translated at 82.3% (42 of 51 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/sv/
2023-10-10 11:18:21 +02:00
Harvey Tindall
cf94fdb2f0 ombi: fix password reset on default route
the ombi password wasn't being changed w/ password resets initiated
through the admin page (and probably by other routes, too), as the code
was considering a HTTP 204 from Jellyfin as a failure, causing it to
skip anything with Ombi. Also added a little check for Ombi-imported
accounts that probably won't help with anything, but whatever.
2023-10-09 10:40:40 +01:00
Harvey Tindall
4864c6c53c ombi: implement getOmbiImportedUser 2023-10-06 14:41:33 +01:00
Harvey Tindall
5702e8012c proxy: use for updater & SMTP
imports a new package to create a HTTP proxy dialer for the SMTP client.
2023-10-05 12:32:25 +01:00
Harvey Tindall
523902f951 proxy: add http/socks5 support, use for Jellyfin
can be found in advanced. Currently only used for the main Jellyfin
client.
2023-10-05 11:25:58 +01:00
Violet Scheen
dd93758b0e Update discord.go 2023-10-03 23:59:42 -04:00
Violet Scheen
b595d3ea03 Update discord.go 2023-10-03 23:37:24 -04:00
Violet Scheen
49dfac514d Update discord.go 2023-10-03 23:22:20 -04:00
Harvey Tindall
543f23c8ef userpage: make refresh token work w/ reverse proxy
potentially for #290.
2023-10-03 09:44:05 +01:00
Harvey Tindall
f6fdd41b35 jellyfin: retry initial connection (configurable)
retries initial connection to Jellyfin 6 times, with a 10s gap between,
before failing. SHould help with issues of jfa-go starting before
Jellyfin.
Configurable in Settings > Advanced > "Initial auth retry count/gap".
2023-10-03 09:33:56 +01:00
Harvey Tindall
4f78b7c33b admin: option link to my account page on login screen 2023-10-02 10:56:50 +01:00
Harvey Tindall
9956bbd974 admin: add setting to hide background on login
for #288.
2023-10-02 10:34:14 +01:00
Harvey Tindall
ff1ea8549a userpage: register routes on reverse proxy subfolder
fixes #289.
2023-10-02 09:45:42 +01:00
Harvey Tindall
5a2d3d2ee2 admin: My Account button respects URL Base 2023-10-02 09:40:19 +01:00
Violet Scheen
729548334d Update discord.go 2023-09-30 12:16:06 -04:00
Violet Scheen
27f85f866e Update discord.go
Hopefully functional, any errors are coming from elsewhere
2023-09-30 12:10:38 -04:00
Violet Scheen
c43d5cf1b0 Update discord.go 2023-09-30 11:25:36 -04:00
HekeHokkus
3538935d3b Update discord.go
Adding /invite command and Discord status message to the bot
2023-09-28 19:37:35 -04:00
HekeHokkus
edf6c13f03 Update discord.go 2023-09-28 17:55:47 -04:00
HekeHokkus
b30d6c3ee1 Update discord.go 2023-09-28 15:54:48 -04:00
da lo
3ff5e6555a Translated using Weblate (German)
Currently translated at 100.0% (114 of 114 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/de/
2023-09-24 21:08:56 +01:00
da lo
2430fc68ba translation from Weblate (German)
Currently translated at 86.4% (51 of 59 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/de/
2023-09-24 21:08:56 +01:00
da lo
bc8f6b7cd6 translation from Weblate (German)
Currently translated at 98.8% (176 of 178 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/de/
2023-09-24 21:08:56 +01:00
jim608
e31d11e2bb Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (114 of 114 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/zh_Hant/
2023-09-24 21:08:56 +01:00
jim608
3d45f2b95e translation from Weblate (Chinese (Traditional))
Currently translated at 100.0% (59 of 59 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/zh_Hant/
2023-09-24 21:08:56 +01:00
Bgabor997
80ebafa9f9 translation from Weblate (Hungarian)
Currently translated at 57.8% (103 of 178 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/hu/
2023-09-24 21:08:54 +01:00
Lican-X
471497ff6a translation from Weblate (Spanish)
Currently translated at 91.5% (163 of 178 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/es/
2023-09-24 21:07:57 +01:00
Lican-X
1badc4975e Translated using Weblate (Spanish)
Currently translated at 99.1% (113 of 114 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/es/
2023-09-24 21:07:57 +01:00
Bgabor997
0728c8bdd3 Translated using Weblate (Hungarian)
Currently translated at 1.9% (1 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/hu/
2023-09-24 21:07:57 +01:00
Bgabor997
498f7bd29b Translated using Weblate (Hungarian)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/hu/
2023-09-24 21:07:57 +01:00
Bgabor997
ad3e6ad7dc Translated using Weblate (Hungarian)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/hu/
2023-09-24 21:07:56 +01:00
Bgabor997
e2b975ac9c Translated using Weblate (Hungarian)
Currently translated at 100.0% (49 of 49 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/hu/
2023-09-24 21:07:56 +01:00
Bgabor997
b7bf1f835e Translated using Weblate (Hungarian)
Currently translated at 17.5% (20 of 114 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/hu/
2023-09-24 21:07:56 +01:00
Bgabor997
525eaab4bb translation from Weblate (Hungarian)
Currently translated at 42.1% (75 of 178 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/hu/
2023-09-24 21:07:56 +01:00
Bgabor997
a67119d1ec translation from Weblate (Hungarian)
Currently translated at 100.0% (59 of 59 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/hu/
2023-09-24 21:07:56 +01:00
Thomas Widyantoko
10cc130674 translation from Weblate (Indonesian)
Currently translated at 86.4% (51 of 59 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/id/
2023-09-24 21:07:56 +01:00
Thomas Widyantoko
044ce6fbd8 translation from Weblate (Indonesian)
Currently translated at 60.6% (108 of 178 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/id/
2023-09-24 21:07:56 +01:00
Thomas Widyantoko
a4bb2de901 Translated using Weblate (Indonesian)
Currently translated at 46.9% (23 of 49 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/id/
2023-09-24 21:07:56 +01:00
Bgabor997
05df04b754 Added translation using Weblate (Hungarian) 2023-09-24 21:07:56 +01:00
Bgabor997
998b719f38 Added translation using Weblate (Hungarian) 2023-09-24 21:07:56 +01:00
Bgabor997
2ec34278cc Added translation using Weblate (Hungarian) 2023-09-24 21:07:56 +01:00
KSAm3lm
0dbe058433 Translated using Weblate (Arabic)
Currently translated at 100.0% (49 of 49 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/ar/
2023-09-24 21:07:56 +01:00
KSAm3lm
4ddb7dce32 translation from Weblate (Arabic)
Currently translated at 100.0% (59 of 59 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/ar/
2023-09-24 21:07:56 +01:00
KSAm3lm
148c36cb64 Translated using Weblate (Arabic)
Currently translated at 14.0% (16 of 114 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/ar/
2023-09-24 21:07:56 +01:00
KSAm3lm
448df6c1e3 Translated using Weblate (Arabic)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/ar/
2023-09-24 21:07:56 +01:00
KSAm3lm
72c616811b translation from Weblate (Arabic)
Currently translated at 28.6% (51 of 178 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2023-09-24 21:07:56 +01:00
KSAm3lm
2a816b397c Translated using Weblate (Arabic)
Currently translated at 100.0% (49 of 49 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/ar/
2023-09-24 21:07:56 +01:00
KSAm3lm
bb9e94c632 Translated using Weblate (Arabic)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/ar/
2023-09-24 21:07:56 +01:00
KSAm3lm
fec7a7aa70 translation from Weblate (Arabic)
Currently translated at 100.0% (59 of 59 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/ar/
2023-09-24 21:07:56 +01:00
KSAm3lm
f9a5e32ec9 Translated using Weblate (Arabic)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/ar/
2023-09-24 21:07:56 +01:00
Edward
4551ae3fa1 translation from Weblate (Chinese (Simplified))
Currently translated at 100.0% (178 of 178 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/zh_Hans/
2023-09-24 21:07:56 +01:00
Edward
a2af9ca4d2 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (114 of 114 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/zh_Hans/
2023-09-24 21:07:56 +01:00
Edward
623934c980 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (49 of 49 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/zh_Hans/
2023-09-24 21:07:55 +01:00
Edward
720ff1f7a6 translation from Weblate (Chinese (Simplified))
Currently translated at 100.0% (59 of 59 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/zh_Hans/
2023-09-24 21:07:32 +01:00
Someone
68e062ff08 Translated using Weblate (French)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/fr/
2023-09-24 21:07:32 +01:00
Someone
d6176d3f39 Translated using Weblate (French)
Currently translated at 100.0% (114 of 114 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/fr/
2023-09-24 21:07:32 +01:00
Someone
edd3aeba16 Translated using Weblate (French)
Currently translated at 100.0% (49 of 49 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/fr/
2023-09-24 21:07:30 +01:00
RobertRvB
cf3efe770d translation from Weblate (Dutch)
Currently translated at 88.1% (52 of 59 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/nl/
2023-09-24 21:07:08 +01:00
Someone
826deec8a8 translation from Weblate (French)
Currently translated at 100.0% (59 of 59 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/fr/
2023-09-24 21:07:08 +01:00
Someone
fc8910ffee translation from Weblate (French)
Currently translated at 100.0% (178 of 178 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/fr/
2023-09-24 21:07:08 +01:00
tacooc
41bd828367 Translated using Weblate (Arabic)
Currently translated at 8.7% (10 of 114 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/ar/
2023-09-24 21:07:08 +01:00
tacooc
ddb996882f Translated using Weblate (Arabic)
Currently translated at 37.2% (19 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/ar/
2023-09-24 21:07:08 +01:00
tacooc
5d1917efa2 Translated using Weblate (Arabic)
Currently translated at 100.0% (49 of 49 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/ar/
2023-09-24 21:07:08 +01:00
tacooc
9ec54ecec8 Translated using Weblate (Arabic)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/ar/
2023-09-24 21:07:08 +01:00
tacooc
8f25e18c53 Translated using Weblate (Arabic)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/ar/
2023-09-24 21:07:08 +01:00
liimee
a734afaabe translation from Weblate (Indonesian)
Currently translated at 84.7% (50 of 59 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/id/
2023-09-24 21:07:08 +01:00
tacooc
ca87a3f93f Added translation using Weblate (Arabic) 2023-09-24 21:07:08 +01:00
tacooc
a8d9c90bfa Added translation using Weblate (Arabic) 2023-09-24 21:07:08 +01:00
tacooc
68a2a945f9 add translation from Weblate (Arabic) 2023-09-24 21:07:08 +01:00
Davide Casella
3c45fcbef2 Translated using Weblate (Italian)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/it/
2023-09-24 21:07:08 +01:00
Davide Casella
71efae7300 Translated using Weblate (Italian)
Currently translated at 70.1% (80 of 114 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/it/
2023-09-24 21:07:08 +01:00
Francesco
25b5ae398e Translated using Weblate (Italian)
Currently translated at 70.0% (7 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/it/
2023-09-24 21:07:08 +01:00
Francesco
86b540c13a translation from Weblate (Italian)
Currently translated at 1.1% (2 of 178 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/it/
2023-09-24 21:07:08 +01:00
Francesco
971007fc3f Translated using Weblate (Italian)
Currently translated at 29.8% (34 of 114 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/it/
2023-09-24 21:07:07 +01:00
Francesco
c006575498 Translated using Weblate (Italian)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/it/
2023-09-24 21:07:07 +01:00
Francesco
51917c5403 Translated using Weblate (Italian)
Currently translated at 100.0% (49 of 49 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/it/
2023-09-24 21:07:03 +01:00
Francesco
cc226bfe9e Translated using Weblate (Italian)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/it/
2023-09-24 21:06:37 +01:00
Francesco
a965f7fbb0 translation from Weblate (Italian)
Currently translated at 100.0% (59 of 59 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/it/
2023-09-24 21:06:37 +01:00
Francesco
7bcc949a17 Added translation using Weblate (Italian) 2023-09-24 21:06:37 +01:00
Francesco
a4d9d83fac add translation from Weblate (Italian) 2023-09-24 21:06:37 +01:00
Francesco
8edf712669 Added translation using Weblate (Italian) 2023-09-24 21:06:37 +01:00
Harvey Tindall
084f8aa658 settings: link to new PWR wiki article in note 2023-09-09 16:02:39 +01:00
Harvey Tindall
084a62e60f updater: account for build/publish time diff 2023-09-08 19:00:19 +01:00
Harvey Tindall
655dc88c62 node: update deps 2023-09-08 18:21:09 +01:00
Harvey Tindall
46109d1ea3 images: one more try 2023-09-08 17:46:58 +01:00
Harvey Tindall
f7d931be0c images: use non-svgo-ed banner 2023-09-08 17:45:00 +01:00
Harvey Tindall
8d6af53e54 images: fix main banner 2023-09-08 17:43:36 +01:00
Harvey Tindall
8d3bd52fc5 images: use hanken grotesk in banners 2023-09-08 17:41:27 +01:00
Harvey Tindall
8da95ed824 accounts: make sort-by card height = filter card height 2023-09-08 17:00:37 +01:00
Harvey Tindall
8207a75820 accounts: sort by referrals 2023-09-08 16:54:07 +01:00
Harvey Tindall
2c48ce0152 settings: rename "User Page" 2023-09-08 16:50:33 +01:00
Harvey Tindall
dae0ad1de5 invites: "User Label" 2/2
applies label to users. Also hide the user label element on the invite
dropdown when not set.
2023-09-08 14:37:07 +01:00
Harvey Tindall
7c76b58ab8 invites: add "User Label" 1/2
Adds a "User Label" invite field, which is a label applied to users
created with it. This commit contains everything apart from the code to
apply it on account creation.
2023-09-08 14:29:25 +01:00
Harvey Tindall
4ea2dfdfb7 accounts: fix announcements preview window
since the "Announcement" template doesn't actually exist, finding it in
the DB would fail, which is now ignored.
2023-09-08 13:54:01 +01:00
Harvey Tindall
d8d478a95e form: move referral info message to aside, change wording 2023-09-08 13:31:05 +01:00
Harvey Tindall
4c20250888 userpage: actually sub {username} 2023-09-07 23:40:15 +01:00
Harvey Tindall
f5a15905e4 userpage: fix email change modal show/hide 2023-09-07 23:35:41 +01:00
Harvey Tindall
53742e5ec2 setup: encourage user to browse settings for new features
Lots of things aren't present in setup, including the my account page
and referrals, so a nudge in the right direction is warranted.
2023-09-07 23:11:49 +01:00
Harvey Tindall
504c75566a README: Mention referrals 2023-09-07 23:07:52 +01:00
Harvey Tindall
ed4dcbac3b README: new images, remove GIF
don't really think it's necessary. also, they're annoying to make.
2023-09-07 23:01:12 +01:00
Harvey Tindall
a0f1cd5814 captcha: fix missing images
The captcha library's data struct wasn't being serialized/deserialized
fully, meaning the image was never stored. I never really wanted it to
be stored anyway, but as a compromise, the invite daemon now deletes
captcha images from the DB 20 minutes after generation.
2023-09-07 22:38:23 +01:00
Harvey Tindall
4607a30e6a accounts: fix filter card overlap 2023-09-07 21:59:35 +01:00
Harvey Tindall
fca370b9d9 discord: hide "Join Server" text when invite not provided 2023-09-07 21:40:44 +01:00
Harvey Tindall
dc3f1661e8 accounts: fix filter button appearing over lang dropdown 2023-09-07 21:37:25 +01:00
Harvey Tindall
463fe97b29 Merge Referrals
Add Referrals
2023-09-07 21:31:32 +01:00
Harvey Tindall
b08527bce2 userpage: cleanup referral code
moved to its own class, like the expiry card.
2023-09-07 20:42:40 +01:00
Harvey Tindall
41c092f578 referrals: show referrer username on form 2023-09-07 20:19:25 +01:00
Harvey Tindall
311ecb7030 userpage: generate & display referral links
shown on a new card, with an explanation, the number of remaining uses,
and expiry of the current referral.
2023-09-07 16:25:47 +01:00
Harvey Tindall
4a28ea7003 daemon: fix bug wiping out contact details
records were being left alonge if "... err != nil", instead of "... err
== nil". Sorry to anyone affected.
2023-09-07 14:50:26 +01:00
Harvey Tindall
0a82f889f3 daemon: fix bug wiping out contact details
records were being left alone if "status == 200 && err != nil", instead
of "... && err == nil". Sorry.
2023-09-07 14:48:12 +01:00
Harvey Tindall
00e6da520d userpage: cope with disabled contact methods 2023-09-07 14:40:24 +01:00
Harvey Tindall
0b830e9b5e referrals: enable for new users from profile 2023-09-07 14:31:42 +01:00
Harvey Tindall
468b2f3284 accounts: descriptive error when no template found 2023-09-07 14:04:32 +01:00
Harvey Tindall
db21131185 accounts: allow disabling of referrals for users 2023-09-07 14:00:30 +01:00
Harvey Tindall
7d9555fdf7 accounts: add referrals to search queries 2023-09-07 13:30:21 +01:00
Harvey Tindall
729552a827 referrals: Show enabled status on account list 2023-09-06 22:46:16 +01:00
Harvey Tindall
cdc8f9af4b referrals: unlink/disable referrals for profile 2023-09-06 22:12:36 +01:00
Harvey Tindall
9e5034ebab referrals: enable referral for users & profiles
Enabling for individual users works, as does adding a template to a
profile. Removing/Disabling for both needs to be completed.
2023-09-06 22:00:44 +01:00
Harvey Tindall
c2f835c897 referrals: show data on enable referral for user modal
profiles and invites are properly shown.
2023-06-30 16:47:35 +01:00
Harvey Tindall
9c2f27bcdb referrals: 1/2 generation routes, display route, partial frontend
route for generation/enabling of referral for user(s) done? the frontend
is mostly done, but functionality is not there yet. Route for finding
and displaying referral to user is done. Also the config option for
referral is there, in user page settings.
2023-06-28 16:05:24 +01:00
Harvey Tindall
423fc4ac80 accounts: non-case sensitive search 2023-06-27 07:35:28 +01:00
Harvey Tindall
e1292a0780 build: dont install swag if already present 2023-06-27 07:23:25 +01:00
Harvey Tindall
f72960635d build: include debug symbols & sourcemaps in unstable builds
should help with debugging.
2023-06-26 23:48:16 +01:00
Harvey Tindall
b5c80e9d27 config: make sure recaptcha is hidden when disabled 2023-06-26 23:24:58 +01:00
Harvey Tindall
3fa4b01115 setup: add user page
also sprinkled mentions of it throughout other relevant pages.
2023-06-26 21:29:49 +01:00
Harvey Tindall
65f402fd35 admin: hide my account button when disabled 2023-06-26 20:48:57 +01:00
Harvey Tindall
46f1bc20c8 css: fix font error
comment wasn't ended, so some font weights/styles weren't loading and
esbuild was complaining.
2023-06-26 20:29:59 +01:00
Harvey Tindall
a13a72c626 admin: fix logout when url base is used
two tries are made, with and without the url base.
2023-06-26 20:28:20 +01:00
Harvey Tindall
5a80145607 css: add notification animation
simple slide animation, plus a little scale effect when a duplicate
notification gets sent to make the notification more obvious.
2023-06-26 20:13:02 +01:00
Harvey Tindall
baf5e6a593 accounts: add dropdown arrow on "Announce" button 2023-06-26 13:08:34 +01:00
Harvey Tindall
850bb8f44e accounts: fix modify user card layout 2023-06-26 13:04:01 +01:00
Harvey Tindall
b17d8424e9 profiles: fix application
moving to a DB meant empty slices in the Configuration & Policy structs
were being stored as null, and striking a nerve with Jellyfin.
Mediabrowser library change fixed that by de-nulling them itself, and a
new bool field called "Homescreen" is now used to decide if a profile
has a homescreen layout stored or not. This field is hopefully correctly
filled in during migration.
2023-06-26 13:01:17 +01:00
Harvey Tindall
d2253ff069 accounts: fix filter card height
string filter cards were too tall, so bool cards now expand to the same
height. y-margins also removed it made the bottom get covered.
2023-06-25 21:28:38 +01:00
Harvey Tindall
0946b3a1da Merge Database Migration
Database Migration
2023-06-25 20:32:33 +01:00
197 changed files with 13493 additions and 2816 deletions

View File

@@ -45,9 +45,9 @@ type: docker
steps:
- name: build-deploy
image: appleboy/drone-ssh
volumes:
- name: ssh_key
path: /root/drone_rsa
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
settings:
host:
from_secret: ssh2_host
@@ -55,23 +55,20 @@ steps:
from_secret: ssh2_username
port:
from_secret: ssh2_port
volumes:
- /root/.ssh/docker-build:/root/drone_rsa
key_path: /root/drone_rsa
envs:
- buildrone_key
key:
from_secret: ssh2_key
command_timeout: 50m
script:
- /mnt/buildx/jfa-go/build.sh stable
- wget https://builds.hrfee.pw/upload.py -O /mnt/buildx/jfa-go/jfa-go/upload.py
- pip3 install requests
- bash -c 'cd /mnt/buildx/jfa-go/jfa-go && BUILDRONE_KEY=$(cat /mnt/buildx/jfa-go/key) python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-stable=true'
- bash -c 'cd /mnt/buildx/jfa-go/jfa-go && python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-stable=true'
- rm -f /mnt/buildx/jfa-go/jfa-go/upload.py
trigger:
event:
- tag
volumes:
- name: ssh_key
host:
path: /root/.ssh/docker-build
---
name: jfa-go-git
kind: pipeline
@@ -88,7 +85,7 @@ steps:
commands:
- curl -sL https://git.io/goreleaser > goreleaser
- chmod +x goreleaser
- ./scripts/version.sh ./goreleaser --snapshot --skip-publish --clean
- ./scripts/version.sh ./goreleaser --snapshot --skip=publish --clean
- wget https://builds.hrfee.pw/upload.py
- pip3 install requests
- bash -c 'sftp -i /id_rsa2 -o StrictHostKeyChecking=no root@161.97.102.153:/mnt/redoc <<< $"put docs/swagger.json jfa-go.json"'
@@ -103,6 +100,7 @@ steps:
from_secret: BUILDRONE_KEY
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
JFA_GO_SNAPSHOT: y
volumes:
- name: ssh_key
@@ -127,9 +125,9 @@ type: docker
steps:
- name: build-deploy
image: appleboy/drone-ssh
volumes:
- name: ssh_key
path: /root/drone_rsa
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
settings:
host:
from_secret: ssh2_host
@@ -137,15 +135,16 @@ steps:
from_secret: ssh2_username
port:
from_secret: ssh2_port
volumes:
- /root/.ssh/docker-build:/root/drone_rsa
key_path: /root/drone_rsa
envs:
- buildrone_key
key:
from_secret: ssh2_key
command_timeout: 50m
script:
- /mnt/buildx/jfa-go/build.sh
- wget https://builds.hrfee.pw/upload.py -O /mnt/buildx/jfa-go/jfa-go/upload.py
- pip3 install requests
- bash -c 'cd /mnt/buildx/jfa-go/jfa-go && BUILDRONE_KEY=$(cat /mnt/buildx/jfa-go/key) python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-unstable=true'
- bash -c 'cd /mnt/buildx/jfa-go/jfa-go && python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-unstable=true'
- rm -f /mnt/buildx/jfa-go/jfa-go/upload.py
trigger:
branch:
@@ -154,10 +153,6 @@ trigger:
exclude:
- pull_request
volumes:
- name: ssh_key
host:
path: /root/.ssh/docker-build
---
name: jfa-go-pr
kind: pipeline
@@ -169,7 +164,7 @@ steps:
commands:
- curl -sL https://git.io/goreleaser > goreleaser
- chmod +x goreleaser
- ./scripts/version.sh ./goreleaser --snapshot --skip-publish --clean
- ./scripts/version.sh ./goreleaser --snapshot --skip=publish --clean
trigger:
event:

1
.gitignore vendored
View File

@@ -24,3 +24,4 @@ matacc.txt
scripts/langmover/lang
scripts/langmover/lang2
scripts/langmover/out
tinyproxy.conf

View File

@@ -1,3 +1,5 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
version: 2
project_name: jfa-go
release:
github:
@@ -26,12 +28,14 @@ before:
- scripts/dark-variant.sh tempts
- scripts/dark-variant.sh tempts/modules
- mkdir -p data/web/js
- npx esbuild --target=es6 --bundle tempts/admin.ts --outfile=./data/web/js/admin.js --minify
- npx esbuild --target=es6 --bundle tempts/user.ts --outfile=./data/web/js/user.js --minify
- npx esbuild --target=es6 --bundle tempts/pwr.ts --outfile=./data/web/js/pwr.js --minify
- npx esbuild --target=es6 --bundle tempts/form.ts --outfile=./data/web/js/form.js --minify
- npx esbuild --target=es6 --bundle tempts/setup.ts --outfile=./data/web/js/setup.js --minify
- npx esbuild --target=es6 --bundle tempts/crash.ts --outfile=./data/crash.js --minify
- npx esbuild --target=es6 --bundle tempts/admin.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/admin.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/user.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/user.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/pwr.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/pwr.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/pwr-pin.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/pwr-pin.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/form.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/form.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/setup.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/setup.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/crash.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/crash.js {{.Env.JFA_GO_MINIFY}}
- bash -c "{{.Env.JFA_GO_COPYTS}}"
- rm -r tempts
- npx esbuild --bundle css/base.css --outfile=./data/web/css/bundle.css --external:remixicon.css --external:../fonts/hanken* --minify
- cp html/crash.html data/
@@ -49,10 +53,11 @@ builds:
env:
- CGO_ENABLED=0
ldflags:
- -s -w -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
goos:
- linux
- darwin
- windows
goarch:
- arm
- arm64
@@ -66,7 +71,7 @@ builds:
flags:
- -tags=tray
ldflags:
- -s -w -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}" -H=windowsgui
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}" -H=windowsgui
goos:
- windows
goarch:
@@ -75,10 +80,13 @@ builds:
dir: ./
env:
- CGO_ENABLED=1
- CC=x86_64-linux-gnu-gcc
- CXX=x86_64-linux-gnu-gcc
- PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH
flags:
- -tags=tray
ldflags:
- -s -w -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
goos:
- linux
goarch:

View File

@@ -0,0 +1,51 @@
when:
- event: push
branch: main
# - evaluate: 'CI_PIPELINE_EVENT != "PULL_REQUEST" && CI_COMMIT_BRANCH == CI_REPO_DEFAULT_BRANCH'
clone:
git:
image: woodpeckerci/plugin-git
settings:
tags: true
partial: false
depth: 0
steps:
- name: redoc
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
REDOC_SSH_ID:
from_secret: REDOC_SSH_ID
commands:
- sh -c "echo \"$REDOC_SSH_ID\" > /tmp/id_redoc && chmod 600 /tmp/id_redoc"
- bash -c 'sftp -P 3625 -i /tmp/id_redoc -o StrictHostKeyChecking=no redoc@api.jfa-go.com:/home/redoc <<< $"put docs/swagger.json jfa-go.json"'
- name: build
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
commands:
- curl -sfL https://goreleaser.com/static/run > goreleaser
- chmod +x goreleaser
- ./scripts/version.sh ./goreleaser --snapshot --skip=publish --clean
- name: deb-repo
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
REPO_SSH_ID:
from_secret: REPO_SSH_ID
commands:
- sh -c "echo \"$REPO_SSH_ID\" > /tmp/id_repo && chmod 600 /tmp/id_repo"
- bash -c 'sftp -P 2022 -i /tmp/id_repo -o StrictHostKeyChecking=no root@apt.hrfee.dev:/repo/incoming <<< $"put dist/*.deb"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty-unstable"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "rm -f /repo/incoming/*.deb"'
- name: buildrone
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
commands:
- wget https://builds.hrfee.pw/upload.py
- bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --upload ./dist/*.zip ./dist/*.rpm ./dist/*.apk --tag internal-git=true'

View File

@@ -0,0 +1,29 @@
when:
- event: push
branch: main
steps:
- name: build
image: docker.io/woodpeckerci/plugin-docker-buildx
secrets: [ BUILT_BY ]
settings:
username:
from_secret: DOCKER_USERNAME
password:
from_secret: DOCKER_TOKEN
repo: docker.io/hrfee/jfa-go
tags: unstable
registry: docker.io
platforms: linux/amd64,linux/arm64,linux/arm/v7
build_args:
- BUILT_BY: $BUILT_BY
- name: buildrone
image: docker.io/python
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
commands:
- wget https://builds.hrfee.pw/upload.py
- pip install requests
- python upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-unstable=true

View File

@@ -0,0 +1,41 @@
when:
- event: tag
branch: main
clone:
git:
image: woodpeckerci/plugin-git
settings:
tags: true
partial: false
depth: 0
steps:
- name: build
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
commands:
- curl -sfL https://goreleaser.com/static/run > ../goreleaser
- chmod +x ../goreleaser
- ./scripts/version.sh ../goreleaser
- name: deb-repo
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
REPO_SSH_ID:
from_secret: REPO_SSH_ID
commands:
- sh -c "echo \"$REPO_SSH_ID\" > /tmp/id_repo && chmod 600 /tmp/id_repo"
- bash -c 'sftp -P 2022 -i /tmp/id_repo -o StrictHostKeyChecking=no root@apt.hrfee.dev:/repo/incoming <<< $"put dist/*.deb"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty-unstable"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "rm -f /repo/incoming/*.deb"'
- name: buildrone
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
commands:
- wget https://builds.hrfee.pw/upload.py
- bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag internal=true'

View File

@@ -0,0 +1,29 @@
when:
- event: tag
branch: main
steps:
- name: build
image: docker.io/woodpeckerci/plugin-docker-buildx
secrets: [ BUILT_BY ]
settings:
username:
from_secret: DOCKER_USERNAME
password:
from_secret: DOCKER_TOKEN
repo: docker.io/hrfee/jfa-go
tags: latest
registry: docker.io
platforms: linux/amd64,linux/arm64,linux/arm/v7
build_args:
- BUILT_BY: $BUILT_BY
- name: buildrone
image: docker.io/python
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
commands:
- wget https://builds.hrfee.pw/upload.py
- pip install requests
- python upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-stable=true

View File

@@ -3,42 +3,5 @@ title: "Building/Contributing for developers"
date: 2021-07-25T00:33:36+01:00
draft: false
---
# Code
I use 4 spaces for indentation. Go should ideally be formatted with `goimports` and/or `gofmt`. I don't use a formatter on typescript, so don't worry about that.
Code in Go should ideally use `PascalCase` for exported values, and `camelCase` for non-exported, JSON for transferring data should use `snake_case`, and Typescript should use `camelCase`. Forgive me for my many inconsistencies in this, and feel free to fix them if you want.
Functions in Go that need to access `*appContext` should be generally be receivers, except when the behaviour could be seen as somewhat independent from it (`email.go` is the best example, its behaviour is broadly independent from the main app except from a couple config values).
# Compiling
The Makefile is more suited towards development than other build methods, and provides separate build stages to speed up compilation when only making changes to specific aspects of the project.
Prefix each of these with `make DEBUG=on `:
* `all` will download deps and build everything. The executable and data will be placed in `build`. This is only necessary the first time.
* `npm` will download all node.js build-time dependencies.
* `compile` will only compile go code into the `build/jfa-go` executable.
* `typescript` will compile typescript w/ sourcemaps into `build/data/web/js`.
* `bundle-css` will bundle CSS and place it in `build/data/web/css`.
* `inline` will inline the css and javascript used in the single-file crash report webpage.
* `configuration` will generate the `config-base.json` (used to render settings in the web ui) and `config-default.ini` and put them in `build/data`.
* `email` will compile email mjml, and copy the text versions in to `build/data`.
* `swagger`: generates swagger documentation for the API.
* `copy` will copy iconography, html, language files and static data into `build/data`.
## Environment variables
* `DEBUG=on/off`: If on, compiles with type-checking for typescript, sourcemaps, non-minified css and no symbol stripping.
* `INTERNAL=on/off`: Whether or not to embed file assets into the binary itself, or store them separately beside the binary.
* `UPDATER=on/off/docker`: Enable/Disable the updater, or set a special update type (currently only docker, which disables self-updating the binary).
* `TRAY=on/off`: Enable/disable the tray icon, which lets you start/stop/autostart on login. For linux, requires `libappindicator3-dev` for debian or the equivalent on other distributions.
* `GOESBUILD=on`: Use a locally installed `esbuild` binary. NPM doesn't provide builds for all os/architectures, so `npx esbuild` might not work for you, so the binary is compiled/installed with `go get`.
* `GOBINARY=<path to go>`: Alternative path to go executable. Useful for testing with unstable go releases.
* `VERSION=v<semver>`: Alternative verision number, useful to test update functionality.
* `COMMIT=<short commit>`: Self explanatory.
* `LDFLAGS=<ldflags>`: Passed to `go build -ldflags`.
* `E2EE=on/off`: Enable/disable end-to-end encryption support for Matrix, which is currently very broken. Must subsequently be enabled (with Advanced settings enabled) in Settings > Matrix.
* `TAGS=<tags>`: Passed to `go build -tags`.
* `OS=<os>`: Unrelated to GOOS, if set to `windows`, `-H=windowsgui` is passed to ldflags, which stops a windows terminal popping up when run.
* `RACE=on/off`: If on, compiles with the go race detector included.
[See the wiki page](https://wiki.jfa-go.com/docs/dev/).

View File

@@ -1,18 +1,22 @@
FROM --platform=$BUILDPLATFORM golang:latest AS support
# Use this instead if hrfee/jfa-go-build-docker doesn't support your architecture
# FROM --platform=$BUILDPLATFORM golang:latest AS support
FROM --platform=$BUILDPLATFORM hrfee/jfa-go-build-docker AS support
COPY . /opt/build
RUN apt-get update -y \
&& apt-get install build-essential python3-pip curl software-properties-common sed -y \
&& (curl -sL https://deb.nodesource.com/setup_current.x | bash -) \
&& apt-get install nodejs \
&& (cd /opt/build; make configuration npm email typescript variants-html bundle-css inline-css swagger copy INTERNAL=off GOESBUILD=on) \
# Uncomment this if hrfee/jfa-go-build-docker doesn't support your architecture
# RUN apt-get update -y \
# && apt-get install build-essential python3-pip -y \
# && (curl -sL https://deb.nodesource.com/setup_current.x | bash -) \
# && apt-get install nodejs
RUN (cd /opt/build; make configuration npm email typescript variants-html bundle-css inline-css swagger copy INTERNAL=off GOESBUILD=on) \
&& sed -i 's#id="password_resets-watch_directory" placeholder="/config/jellyfin"#id="password_resets-watch_directory" value="/jf" disabled#g' /opt/build/build/data/html/setup.html
FROM --platform=$BUILDPLATFORM golang:latest AS build
ARG TARGETARCH
ENV GOARCH=$TARGETARCH
ARG BUILT_BY
ENV BUILTBY=$BUILT_BY
COPY --from=support /opt/build /opt/build

View File

@@ -88,6 +88,11 @@ else
NPMOPTS :=
endif
ifeq (, $(shell which swag))
SWAGINSTALL := $(GOBINARY) install github.com/swaggo/swag/cmd/swag@latest
else
SWAGINSTALL :=
endif
npm:
$(info installing npm dependencies)
@@ -116,13 +121,14 @@ typescript:
$(ESBUILD) --target=es6 --bundle tempts/admin.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/admin.js --minify
$(ESBUILD) --target=es6 --bundle tempts/user.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/user.js --minify
$(ESBUILD) --target=es6 --bundle tempts/pwr.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/pwr.js --minify
$(ESBUILD) --target=es6 --bundle tempts/pwr-pin.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/pwr-pin.js --minify
$(ESBUILD) --target=es6 --bundle tempts/form.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/form.js --minify
$(ESBUILD) --target=es6 --bundle tempts/setup.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/setup.js --minify
$(ESBUILD) --target=es6 --bundle tempts/crash.ts --outfile=./$(DATA)/crash.js --minify
$(COPYTS)
swagger:
$(GOBINARY) install github.com/swaggo/swag/cmd/swag@latest
$(SWAGINSTALL)
swag init -g main.go
compile:

View File

@@ -13,7 +13,7 @@
Studies mean I can't work on this project a lot outside of breaks, however I hope i'll be able to fit in general support and things like bug fixes into my time. New features and such will likely come in short bursts throughout the year (if they do at all).
#### Does/Will it still work?
jfa-go currently works on Jellyfin 10.8.9, the latest version. I should be able to maintain compatability in the future, unless any big changes occur.
jfa-go currently works on Jellyfin 10.8.13, the latest version as of 26/12/23. I should be able to maintain compatability in the future, unless any big changes occur.
#### Alternatives
If you want a bit more of a guarantee of support, I've seen these projects mentioned although haven't tried them myself.
@@ -22,29 +22,25 @@ If you want a bit more of a guarantee of support, I've seen these projects menti
* [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) is a fork of Overseerr, which can manage users and mainly acts as an Ombi alternative.
* [Organizr](https://github.com/causefx/Organizr) doesn't focus on Jellyfin, but allows putting self-hosted services into "tabs" on a central page, and allows creating users, which lets one control who can access what.
---
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) (and now [Emby](https://emby.media/)) that provides invite-based account creation as well as other features that make one's instance much easier to manage.
a rewrite of [jellyfin-accounts](https://github.com/hrfee/jellyfin-accounts) (original naming for both, ik
😂).
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) (and [Emby](https://emby.media/) as 2nd class) that provides invite-based account creation as well as other features that make one's instance much easier to manage.
#### Features
* 🧑 Invite based account creation: Send invites to your friends or family, and let them choose their own username and password without relying on you.
* Send invites via a link and/or email
* Send invites via a link and/or email, discord, telegram or matrix
* Granular control over invites: Validity period as well as number of uses can be specified.
* Account profiles: Assign settings profiles to invites so new users have your predefined permissions, homescreen layout, etc. applied to their account on creation.
* Password validation: Ensure users choose a strong password.
* CAPTCHAs can be enabled to avoid bots
* CAPTCHAs and contact method verificatoin can be enabled to avoid bots.
* ⌛ User expiry: Specify a validity period, and new users accounts will be disabled/deleted after it. The period can be manually extended too.
* 🔗 Ombi Integration: Automatically creates Ombi accounts for new users using their email address and login details, and your own defined set of permissions.
* Account management: Apply settings to your users individually or en masse, and delete users, optionally sending them an email notification with a reason.
* Telegram/Discord/Matrix Integration: Verify users via a chat bot, and send Password Resets, Announcements, etc. through it.
* 📨 Email storage: Add your existing users email addresses through the UI, and jfa-go will ask new users for them on account creation.
* Email addresses can optionally be used instead of usernames
* 🔑 Password resets: When users forget their passwords and request a change in Jellyfin, jfa-go reads the PIN from the created file and sends it straight to them via email/telegram.
* Admin Notifications: Get notified when someone creates an account, or an invite expires.
* 🔗 Ombi Integration: Automatically creates Ombi accounts for new users using their email address and login details, and your own defined set of permissions. See [wiki](https://wiki.jfa-go.com/docs/ombi/) for a warning on this one.
* Account management: Bulk or individually; apply settings, delete, disable/enable, send messages and much more.
* 📣 Announcements: Bulk message your users with announcements about your server.
* Authentication via Jellyfin: Instead of using separate credentials for jfa-go and Jellyfin, jfa-go can use it as the authentication provider.
* Enables the usage of jfa-go by multiple people
* Telegram/Discord/Matrix Integration: Verify users via a chat bot, and send Password Resets, Announcements, etc. through it.
* "My Account" Page: Allows users to reset their password, manage contact details, view their account expiry date, and send referrals. Can be customized with markdown.
* Referrals: Users can be given special invites to send to their friends and families, similar to some invite-only services like Bluesky.
* 🔑 Password resets: When users forget their passwords and request a change in Jellyfin, jfa-go reads the PIN from the created file and sends it straight to them via email/telegram.
* Can also be done through the "My Account" page if enabled.
* Admin Notifications: Get notified when someone creates an account, or an invite expires.
* 🌓 Customizations
* Customize emails with variables and markdown
* Specify contact and help messages to appear in emails and pages
@@ -52,13 +48,10 @@ a rewrite of [jellyfin-accounts](https://github.com/hrfee/jellyfin-accounts) (or
#### Interface
<p align="center">
<img src="images/demo.gif" width="100%"></img>
</p>
<p align="center">
<img src="images/invites.png" width="31%" style="margin-left: 1.5%;" alt="Invites tab"></img>
<img src="images/accounts.png" width="31%" style="margin-right: 1.5%;" alt="Accounts tab"></img>
<img src="images/create.png" width="31%" style="margin-right: 1.5%;" alt="Accounts creation"></img>
<img src="images/invites.png" width="47%" style="margin-left: 1.5%;" align="top" alt="Invites tab"></img>
<img src="images/create.png" width="47%" style="margin-right: 1.5%;" align="top" alt="Accounts creation"></img>
<img src="images/myaccount.png" width="47%" style="margin-left: 1.5%; margin-top: 1rem;" align="top" alt="My Account Page"></img>
<img src="images/accounts.png" width="47%" style="margin-right: 1.5%; margin-top: 1rem;" align="top" alt="Accounts tab"></img>
</p>
#### Install
@@ -72,7 +65,7 @@ docker create \
-p 8056:8056 \
# -p 8057:8057 if using tls
-v /path/to/.config/jfa-go:/data \ # Path to wherever you want to store the config file and other data
-v /path/to/jellyfin:/jf \ # Path to Jellyfin config directory, ignore if using Emby
-v /path/to/jellyfin:/jf \ # Only needed for password resets through Jellyfin, ignore if not using or using Emby
-v /etc/localtime:/etc/localtime:ro \ # Makes sure time is correct
hrfee/jfa-go # hrfee/jfa-go:unstable for latest build from git
```
@@ -80,7 +73,7 @@ docker create \
##### [Debian/Ubuntu](https://apt.hrfee.dev)
```sh
sudo apt-get update && sudo apt-get install curl apt-transport-https gnupg
curl https://apt.hrfee.dev/hrfee.pubkey.gpg | sudo apt-key add -
curl https://apt.hrfee.dev/hrfee.pubkey.gpg | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/apt.hrfee.dev.gpg
# For stable releases
echo "deb https://apt.hrfee.dev trusty main" | sudo tee /etc/apt/sources.list.d/hrfee.list
@@ -108,7 +101,7 @@ Available on the AUR as:
##### Other platforms
Download precompiled binaries from:
* [The releases section](https://github.com/hrfee/jfa-go/releases) (stable)
* [Buildrone](https://builds.hrfee.dev/view/hrfee/jfa-go) (nightly)
* [dl.jfa-go.com](https://dl.jfa-go.com) (nightly)
unzip the `jfa-go`/`jfa-go.exe` executable to somewhere useful.
* For \*nix/macOS users, `chmod +x jfa-go` then place it somewhere in your PATH like `/usr/bin`.
@@ -147,6 +140,8 @@ Usage of jfa-go:
alternate port to host web ui on.
-pprof
Exposes pprof profiler on /debug/pprof.
-restore string
path to database backup to restore.
-swagger
Enable swagger at /swagger/index.html
```
@@ -154,18 +149,9 @@ Usage of jfa-go:
#### Systemd
jfa-go does not run as a daemon by default. Run `jfa-go systemd` to create a systemd `.service` file in your current directory, which you can copy into `~/.config/systemd/user` or somewhere else.
---
If you're switching from jellyfin-accounts, copy your existing `~/.jf-accounts` to:
* `XDG_CONFIG_DIR/jfa-go` (usually ~/.config/jfa-go) on \*nix systems,
* `%AppData%/jfa-go` on Windows,
* `~/Library/Application Support/jfa-go` on macOS.
(or specify config/data path with `-config/-data` respectively.)
#### Contributing
See [the wiki page](https://wiki.jfa-go.com/docs/dev/) or [CONTRIBUTING.md](https://github.com/hrfee/jfa-go/blob/main/CONTRIBUTING.md).
See [the wiki page](https://wiki.jfa-go.com/docs/dev/).
##### Translation
[![Translation status](https://weblate.jfa-go.com/widgets/jfa-go/-/multi-auto.svg)](https://weblate.jfa-go.com/engage/jfa-go/)

187
api-activities.go Normal file
View File

@@ -0,0 +1,187 @@
package main
import (
"github.com/gin-gonic/gin"
"github.com/timshannon/badgerhold/v4"
)
func stringToActivityType(v string) ActivityType {
switch v {
case "creation":
return ActivityCreation
case "deletion":
return ActivityDeletion
case "disabled":
return ActivityDisabled
case "enabled":
return ActivityEnabled
case "contactLinked":
return ActivityContactLinked
case "contactUnlinked":
return ActivityContactUnlinked
case "changePassword":
return ActivityChangePassword
case "resetPassword":
return ActivityResetPassword
case "createInvite":
return ActivityCreateInvite
case "deleteInvite":
return ActivityDeleteInvite
}
return ActivityUnknown
}
func activityTypeToString(v ActivityType) string {
switch v {
case ActivityCreation:
return "creation"
case ActivityDeletion:
return "deletion"
case ActivityDisabled:
return "disabled"
case ActivityEnabled:
return "enabled"
case ActivityContactLinked:
return "contactLinked"
case ActivityContactUnlinked:
return "contactUnlinked"
case ActivityChangePassword:
return "changePassword"
case ActivityResetPassword:
return "resetPassword"
case ActivityCreateInvite:
return "createInvite"
case ActivityDeleteInvite:
return "deleteInvite"
}
return "unknown"
}
func stringToActivitySource(v string) ActivitySource {
switch v {
case "user":
return ActivityUser
case "admin":
return ActivityAdmin
case "anon":
return ActivityAnon
case "daemon":
return ActivityDaemon
}
return ActivityAnon
}
func activitySourceToString(v ActivitySource) string {
switch v {
case ActivityUser:
return "user"
case ActivityAdmin:
return "admin"
case ActivityAnon:
return "anon"
case ActivityDaemon:
return "daemon"
}
return "anon"
}
// @Summary Get the requested set of activities, Paginated, filtered and sorted.
// @Produce json
// @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters"
// @Success 200 {object} GetActivitiesRespDTO
// @Router /activity [post]
// @Security Bearer
// @tags Activity
func (app *appContext) GetActivities(gc *gin.Context) {
req := GetActivitiesDTO{}
gc.BindJSON(&req)
query := &badgerhold.Query{}
activityTypes := make([]interface{}, len(req.Type))
for i, v := range req.Type {
activityTypes[i] = stringToActivityType(v)
}
if len(activityTypes) != 0 {
query = badgerhold.Where("Type").In(activityTypes...)
}
if !req.Ascending {
query = query.Reverse()
}
query = query.SortBy("Time")
if req.Limit == 0 {
req.Limit = 10
}
query = query.Skip(req.Page * req.Limit).Limit(req.Limit)
var results []Activity
err := app.storage.db.Find(&results, query)
if err != nil {
app.err.Printf("Failed to read activities from DB: %v\n", err)
}
resp := GetActivitiesRespDTO{
Activities: make([]ActivityDTO, len(results)),
LastPage: len(results) != req.Limit,
}
for i, act := range results {
resp.Activities[i] = ActivityDTO{
ID: act.ID,
Type: activityTypeToString(act.Type),
UserID: act.UserID,
SourceType: activitySourceToString(act.SourceType),
Source: act.Source,
InviteCode: act.InviteCode,
Value: act.Value,
Time: act.Time.Unix(),
IP: act.IP,
}
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
resp.Activities[i].Username = act.Value
resp.Activities[i].Value = ""
} else if user, status, err := app.jf.UserByID(act.UserID, false); status == 200 && err == nil {
resp.Activities[i].Username = user.Name
}
if (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != "" {
user, status, err := app.jf.UserByID(act.Source, false)
if status == 200 && err == nil {
resp.Activities[i].SourceUsername = user.Name
}
}
}
gc.JSON(200, resp)
}
// @Summary Delete the activity with the given ID. No-op if non-existent, always succeeds.
// @Produce json
// @Param id path string true "ID of activity to delete"
// @Success 200 {object} boolResponse
// @Router /activity/{id} [delete]
// @Security Bearer
// @tags Activity
func (app *appContext) DeleteActivity(gc *gin.Context) {
app.storage.DeleteActivityKey(gc.Param("id"))
respondBool(200, true, gc)
}
// @Summary Returns the total number of activities stored in the database.
// @Produce json
// @Success 200 {object} GetActivityCountDTO
// @Router /activity/count [get]
// @Security Bearer
// @tags Activity
func (app *appContext) GetActivityCount(gc *gin.Context) {
resp := GetActivityCountDTO{}
var err error
resp.Count, err = app.storage.db.Count(&Activity{}, &badgerhold.Query{})
if err != nil {
resp.Count = 0
}
gc.JSON(200, resp)
}

117
api-backups.go Normal file
View File

@@ -0,0 +1,117 @@
package main
import (
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// @Summary Creates a backup of the database.
// @Router /backups [post]
// @Success 200 {object} CreateBackupDTO
// @Security Bearer
// @tags Backups
func (app *appContext) CreateBackup(gc *gin.Context) {
backup := app.makeBackup()
gc.JSON(200, backup)
}
// @Summary Download a specific backup file. Requires auth, so can't be accessed plainly in the browser.
// @Param fname path string true "backup filename"
// @Router /backups/{fname} [get]
// @Produce octet-stream
// @Produce json
// @Success 200 {body} file
// @Failure 400 {object} boolResponse
// @Security Bearer
// @tags Backups
func (app *appContext) GetBackup(gc *gin.Context) {
fname := gc.Param("fname")
// Hopefully this is enough to ensure the path isn't malicious. Hidden behind bearer auth anyway so shouldn't matter too much I guess.
ok := (strings.HasPrefix(fname, BACKUP_PREFIX) || strings.HasPrefix(fname, BACKUP_UPLOAD_PREFIX+BACKUP_PREFIX)) && strings.HasSuffix(fname, BACKUP_SUFFIX)
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(fname, BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX), BACKUP_SUFFIX))
if !ok || err != nil || t.IsZero() {
app.debug.Printf("Ignoring backup DL request due to fname: %v\n", err)
respondBool(400, false, gc)
return
}
path := app.config.Section("backups").Key("path").String()
fullpath := filepath.Join(path, fname)
gc.FileAttachment(fullpath, fname)
}
// @Summary Get a list of backups.
// @Router /backups [get]
// @Produce json
// @Success 200 {object} GetBackupsDTO
// @Security Bearer
// @tags Backups
func (app *appContext) GetBackups(gc *gin.Context) {
path := app.config.Section("backups").Key("path").String()
backups := app.getBackups()
sort.Sort(backups)
resp := GetBackupsDTO{}
resp.Backups = make([]CreateBackupDTO, backups.count)
for i, item := range backups.files[:backups.count] {
resp.Backups[i].Name = item.Name()
fullpath := filepath.Join(path, item.Name())
resp.Backups[i].Path = fullpath
resp.Backups[i].Date = backups.dates[i].Unix()
fstat, err := os.Stat(fullpath)
if err == nil {
resp.Backups[i].Size = fileSize(fstat.Size())
}
}
gc.JSON(200, resp)
}
// @Summary Restore a backup file stored locally to the server.
// @Param fname path string true "backup filename"
// @Router /backups/restore/{fname} [post]
// @Produce json
// @Failure 400 {object} boolResponse
// @Security Bearer
// @tags Backups
func (app *appContext) RestoreLocalBackup(gc *gin.Context) {
fname := gc.Param("fname")
// Hopefully this is enough to ensure the path isn't malicious. Hidden behind bearer auth anyway so shouldn't matter too much I guess.
ok := strings.HasPrefix(fname, BACKUP_PREFIX) && strings.HasSuffix(fname, BACKUP_SUFFIX)
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(fname, BACKUP_PREFIX), BACKUP_SUFFIX))
if !ok || err != nil || t.IsZero() {
app.debug.Printf("Ignoring backup DL request due to fname: %v\n", err)
respondBool(400, false, gc)
return
}
path := app.config.Section("backups").Key("path").String()
fullpath := filepath.Join(path, fname)
LOADBAK = fullpath
app.restart(gc)
}
// @Summary Restore a backup file uploaded by the user.
// @Param file formData file true ".bak file"
// @Router /backups/restore [post]
// @Produce json
// @Failure 400 {object} boolResponse
// @Security Bearer
// @tags Backups
func (app *appContext) RestoreBackup(gc *gin.Context) {
file, err := gc.FormFile("backups-file")
if err != nil {
app.err.Printf("Failed to get file from form data: %v\n", err)
respondBool(400, false, gc)
return
}
app.debug.Printf("Got uploaded file \"%s\"\n", file.Filename)
path := app.config.Section("backups").Key("path").String()
fullpath := filepath.Join(path, BACKUP_UPLOAD_PREFIX+BACKUP_PREFIX+time.Now().Local().Format(BACKUP_DATEFMT)+BACKUP_SUFFIX)
gc.SaveUploadedFile(file, fullpath)
app.debug.Printf("Saved to \"%s\"\n", fullpath)
LOADBAK = fullpath
app.restart(gc)
}

View File

@@ -13,14 +13,56 @@ import (
"github.com/timshannon/badgerhold/v4"
)
const (
CAPTCHA_VALIDITY = 20 * 60 // Seconds
)
// GenerateInviteCode generates an invite code in the correct format.
func GenerateInviteCode() string {
// make sure code doesn't begin with number
inviteCode := shortuuid.New()
_, err := strconv.Atoi(string(inviteCode[0]))
for err == nil {
inviteCode = shortuuid.New()
_, err = strconv.Atoi(string(inviteCode[0]))
}
return inviteCode
}
func (app *appContext) checkInvites() {
currentTime := time.Now()
for _, data := range app.storage.GetInvites() {
captchas := data.Captchas
captchasExpired := false
for key, capt := range data.Captchas {
if time.Now().After(capt.Generated.Add(CAPTCHA_VALIDITY * time.Second)) {
delete(captchas, key)
captchasExpired = true
}
}
if captchasExpired {
data.Captchas = captchas
app.storage.SetInvitesKey(data.Code, data)
}
if data.IsReferral && (!data.UseReferralExpiry || data.ReferrerJellyfinID == "") {
continue
}
expiry := data.ValidTill
if !currentTime.After(expiry) {
continue
}
app.debug.Printf("Housekeeping: Deleting old invite %s", data.Code)
// Disable referrals for the user if UseReferralExpiry is enabled, so no new ones are made.
if data.IsReferral && data.UseReferralExpiry && data.ReferrerJellyfinID != "" {
user, ok := app.storage.GetEmailsKey(data.ReferrerJellyfinID)
if ok {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(data.ReferrerJellyfinID, user)
}
}
notify := data.Notify
if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
app.debug.Printf("%s: Expiry notification", data.Code)
@@ -53,6 +95,14 @@ func (app *appContext) checkInvites() {
wait.Wait()
}
app.storage.DeleteInvitesKey(data.Code)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeleteInvite,
SourceType: ActivityDaemon,
InviteCode: data.Code,
Value: data.Label,
Time: time.Now(),
}, nil, false)
}
}
@@ -96,14 +146,35 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
}
wait.Wait()
}
if inv.IsReferral && inv.ReferrerJellyfinID != "" && inv.UseReferralExpiry {
user, ok := app.storage.GetEmailsKey(inv.ReferrerJellyfinID)
if ok {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(inv.ReferrerJellyfinID, user)
}
}
match = false
app.storage.DeleteInvitesKey(code)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeleteInvite,
SourceType: ActivityDaemon,
InviteCode: code,
Value: inv.Label,
Time: time.Now(),
}, nil, false)
} else if used {
del := false
newInv := inv
if newInv.RemainingUses == 1 {
del = true
app.storage.DeleteInvitesKey(code)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeleteInvite,
SourceType: ActivityDaemon,
InviteCode: code,
Value: inv.Label,
Time: time.Now(),
}, nil, false)
} else if newInv.RemainingUses != 0 {
// 0 means infinite i guess?
newInv.RemainingUses--
@@ -130,17 +201,14 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
currentTime := time.Now()
validTill := currentTime.AddDate(0, req.Months, req.Days)
validTill = validTill.Add(time.Hour*time.Duration(req.Hours) + time.Minute*time.Duration(req.Minutes))
// make sure code doesn't begin with number
inviteCode := shortuuid.New()
_, err := strconv.Atoi(string(inviteCode[0]))
for err == nil {
inviteCode = shortuuid.New()
_, err = strconv.Atoi(string(inviteCode[0]))
}
var invite Invite
invite.Code = GenerateInviteCode()
if req.Label != "" {
invite.Label = req.Label
}
if req.UserLabel != "" {
invite.UserLabel = req.UserLabel
}
invite.Created = currentTime
if req.MultipleUses {
if req.NoLimit {
@@ -162,8 +230,8 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
if req.SendTo != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
addressValid := false
discord := ""
app.debug.Printf("%s: Sending invite message", inviteCode)
if discordEnabled && !strings.Contains(req.SendTo, "@") {
app.debug.Printf("%s: Sending invite message", invite.Code)
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
users := app.discord.GetUsers(req.SendTo)
if len(users) == 0 {
invite.SendTo = fmt.Sprintf("Failed: User not found: \"%s\"", req.SendTo)
@@ -179,10 +247,10 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
invite.SendTo = req.SendTo
}
if addressValid {
msg, err := app.email.constructInvite(inviteCode, invite, app, false)
msg, err := app.email.constructInvite(invite.Code, invite, app, false)
if err != nil {
invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo)
app.err.Printf("%s: Failed to construct invite message: %v", inviteCode, err)
app.err.Printf("%s: Failed to construct invite message: %v", invite.Code, err)
} else {
var err error
if discord != "" {
@@ -192,9 +260,9 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
}
if err != nil {
invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo)
app.err.Printf("%s: %s: %v", inviteCode, invite.SendTo, err)
app.err.Printf("%s: %s: %v", invite.Code, invite.SendTo, err)
} else {
app.info.Printf("%s: Sent invite email to \"%s\"", inviteCode, req.SendTo)
app.info.Printf("%s: Sent invite email to \"%s\"", invite.Code, req.SendTo)
}
}
}
@@ -206,7 +274,19 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
invite.Profile = "Default"
}
}
app.storage.SetInvitesKey(inviteCode, invite)
app.storage.SetInvitesKey(invite.Code, invite)
// Record activity
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityCreateInvite,
UserID: "",
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
InviteCode: invite.Code,
Value: invite.Label,
Time: time.Now(),
}, gc, false)
respondBool(200, true, gc)
}
@@ -222,7 +302,11 @@ func (app *appContext) GetInvites(gc *gin.Context) {
app.checkInvites()
var invites []inviteDTO
for _, inv := range app.storage.GetInvites() {
_, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
if inv.IsReferral {
continue
}
years, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
months += years * 12
invite := inviteDTO{
Code: inv.Code,
Months: months,
@@ -238,6 +322,7 @@ func (app *appContext) GetInvites(gc *gin.Context) {
Profile: inv.Profile,
NoLimit: inv.NoLimit,
Label: inv.Label,
UserLabel: inv.UserLabel,
}
if len(inv.UsedBy) != 0 {
invite.UsedBy = map[string]int64{}
@@ -396,10 +481,20 @@ func (app *appContext) DeleteInvite(gc *gin.Context) {
var req deleteInviteDTO
gc.BindJSON(&req)
app.debug.Printf("%s: Deletion requested", req.Code)
var ok bool
_, ok = app.storage.GetInvitesKey(req.Code)
inv, ok := app.storage.GetInvitesKey(req.Code)
if ok {
app.storage.DeleteInvitesKey(req.Code)
// Record activity
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeleteInvite,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
InviteCode: req.Code,
Value: inv.Label,
Time: time.Now(),
}, gc, false)
app.info.Printf("%s: Invite deleted", req.Code)
respondBool(200, true, gc)
return

100
api-jellyseerr.go Normal file
View File

@@ -0,0 +1,100 @@
package main
import (
"net/url"
"strconv"
"github.com/gin-gonic/gin"
)
// @Summary Get a list of Jellyseerr users.
// @Produce json
// @Success 200 {object} ombiUsersDTO
// @Failure 500 {object} stringResponse
// @Router /jellyseerr/users [get]
// @Security Bearer
// @tags Jellyseerr
func (app *appContext) JellyseerrUsers(gc *gin.Context) {
app.debug.Println("Jellyseerr users requested")
users, err := app.js.GetUsers()
if err != nil {
app.err.Printf("Failed to get users from Jellyseerr: %v", err)
respond(500, "Couldn't get users", gc)
return
}
app.debug.Printf("Jellyseerr users retrieved: %d", len(users))
userlist := make([]ombiUser, len(users))
i := 0
for _, u := range users {
userlist[i] = ombiUser{
Name: u.Name(),
ID: strconv.FormatInt(u.ID, 10),
}
i++
}
gc.JSON(200, ombiUsersDTO{Users: userlist})
}
// @Summary Store Jellyseerr user template in an existing profile.
// @Produce json
// @Param id path string true "Jellyseerr ID of user to source from"
// @Param profile path string true "Name of profile to store in"
// @Success 200 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Router /profiles/jellyseerr/{profile}/{id} [post]
// @Security Bearer
// @tags Jellyseerr
func (app *appContext) SetJellyseerrProfile(gc *gin.Context) {
jellyseerrID, err := strconv.ParseInt(gc.Param("id"), 10, 64)
if err != nil {
respondBool(400, false, gc)
return
}
escapedProfileName := gc.Param("profile")
profileName, _ := url.QueryUnescape(escapedProfileName)
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respondBool(400, false, gc)
return
}
u, err := app.js.UserByID(jellyseerrID)
if err != nil {
app.err.Printf("Couldn't get user from Jellyseerr: %v", err)
respond(500, "Couldn't get user", gc)
return
}
profile.Jellyseerr.User = u.UserTemplate
n, err := app.js.GetNotificationPreferencesByID(jellyseerrID)
if err != nil {
app.err.Printf("Couldn't get user's notification prefs from Jellyseerr: %v", err)
respond(500, "Couldn't get user notification prefs", gc)
return
}
profile.Jellyseerr.Notifications = n.NotificationsTemplate
profile.Jellyseerr.Enabled = true
app.storage.SetProfileKey(profileName, profile)
respondBool(204, true, gc)
}
// @Summary Remove jellyseerr user template from a profile.
// @Produce json
// @Param profile path string true "Name of profile to store in"
// @Success 200 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Router /profiles/jellyseerr/{profile} [delete]
// @Security Bearer
// @tags Jellyseerr
func (app *appContext) DeleteJellyseerrProfile(gc *gin.Context) {
escapedProfileName := gc.Param("profile")
profileName, _ := url.QueryUnescape(escapedProfileName)
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respondBool(400, false, gc)
return
}
profile.Jellyseerr.Enabled = false
app.storage.SetProfileKey(profileName, profile)
respondBool(204, true, gc)
}

View File

@@ -5,6 +5,8 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/jellyseerr"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
)
@@ -25,18 +27,20 @@ func (app *appContext) GetCustomContent(gc *gin.Context) {
adminLang = app.storage.lang.chosenAdminLang
}
list := emailListDTO{
"UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.MustGetCustomContentKey("UserCreated").Enabled},
"InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.MustGetCustomContentKey("InviteExpiry").Enabled},
"PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.MustGetCustomContentKey("PasswordReset").Enabled},
"UserDeleted": {Name: app.storage.lang.Email[lang].UserDeleted["name"], Enabled: app.storage.MustGetCustomContentKey("UserDeleted").Enabled},
"UserDisabled": {Name: app.storage.lang.Email[lang].UserDisabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserDisabled").Enabled},
"UserEnabled": {Name: app.storage.lang.Email[lang].UserEnabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserEnabled").Enabled},
"InviteEmail": {Name: app.storage.lang.Email[lang].InviteEmail["name"], Enabled: app.storage.MustGetCustomContentKey("InviteEmail").Enabled},
"WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.MustGetCustomContentKey("WelcomeEmail").Enabled},
"EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.MustGetCustomContentKey("EmailConfirmation").Enabled},
"UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpired").Enabled},
"UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.MustGetCustomContentKey("Login").Enabled},
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("Page").Enabled},
"UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.MustGetCustomContentKey("UserCreated").Enabled},
"InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.MustGetCustomContentKey("InviteExpiry").Enabled},
"PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.MustGetCustomContentKey("PasswordReset").Enabled},
"UserDeleted": {Name: app.storage.lang.Email[lang].UserDeleted["name"], Enabled: app.storage.MustGetCustomContentKey("UserDeleted").Enabled},
"UserDisabled": {Name: app.storage.lang.Email[lang].UserDisabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserDisabled").Enabled},
"UserEnabled": {Name: app.storage.lang.Email[lang].UserEnabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserEnabled").Enabled},
"UserExpiryAdjusted": {Name: app.storage.lang.Email[lang].UserExpiryAdjusted["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpiryAdjusted").Enabled},
"InviteEmail": {Name: app.storage.lang.Email[lang].InviteEmail["name"], Enabled: app.storage.MustGetCustomContentKey("InviteEmail").Enabled},
"WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.MustGetCustomContentKey("WelcomeEmail").Enabled},
"EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.MustGetCustomContentKey("EmailConfirmation").Enabled},
"UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpired").Enabled},
"UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.MustGetCustomContentKey("UserLogin").Enabled},
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("UserPage").Enabled},
"PostSignupCard": {Name: app.storage.lang.Admin[adminLang].Strings["postSignupCard"], Enabled: app.storage.MustGetCustomContentKey("PostSignupCard").Enabled, Description: app.storage.lang.Admin[adminLang].Strings["postSignupCardDescription"]},
}
filter := gc.Query("filter")
@@ -50,39 +54,6 @@ func (app *appContext) GetCustomContent(gc *gin.Context) {
gc.JSON(200, list)
}
// No longer needed, these are stored by string keys in the database now.
/* func (app *appContext) getCustomMessage(id string) *CustomContent {
switch id {
case "Announcement":
return &CustomContent{}
case "UserCreated":
return &app.storage.customEmails.UserCreated
case "InviteExpiry":
return &app.storage.customEmails.InviteExpiry
case "PasswordReset":
return &app.storage.customEmails.PasswordReset
case "UserDeleted":
return &app.storage.customEmails.UserDeleted
case "UserDisabled":
return &app.storage.customEmails.UserDisabled
case "UserEnabled":
return &app.storage.customEmails.UserEnabled
case "InviteEmail":
return &app.storage.customEmails.InviteEmail
case "WelcomeEmail":
return &app.storage.customEmails.WelcomeEmail
case "EmailConfirmation":
return &app.storage.customEmails.EmailConfirmation
case "UserExpired":
return &app.storage.customEmails.UserExpired
case "UserLogin":
return &app.storage.userPage.Login
case "UserPage":
return &app.storage.userPage.Page
}
return nil
} */
// @Summary Sets the corresponding custom content.
// @Produce json
// @Param CustomContent body CustomContent true "Content = email (in markdown)."
@@ -162,7 +133,7 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
username := app.storage.lang.Email[lang].Strings.get("username")
emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress")
customMessage, ok := app.storage.GetCustomContentKey(id)
if !ok {
if !ok && id != "Announcement" {
app.err.Printf("Failed to get custom message with ID \"%s\"", id)
respondBool(400, false, gc)
return
@@ -176,7 +147,11 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
} else if id == "UserLogin" {
variables = []string{}
customMessage.Variables = variables
} else if id == "PostSignupCard" {
variables = []string{"{username}", "{myAccountURL}"}
customMessage.Variables = variables
}
content = customMessage.Content
noContent := content == ""
if !noContent {
@@ -216,6 +191,11 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
msg, err = app.email.constructEnabled("", app, true)
}
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
case "UserExpiryAdjusted":
if noContent {
msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", app, true)
}
values = app.email.expiryAdjustedValues(username, time.Now(), app.storage.lang.Email[lang].Strings.get("reason"), app, false, true)
case "InviteEmail":
if noContent {
msg, err = app.email.constructInvite("", Invite{}, app, true)
@@ -236,14 +216,14 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
msg, err = app.email.constructUserExpired(app, true)
}
values = app.email.userExpiredValues(app, false)
case "UserLogin", "UserPage":
case "UserLogin", "UserPage", "PostSignupCard":
values = map[string]interface{}{}
}
if err != nil {
respondBool(500, false, gc)
return
}
if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" {
if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" && id != "PostSignupCard" {
content = msg.Text
variables = make([]string, strings.Count(content, "{"))
i := 0
@@ -269,17 +249,32 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
}
app.storage.SetCustomContentKey(id, customMessage)
var mail *Message
if id != "UserLogin" && id != "UserPage" {
if id != "UserLogin" && id != "UserPage" && id != "PostSignupCard" {
mail, err = app.email.constructTemplate("", "<div class=\"preview-content\"></div>", app)
if err != nil {
respondBool(500, false, gc)
return
}
} else if id == "PostSignupCard" {
// Jankiness follows.
// Source content from "Success Message" setting.
if noContent {
content = "# " + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("successHeader") + "\n" + app.config.Section("ui").Key("success_message").String()
if app.config.Section("user_page").Key("enabled").MustBool(false) {
content += "\n\n<br>\n" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.template("userPageSuccessMessage", tmpl{
"myAccount": "[" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("myAccount") + "]({myAccountURL})",
})
}
}
mail = &Message{
HTML: "<div class=\"card ~neutral dark:~d_neutral @low\"><div class=\"preview-content\"></div><br><button class=\"button ~urge dark:~d_urge @low full-width center supra submit\">" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("continue") + "</a></div>",
}
mail.Markdown = mail.HTML
} else {
mail = &Message{
HTML: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
Markdown: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
HTML: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
}
mail.Markdown = mail.HTML
}
gc.JSON(200, customEmailDTO{Content: content, Variables: variables, Conditionals: conditionals, Values: values, HTML: mail.HTML, Plaintext: mail.Text})
}
@@ -328,6 +323,14 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
tgUser.Lang = lang
}
app.storage.SetTelegramKey(req.ID, tgUser)
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldTelegram: tgUser.ChatID,
jellyseerr.FieldTelegramEnabled: tgUser.Contact,
}); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
}
linkExistingOmbiDiscordTelegram(app)
respondBool(200, true, gc)
}
@@ -352,6 +355,7 @@ func (app *appContext) SetContactMethods(gc *gin.Context) {
}
func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Context) {
jsPrefs := map[jellyseerr.NotificationsField]any{}
if tgUser, ok := app.storage.GetTelegramKey(req.ID); ok {
change := tgUser.Contact != req.Telegram
tgUser.Contact = req.Telegram
@@ -362,6 +366,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
msg = " not"
}
app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg)
jsPrefs[jellyseerr.FieldTelegramEnabled] = req.Telegram
}
}
if dcUser, ok := app.storage.GetDiscordKey(req.ID); ok {
@@ -374,6 +379,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
msg = " not"
}
app.debug.Printf("Discord: User \"%s\" will%s be notified through Discord.", dcUser.Username, msg)
jsPrefs[jellyseerr.FieldDiscordEnabled] = req.Discord
}
}
if mxUser, ok := app.storage.GetMatrixKey(req.ID); ok {
@@ -398,6 +404,13 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
msg = " not"
}
app.debug.Printf("\"%s\" will%s be notified via Email.", email.Addr, msg)
jsPrefs[jellyseerr.FieldEmailEnabled] = req.Email
}
}
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
err := app.js.ModifyNotifications(req.ID, jsPrefs)
if err != nil {
app.err.Printf("Failed to sync contact prefs with Jellyseerr: %v", err)
}
}
respondBool(200, true, gc)
@@ -572,6 +585,7 @@ func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
// @Failure 500 {object} boolResponse
// @Param MatrixLoginDTO body MatrixLoginDTO true "Username & password."
// @Router /matrix/login [post]
// @Security Bearer
// @tags Other
func (app *appContext) MatrixLogin(gc *gin.Context) {
var req MatrixLoginDTO
@@ -607,6 +621,7 @@ func (app *appContext) MatrixLogin(gc *gin.Context) {
// @Failure 500 {object} boolResponse
// @Param MatrixConnectUserDTO body MatrixConnectUserDTO true "User's Jellyfin ID & Matrix user ID."
// @Router /users/matrix [post]
// @Security Bearer
// @tags Other
func (app *appContext) MatrixConnect(gc *gin.Context) {
var req MatrixConnectUserDTO
@@ -638,6 +653,7 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
// @Failure 500 {object} boolResponse
// @Param username path string true "username to search."
// @Router /users/discord/{username} [get]
// @Security Bearer
// @tags Other
func (app *appContext) DiscordGetUsers(gc *gin.Context) {
name := gc.Param("username")
@@ -664,6 +680,7 @@ func (app *appContext) DiscordGetUsers(gc *gin.Context) {
// @Failure 500 {object} boolResponse
// @Param DiscordConnectUserDTO body DiscordConnectUserDTO true "User's Jellyfin ID & Discord ID."
// @Router /users/discord [post]
// @Security Bearer
// @tags Other
func (app *appContext) DiscordConnect(gc *gin.Context) {
var req DiscordConnectUserDTO
@@ -677,7 +694,25 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
respondBool(500, false, gc)
return
}
app.storage.SetDiscordKey(req.JellyfinID, user)
if err := app.js.ModifyNotifications(req.JellyfinID, map[jellyseerr.NotificationsField]any{
jellyseerr.FieldDiscord: req.DiscordID,
jellyseerr.FieldDiscordEnabled: true,
}); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: req.JellyfinID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
}, gc, false)
linkExistingOmbiDiscordTelegram(app)
respondBool(200, true, gc)
}
@@ -687,6 +722,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
// @Success 200 {object} boolResponse
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
// @Router /users/discord [delete]
// @Security Bearer
// @Tags Users
func (app *appContext) UnlinkDiscord(gc *gin.Context) {
var req forUserDTO
@@ -697,6 +733,24 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
return
} */
app.storage.DeleteDiscordKey(req.ID)
// May not actually remove Discord ID, but should disable interaction.
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier,
jellyseerr.FieldDiscordEnabled: false,
}); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: req.ID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
}, gc, false)
respondBool(200, true, gc)
}
@@ -705,6 +759,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
// @Success 200 {object} boolResponse
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
// @Router /users/telegram [delete]
// @Security Bearer
// @Tags Users
func (app *appContext) UnlinkTelegram(gc *gin.Context) {
var req forUserDTO
@@ -715,6 +770,23 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
return
} */
app.storage.DeleteTelegramKey(req.ID)
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier,
jellyseerr.FieldTelegramEnabled: false,
}); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: req.ID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
}, gc, false)
respondBool(200, true, gc)
}
@@ -723,6 +795,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
// @Success 200 {object} boolResponse
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
// @Router /users/matrix [delete]
// @Security Bearer
// @Tags Users
func (app *appContext) UnlinkMatrix(gc *gin.Context) {
var req forUserDTO
@@ -733,5 +806,15 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) {
return
} */
app.storage.DeleteMatrixKey(req.ID)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: req.ID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
}, gc, false)
respondBool(200, true, gc)
}

View File

@@ -2,8 +2,10 @@ package main
import (
"fmt"
"net/url"
"github.com/gin-gonic/gin"
"github.com/hrfee/mediabrowser"
)
func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, error) {
@@ -29,7 +31,31 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er
return ombiUser, code, err
}
}
return nil, 400, fmt.Errorf("Couldn't find user")
return nil, 400, fmt.Errorf("couldn't find user")
}
// Returns a user with the given name who has been imported from Jellyfin/Emby by Ombi
func (app *appContext) getOmbiImportedUser(name string) (map[string]interface{}, int, error) {
// Ombi User Types: 3/4 = Emby, 5 = Jellyfin
ombiUsers, code, err := app.ombi.GetUsers()
if err != nil || code != 200 {
return nil, code, err
}
for _, ombiUser := range ombiUsers {
if ombiUser["userName"].(string) == name {
uType, ok := ombiUser["userType"].(int)
if !ok { // Don't know if Ombi somehow allows duplicate usernames
continue
}
if serverType == mediabrowser.JellyfinServer && uType != 5 { // Jellyfin
continue
} else if uType != 3 && uType != 4 { // Emby
continue
}
return ombiUser, code, err
}
}
return nil, 400, fmt.Errorf("couldn't find user")
}
// @Summary Get a list of Ombi users.
@@ -70,7 +96,8 @@ func (app *appContext) OmbiUsers(gc *gin.Context) {
func (app *appContext) SetOmbiProfile(gc *gin.Context) {
var req ombiUser
gc.BindJSON(&req)
profileName := gc.Param("profile")
escapedProfileName := gc.Param("profile")
profileName, _ := url.QueryUnescape(escapedProfileName)
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respondBool(400, false, gc)
@@ -97,7 +124,8 @@ func (app *appContext) SetOmbiProfile(gc *gin.Context) {
// @Security Bearer
// @tags Ombi
func (app *appContext) DeleteOmbiProfile(gc *gin.Context) {
profileName := gc.Param("profile")
escapedProfileName := gc.Param("profile")
profileName, _ := url.QueryUnescape(escapedProfileName)
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respondBool(400, false, gc)
@@ -107,3 +135,18 @@ func (app *appContext) DeleteOmbiProfile(gc *gin.Context) {
app.storage.SetProfileKey(profileName, profile)
respondBool(204, true, gc)
}
func (app *appContext) applyOmbiProfile(user map[string]interface{}, profile map[string]interface{}) (status int, err error) {
for k, v := range profile {
switch v.(type) {
case map[string]interface{}, []interface{}:
user[k] = v
default:
if v != user[k] {
user[k] = v
}
}
}
status, err = app.ombi.ModifyUser(user)
return
}

View File

@@ -19,13 +19,24 @@ func (app *appContext) GetProfiles(gc *gin.Context) {
DefaultProfile: app.storage.GetDefaultProfile().Name,
Profiles: map[string]profileDTO{},
}
referralsEnabled := app.config.Section("user_page").Key("referrals").MustBool(false)
baseInv := Invite{}
for _, p := range app.storage.GetProfiles() {
out.Profiles[p.Name] = profileDTO{
Admin: p.Admin,
LibraryAccess: p.LibraryAccess,
FromUser: p.FromUser,
Ombi: p.Ombi != nil,
pdto := profileDTO{
Admin: p.Admin,
LibraryAccess: p.LibraryAccess,
FromUser: p.FromUser,
Ombi: p.Ombi != nil,
Jellyseerr: p.Jellyseerr.Enabled,
ReferralsEnabled: false,
}
if referralsEnabled {
err := app.storage.db.Get(p.ReferralTemplateKey, &baseInv)
if p.ReferralTemplateKey != "" && err == nil {
pdto.ReferralsEnabled = true
}
}
out.Profiles[p.Name] = pdto
}
gc.JSON(200, out)
}
@@ -79,8 +90,9 @@ func (app *appContext) CreateProfile(gc *gin.Context) {
return
}
profile := Profile{
FromUser: user.Name,
Policy: user.Policy,
FromUser: user.Name,
Policy: user.Policy,
Homescreen: req.Homescreen,
}
app.debug.Printf("Creating profile from user \"%s\"", user.Name)
if req.Homescreen {
@@ -93,6 +105,10 @@ func (app *appContext) CreateProfile(gc *gin.Context) {
}
}
app.storage.SetProfileKey(req.Name, profile)
// Refresh discord bots, profile list
if discordEnabled {
app.discord.UpdateCommands()
}
respondBool(200, true, gc)
}
@@ -110,3 +126,78 @@ func (app *appContext) DeleteProfile(gc *gin.Context) {
app.storage.DeleteProfileKey(name)
respondBool(200, true, gc)
}
// @Summary Enable referrals for a profile, sourced from the given invite by its code.
// @Produce json
// @Param profile path string true "name of profile to enable referrals for."
// @Param invite path string true "invite code to create referral template from."
// @Param useExpiry path string true "with-expiry or none."
// @Success 200 {object} boolResponse
// @Failure 400 {object} stringResponse
// @Failure 500 {object} stringResponse
// @Router /profiles/referral/{profile}/{invite}/{useExpiry} [post]
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) EnableReferralForProfile(gc *gin.Context) {
profileName := gc.Param("profile")
invCode := gc.Param("invite")
useExpiry := gc.Param("useExpiry") == "with-expiry"
inv, ok := app.storage.GetInvitesKey(invCode)
if !ok {
respond(400, "Invalid invite code", gc)
app.err.Printf("\"%s\": Failed to enable referrals: invite not found", profileName)
return
}
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respond(400, "Invalid profile", gc)
app.err.Printf("\"%s\": Failed to enable referrals: profile not found", profileName)
return
}
// Generate new code for referral template
inv.Code = GenerateInviteCode()
expiryDelta := inv.ValidTill.Sub(inv.Created)
inv.Created = time.Now()
if useExpiry {
inv.ValidTill = inv.Created.Add(expiryDelta)
} else {
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
}
inv.IsReferral = true
inv.UseReferralExpiry = useExpiry
// Since this is a template for multiple users, ReferrerJellyfinID is not set.
// inv.ReferrerJellyfinID = ...
app.storage.SetInvitesKey(inv.Code, inv)
profile.ReferralTemplateKey = inv.Code
app.storage.SetProfileKey(profile.Name, profile)
respondBool(200, true, gc)
}
// @Summary Disable referrals for a profile, and removes the referral template. no-op if not enabled.
// @Produce json
// @Param profile path string true "name of profile to enable referrals for."
// @Success 200 {object} boolResponse
// @Router /profiles/referral/{profile} [delete]
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) DisableReferralForProfile(gc *gin.Context) {
profileName := gc.Param("profile")
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respondBool(200, true, gc)
return
}
app.storage.DeleteInvitesKey(profile.ReferralTemplateKey)
profile.ReferralTemplateKey = ""
app.storage.SetProfileKey(profileName, profile)
respondBool(200, true, gc)
}

View File

@@ -8,6 +8,13 @@ import (
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
"github.com/hrfee/jfa-go/jellyseerr"
"github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4"
)
const (
REFERRAL_EXPIRY_DAYS = 90
)
// @Summary Returns the logged-in user's Jellyfin ID & Username, and other details.
@@ -74,6 +81,25 @@ func (app *appContext) MyDetails(gc *gin.Context) {
}
}
if app.config.Section("user_page").Key("referrals").MustBool(false) {
// 1. Look for existing template bound to this Jellyfin ID
// If one exists, that means its just for us and so we
// can use it directly.
inv := Invite{}
err := app.storage.db.FindOne(&inv, badgerhold.Where("ReferrerJellyfinID").Eq(resp.Id))
if err == nil {
resp.HasReferrals = true
} else {
// 2. Look for a template matching the key found in the user storage
// Since this key is shared between users in a profile, we make a copy.
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
err = app.storage.db.Get(user.ReferralTemplateKey, &inv)
if ok && err == nil {
resp.HasReferrals = true
}
}
}
gc.JSON(200, resp)
}
@@ -175,24 +201,16 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
gc.Redirect(http.StatusSeeOther, "/my/account")
return
} else if target == UserEmailChange {
emailStore, ok := app.storage.GetEmailsKey(id)
if !ok {
emailStore = EmailAddress{
Contact: true,
}
}
emailStore.Addr = claims["email"].(string)
app.storage.SetEmailsKey(id, emailStore)
if app.config.Section("ombi").Key("enabled").MustBool(false) {
ombiUser, code, err := app.getOmbiUser(id)
if code == 200 && err == nil {
ombiUser["emailAddress"] = claims["email"].(string)
code, err = app.ombi.ModifyUser(ombiUser)
if code != 200 || err != nil {
app.err.Printf("%s: Failed to change ombi email address (%d): %v", ombiUser["userName"].(string), code, err)
}
}
}
app.modifyEmail(id, claims["email"].(string))
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "email",
Time: time.Now(),
}, gc, true)
app.info.Println("Email list modified")
gc.Redirect(http.StatusSeeOther, "/my/account")
@@ -335,6 +353,23 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
dcUser.Contact = existingUser.Contact
}
app.storage.SetDiscordKey(gc.GetString("jfId"), dcUser)
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldDiscord: dcUser.ID,
jellyseerr.FieldDiscordEnabled: dcUser.Contact,
}); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
}, gc, true)
respondBool(200, true, gc)
}
@@ -373,6 +408,23 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
tgUser.Contact = existingUser.Contact
}
app.storage.SetTelegramKey(gc.GetString("jfId"), tgUser)
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldTelegram: tgUser.ChatID,
jellyseerr.FieldTelegramEnabled: tgUser.Contact,
}); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
}, gc, true)
respondBool(200, true, gc)
}
@@ -444,6 +496,16 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
}
app.storage.SetMatrixKey(gc.GetString("jfId"), mxUser)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
}, gc, true)
delete(app.matrix.tokens, pin)
respondBool(200, true, gc)
}
@@ -456,6 +518,23 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
// @Tags User Page
func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
app.storage.DeleteDiscordKey(gc.GetString("jfId"))
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier,
jellyseerr.FieldDiscordEnabled: false,
}); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
}, gc, true)
respondBool(200, true, gc)
}
@@ -467,6 +546,23 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
// @Tags User Page
func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
app.storage.DeleteTelegramKey(gc.GetString("jfId"))
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier,
jellyseerr.FieldTelegramEnabled: false,
}); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
}, gc, true)
respondBool(200, true, gc)
}
@@ -478,6 +574,16 @@ func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
// @Tags User Page
func (app *appContext) UnlinkMyMatrix(gc *gin.Context) {
app.storage.DeleteMatrixKey(gc.GetString("jfId"))
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
}, gc, true)
respondBool(200, true, gc)
}
@@ -495,6 +601,9 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
cancel := time.AfterFunc(1*time.Second, func() {
timerWait <- true
})
usernameAllowed := app.config.Section("user_page").Key("allow_pwr_username").MustBool(true)
emailAllowed := app.config.Section("user_page").Key("allow_pwr_email").MustBool(true)
contactMethodAllowed := app.config.Section("user_page").Key("allow_pwr_contact_method").MustBool(true)
address := gc.Param("address")
if address == "" {
app.debug.Println("Ignoring empty request for PWR")
@@ -505,7 +614,7 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
var pwr InternalPWR
var err error
jfUser, ok := app.ReverseUserSearch(address)
jfUser, ok := app.ReverseUserSearch(address, usernameAllowed, emailAllowed, contactMethodAllowed)
if !ok {
app.debug.Printf("Ignoring PWR request: User not found")
@@ -596,6 +705,15 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
respondBool(500, false, gc)
return
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityChangePassword,
UserID: user.ID,
SourceType: ActivityUser,
Source: user.ID,
Time: time.Now(),
}, gc, true)
if app.config.Section("ombi").Key("enabled").MustBool(false) {
func() {
ombiUser, status, err := app.getOmbiUser(gc.GetString("jfId"))
@@ -621,3 +739,68 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
}
respondBool(204, true, gc)
}
// @Summary Get or generate a new referral code.
// @Produce json
// @Success 200 {object} GetMyReferralRespDTO
// @Failure 400 {object} boolResponse
// @Failure 401 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Router /my/referral [get]
// @Security Bearer
// @Tags User Page
func (app *appContext) GetMyReferral(gc *gin.Context) {
// 1. Look for existing template bound to this Jellyfin ID
// If one exists, that means its just for us and so we
// can use it directly.
inv := Invite{}
err := app.storage.db.FindOne(&inv, badgerhold.Where("ReferrerJellyfinID").Eq(gc.GetString("jfId")))
if err != nil {
// 2. Look for a template matching the key found in the user storage
// Since this key is shared between users in a profile, we make a copy.
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
err = app.storage.db.Get(user.ReferralTemplateKey, &inv)
if !ok || err != nil || user.ReferralTemplateKey == "" {
app.debug.Printf("Ignoring referral request, couldn't find template.")
respondBool(400, false, gc)
return
}
inv.Code = GenerateInviteCode()
expiryDelta := inv.ValidTill.Sub(inv.Created)
inv.Created = time.Now()
if inv.UseReferralExpiry {
inv.ValidTill = inv.Created.Add(expiryDelta)
} else {
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
}
inv.IsReferral = true
inv.ReferrerJellyfinID = gc.GetString("jfId")
app.storage.SetInvitesKey(inv.Code, inv)
} else if time.Now().After(inv.ValidTill) {
// 3. We found an invite for us, but it's expired.
// We delete it from storage, and put it back with a fresh code and expiry.
// If UseReferralExpiry is enabled, we delete it and return nothing.
app.storage.DeleteInvitesKey(inv.Code)
if inv.UseReferralExpiry {
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
if ok {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(gc.GetString("jfId"), user)
}
app.debug.Printf("Ignoring referral request, expired.")
respondBool(400, false, gc)
return
}
inv.Code = GenerateInviteCode()
inv.Created = time.Now()
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
app.storage.SetInvitesKey(inv.Code, inv)
}
gc.JSON(200, GetMyReferralRespDTO{
Code: inv.Code,
RemainingUses: inv.RemainingUses,
NoLimit: inv.NoLimit,
Expiry: inv.ValidTill.Unix(),
UseExpiry: inv.UseReferralExpiry,
})
}

View File

@@ -2,13 +2,17 @@ package main
import (
"fmt"
"net/url"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
"github.com/hrfee/jfa-go/jellyseerr"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4"
)
// @Summary Creates a new Jellyfin user without an invite.
@@ -44,9 +48,25 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
return
}
id := user.ID
// Record activity
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityCreation,
UserID: id,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: user.Name,
Time: time.Now(),
}, gc, false)
profile := app.storage.GetDefaultProfile()
// Check profile isn't empty
if profile.Policy.BlockedTags != nil {
if req.Profile != "" && req.Profile != "none" {
if p, ok := app.storage.GetProfileKey(req.Profile); ok {
profile = p
} else {
app.debug.Printf("Couldn't find profile \"%s\", using default", req.Profile)
}
status, err = app.jf.SetPolicy(id, profile.Policy)
if !(status == 200 || status == 204 || err == nil) {
app.err.Printf("%s: Failed to set user policy (%d): %v", req.Username, status, err)
@@ -64,7 +84,6 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
app.storage.SetEmailsKey(id, EmailAddress{Addr: req.Email, Contact: true})
}
if app.config.Section("ombi").Key("enabled").MustBool(false) {
profile := app.storage.GetDefaultProfile()
if profile.Ombi == nil {
profile.Ombi = map[string]interface{}{}
}
@@ -76,6 +95,29 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
app.info.Println("Created Ombi user")
}
}
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
// Gets existing user (not possible) or imports the given user.
_, err := app.js.MustGetUser(id)
if err != nil {
app.err.Printf("Failed to create Jellyseerr user: %v", err)
} else {
app.info.Println("Created Jellyseerr user")
}
err = app.js.ApplyTemplateToUser(id, profile.Jellyseerr.User)
if err != nil {
app.err.Printf("Failed to apply Jellyseerr user template: %v\n", err)
}
err = app.js.ApplyNotificationsTemplateToUser(id, profile.Jellyseerr.Notifications)
if err != nil {
app.err.Printf("Failed to apply Jellyseerr notifications template: %v\n", err)
}
if emailEnabled {
err = app.js.ModifyUser(id, map[jellyseerr.UserField]any{jellyseerr.FieldEmail: req.Email})
if err != nil {
app.err.Printf("Failed to set Jellyseerr email address: %v\n", err)
}
}
}
if emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "" {
app.debug.Printf("%s: Sending welcome email to %s", req.Username, req.Email)
msg, err := app.email.constructWelcome(req.Username, time.Time{}, app, false)
@@ -97,7 +139,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
type errorFunc func(gc *gin.Context)
// Used on the form & when a users email has been confirmed.
func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, success bool) {
func (app *appContext) newUser(req newUserDTO, confirmed bool, gc *gin.Context) (f errorFunc, success bool) {
existingUser, _, _ := app.jf.UserByName(req.Username, false)
if existingUser.Name != "" {
f = func(gc *gin.Context) {
@@ -275,28 +317,60 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) {
for address, settings := range invite.Notify {
if settings["notify-creation"] {
go func() {
go func(addr string) {
msg, err := app.email.constructCreated(req.Code, req.Username, req.Email, invite, app, false)
if err != nil {
app.err.Printf("%s: Failed to construct user creation notification: %v", req.Code, err)
} else {
// Check whether notify "address" is an email address of Jellyfin ID
if strings.Contains(address, "@") {
err = app.email.send(msg, address)
// Check whether notify "addr" is an email address of Jellyfin ID
if strings.Contains(addr, "@") {
err = app.email.send(msg, addr)
} else {
err = app.sendByID(msg, address)
err = app.sendByID(msg, addr)
}
if err != nil {
app.err.Printf("%s: Failed to send user creation notification: %v", req.Code, err)
} else {
app.info.Printf("Sent user creation notification to %s", address)
app.info.Printf("Sent user creation notification to %s", addr)
}
}
}()
}(address)
}
}
}
id := user.ID
// Record activity
sourceType := ActivityAnon
source := ""
if invite.ReferrerJellyfinID != "" {
sourceType = ActivityUser
source = invite.ReferrerJellyfinID
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityCreation,
UserID: id,
SourceType: sourceType,
Source: source,
InviteCode: invite.Code,
Value: user.Name,
Time: time.Now(),
}, gc, true)
emailStore := EmailAddress{
Addr: req.Email,
Contact: (req.Email != ""),
}
// Only allow disabling of email contact if some other method is available.
if req.DiscordContact || req.TelegramContact || req.MatrixContact {
emailStore.Contact = req.EmailContact
}
if invite.UserLabel != "" {
emailStore.Label = invite.UserLabel
}
var profile Profile
if invite.Profile != "" {
app.debug.Printf("Applying settings from profile \"%s\"", invite.Profile)
@@ -305,27 +379,41 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
if !ok {
profile = app.storage.GetDefaultProfile()
}
if profile.Policy.BlockedTags != nil {
app.debug.Printf("Applying policy from profile \"%s\"", invite.Profile)
status, err = app.jf.SetPolicy(id, profile.Policy)
if !((status == 200 || status == 204) && err == nil) {
app.err.Printf("%s: Failed to set user policy (%d): %v", req.Code, status, err)
}
app.debug.Printf("Applying policy from profile \"%s\"", invite.Profile)
status, err = app.jf.SetPolicy(id, profile.Policy)
if !((status == 200 || status == 204) && err == nil) {
app.err.Printf("%s: Failed to set user policy (%d): %v", req.Code, status, err)
}
if profile.Configuration.GroupedFolders != nil && len(profile.Displayprefs) != 0 {
app.debug.Printf("Applying homescreen from profile \"%s\"", invite.Profile)
status, err = app.jf.SetConfiguration(id, profile.Configuration)
if (status == 200 || status == 204) && err == nil {
status, err = app.jf.SetDisplayPreferences(id, profile.Displayprefs)
}
if !((status == 200 || status == 204) && err == nil) {
app.err.Printf("%s: Failed to set configuration template (%d): %v", req.Code, status, err)
app.debug.Printf("Applying homescreen from profile \"%s\"", invite.Profile)
status, err = app.jf.SetConfiguration(id, profile.Configuration)
if (status == 200 || status == 204) && err == nil {
status, err = app.jf.SetDisplayPreferences(id, profile.Displayprefs)
}
if !((status == 200 || status == 204) && err == nil) {
app.err.Printf("%s: Failed to set configuration template (%d): %v", req.Code, status, err)
}
if app.config.Section("user_page").Key("enabled").MustBool(false) && app.config.Section("user_page").Key("referrals").MustBool(false) && profile.ReferralTemplateKey != "" {
emailStore.ReferralTemplateKey = profile.ReferralTemplateKey
// Store here, just incase email are disabled (whether this is even possible, i don't know)
app.storage.SetEmailsKey(id, emailStore)
// If UseReferralExpiry is enabled, create the ref now so the clock starts ticking
refInv := Invite{}
err = app.storage.db.Get(profile.ReferralTemplateKey, &refInv)
if refInv.UseReferralExpiry {
refInv.Code = GenerateInviteCode()
expiryDelta := refInv.ValidTill.Sub(refInv.Created)
refInv.Created = time.Now()
refInv.ValidTill = refInv.Created.Add(expiryDelta)
refInv.IsReferral = true
refInv.ReferrerJellyfinID = id
app.storage.SetInvitesKey(refInv.Code, refInv)
}
}
}
// if app.config.Section("password_resets").Key("enabled").MustBool(false) {
if req.Email != "" {
app.storage.SetEmailsKey(id, EmailAddress{Addr: req.Email, Contact: true})
if req.Email != "" || invite.UserLabel != "" {
app.storage.SetEmailsKey(id, emailStore)
}
expiry := time.Time{}
if invite.UserExpiry {
@@ -337,6 +425,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
if app.storage.deprecatedDiscord == nil {
app.storage.deprecatedDiscord = discordStore{}
}
// Note we don't log an activity here, since it's part of creating a user.
app.storage.SetDiscordKey(user.ID, discordUser)
delete(app.discord.verifiedTokens, req.DiscordPIN)
}
@@ -359,30 +448,47 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
if profile.Ombi != nil && len(profile.Ombi) != 0 {
template := profile.Ombi
errors, code, err := app.ombi.NewUser(req.Username, req.Password, req.Email, template)
accountExists := false
var ombiUser map[string]interface{}
if err != nil || code != 200 {
app.info.Printf("Failed to create Ombi user (%d): %s", code, err)
app.debug.Printf("Errors reported by Ombi: %s", strings.Join(errors, ", "))
} else {
app.info.Println("Created Ombi user")
if discordVerified || telegramVerified {
ombiUser, status, err := app.getOmbiUser(id)
// Check if on the off chance, Ombi's user importer has already added the account.
ombiUser, status, err = app.getOmbiImportedUser(req.Username)
if status == 200 && err == nil {
app.info.Println("Found existing Ombi user, applying changes")
accountExists = true
template["password"] = req.Password
status, err = app.applyOmbiProfile(ombiUser, template)
if status != 200 || err != nil {
app.err.Printf("Failed to get Ombi user (%d): %v", status, err)
} else {
dID := ""
tUser := ""
if discordVerified {
dID = discordUser.ID
}
if telegramVerified {
u, _ := app.storage.GetTelegramKey(user.ID)
tUser = u.Username
}
resp, status, err := app.ombi.SetNotificationPrefs(ombiUser, dID, tUser)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to link Telegram/Discord to Ombi (%d): %v", status, err)
app.debug.Printf("Response: %v", resp)
}
app.err.Printf("Failed to modify existing Ombi user (%d): %v\n", status, err)
}
} else {
app.info.Printf("Failed to create Ombi user (%d): %s", code, err)
app.debug.Printf("Errors reported by Ombi: %s", strings.Join(errors, ", "))
}
} else {
ombiUser, status, err = app.getOmbiUser(id)
if status != 200 || err != nil {
app.err.Printf("Failed to get Ombi user (%d): %v", status, err)
} else {
app.info.Println("Created Ombi user")
accountExists = true
}
}
if accountExists {
if discordVerified || telegramVerified {
dID := ""
tUser := ""
if discordVerified {
dID = discordUser.ID
}
if telegramVerified {
u, _ := app.storage.GetTelegramKey(user.ID)
tUser = u.Username
}
resp, status, err := app.ombi.SetNotificationPrefs(ombiUser, dID, tUser)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to link Telegram/Discord to Ombi (%d): %v", status, err)
app.debug.Printf("Response: %v", resp)
}
}
}
@@ -390,6 +496,51 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
app.debug.Printf("Skipping Ombi: Profile \"%s\" was empty", invite.Profile)
}
}
if invite.Profile != "" && app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
if profile.Jellyseerr.Enabled {
// Gets existing user (not possible) or imports the given user.
_, err := app.js.MustGetUser(id)
if err != nil {
app.err.Printf("Failed to create Jellyseerr user: %v", err)
} else {
app.info.Println("Created Jellyseerr user")
}
err = app.js.ApplyTemplateToUser(id, profile.Jellyseerr.User)
if err != nil {
app.err.Printf("Failed to apply Jellyseerr user template: %v\n", err)
}
err = app.js.ApplyNotificationsTemplateToUser(id, profile.Jellyseerr.Notifications)
if err != nil {
app.err.Printf("Failed to apply Jellyseerr notifications template: %v\n", err)
}
contactMethods := map[jellyseerr.NotificationsField]any{}
if emailEnabled {
err = app.js.ModifyMainUserSettings(id, jellyseerr.MainUserSettings{Email: req.Email})
if err != nil {
app.err.Printf("Failed to set Jellyseerr email address: %v\n", err)
} else {
contactMethods[jellyseerr.FieldEmailEnabled] = req.EmailContact
}
}
if discordVerified {
contactMethods[jellyseerr.FieldDiscord] = discordUser.ID
contactMethods[jellyseerr.FieldDiscordEnabled] = req.DiscordContact
}
if telegramVerified {
u, _ := app.storage.GetTelegramKey(user.ID)
contactMethods[jellyseerr.FieldTelegram] = u.ChatID
contactMethods[jellyseerr.FieldTelegramEnabled] = req.TelegramContact
}
if emailEnabled || discordVerified || telegramVerified {
err := app.js.ModifyNotifications(id, contactMethods)
if err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
}
}
} else {
app.debug.Printf("Skipping Jellyseerr: Profile \"%s\" was empty", invite.Profile)
}
}
if matrixVerified {
matrixUser.Contact = req.MatrixContact
delete(app.matrix.tokens, req.MatrixPIN)
@@ -426,7 +577,7 @@ func (app *appContext) NewUser(gc *gin.Context) {
var req newUserDTO
gc.BindJSON(&req)
app.debug.Printf("%s: New user attempt", req.Code)
if app.config.Section("captcha").Key("enabled").MustBool(false) && !app.verifyCaptcha(req.Code, req.CaptchaID, req.CaptchaText) {
if app.config.Section("captcha").Key("enabled").MustBool(false) && !app.verifyCaptcha(req.Code, req.CaptchaID, req.CaptchaText, false) {
app.info.Printf("%s: New user failed: Captcha Incorrect", req.Code)
respond(400, "errorCaptcha", gc)
return
@@ -462,7 +613,7 @@ func (app *appContext) NewUser(gc *gin.Context) {
return
}
}
f, success := app.newUser(req, false)
f, success := app.newUser(req, false, gc)
if !success {
f(gc)
return
@@ -506,6 +657,10 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
sendMail = false
}
}
activityType := ActivityDisabled
if req.Enabled {
activityType = ActivityEnabled
}
for _, userID := range req.Users {
user, status, err := app.jf.UserByID(userID, false)
if status != 200 || err != nil {
@@ -520,6 +675,16 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
app.err.Printf("Failed to set policy for user \"%s\" (%d): %v", userID, status, err)
continue
}
// Record activity
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: activityType,
UserID: userID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Time: time.Now(),
}, gc, false)
if sendMail && req.Notify {
if err := app.sendByID(msg, userID); err != nil {
app.err.Printf("Failed to send account enabled/disabled email: %v", err)
@@ -572,6 +737,12 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
}
}
}
username := ""
if user, status, err := app.jf.UserByID(userID, false); status == 200 && err == nil {
username = user.Name
}
status, err := app.jf.DeleteUser(userID)
if !(status == 200 || status == 204) || err != nil {
msg := fmt.Sprintf("%d: %v", status, err)
@@ -581,6 +752,17 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
errors[userID] += msg
}
}
// Record activity
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeletion,
UserID: userID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: username,
Time: time.Now(),
}, gc, false)
if sendMail && req.Notify {
if err := app.sendByID(msg, userID); err != nil {
app.err.Printf("Failed to send account deletion email: %v", err)
@@ -599,7 +781,7 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
respondBool(200, true, gc)
}
// @Summary Extend time before the user(s) expiry, or create and expiry if it doesn't exist.
// @Summary Extend time before the user(s) expiry, or create an expiry if it doesn't exist.
// @Produce json
// @Param extendExpiryDTO body extendExpiryDTO true "Extend expiry object"
// @Success 200 {object} boolResponse
@@ -611,7 +793,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
var req extendExpiryDTO
gc.BindJSON(&req)
app.info.Printf("Expiry extension requested for %d user(s)", len(req.Users))
if req.Months <= 0 && req.Days <= 0 && req.Hours <= 0 && req.Minutes <= 0 {
if req.Months <= 0 && req.Days <= 0 && req.Hours <= 0 && req.Minutes <= 0 && req.Timestamp <= 0 {
respondBool(400, false, gc)
return
}
@@ -623,12 +805,128 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
} else {
app.debug.Printf("Created expiry for \"%s\"", id)
}
expiry := UserExpiry{Expiry: base.AddDate(0, req.Months, req.Days).Add(time.Duration(((60 * req.Hours) + req.Minutes)) * time.Minute)}
expiry := UserExpiry{}
if req.Timestamp != 0 {
expiry.Expiry = time.Unix(req.Timestamp, 0)
} else {
expiry.Expiry = base.AddDate(0, req.Months, req.Days).Add(time.Duration(((60 * req.Hours) + req.Minutes)) * time.Minute)
}
app.storage.SetUserExpiryKey(id, expiry)
if messagesEnabled && req.Notify {
go func(uid string, exp time.Time) {
user, status, err := app.jf.UserByID(uid, false)
if status != 200 || err != nil {
return
}
msg, err := app.email.constructExpiryAdjusted(user.Name, exp, req.Reason, app, false)
if err != nil {
app.err.Printf("%s: Failed to construct expiry adjustment notification: %v", uid, err)
return
}
if err := app.sendByID(msg, uid); err != nil {
app.err.Printf("%s: Failed to send expiry adjustment notification: %v", uid, err)
}
}(id, expiry.Expiry)
}
}
respondBool(204, true, gc)
}
// @Summary Remove an expiry from a user's account.
// @Produce json
// @Param id path string true "id of user to extend expiry of."
// @Success 200 {object} boolResponse
// @Router /users/{id}/expiry [delete]
// @tags Users
func (app *appContext) RemoveExpiry(gc *gin.Context) {
app.storage.DeleteUserExpiryKey(gc.Param("id"))
respondBool(200, true, gc)
}
// @Summary Enable referrals for the given user(s) based on the rules set in the given invite code, or profile.
// @Produce json
// @Param EnableDisableReferralDTO body EnableDisableReferralDTO true "List of users"
// @Param mode path string true "mode of template sourcing from 'invite' or 'profile'."
// @Param source path string true "invite code or profile name, depending on what mode is."
// @Param useExpiry path string true "with-expiry or none."
// @Success 200 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Router /users/referral/{mode}/{source}/{useExpiry} [post]
// @Security Bearer
// @tags Users
func (app *appContext) EnableReferralForUsers(gc *gin.Context) {
var req EnableDisableReferralDTO
gc.BindJSON(&req)
mode := gc.Param("mode")
source := gc.Param("source")
useExpiry := gc.Param("useExpiry") == "with-expiry"
baseInv := Invite{}
if mode == "profile" {
profile, ok := app.storage.GetProfileKey(source)
err := app.storage.db.Get(profile.ReferralTemplateKey, &baseInv)
if !ok || profile.ReferralTemplateKey == "" || err != nil {
app.debug.Printf("Couldn't find template to source from")
respondBool(400, false, gc)
return
}
app.debug.Printf("Found referral template in profile: %+v\n", profile.ReferralTemplateKey)
} else if mode == "invite" {
// Get the invite, and modify it to turn it into a referral
err := app.storage.db.Get(source, &baseInv)
if err != nil {
app.debug.Printf("Couldn't find invite to source from")
respondBool(400, false, gc)
return
}
}
for _, u := range req.Users {
// 1. Wipe out any existing referral codes.
app.storage.db.DeleteMatching(Invite{}, badgerhold.Where("ReferrerJellyfinID").Eq(u))
// 2. Generate referral invite.
inv := baseInv
inv.Code = GenerateInviteCode()
expiryDelta := inv.ValidTill.Sub(inv.Created)
inv.Created = time.Now()
if useExpiry {
inv.ValidTill = inv.Created.Add(expiryDelta)
} else {
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
}
inv.IsReferral = true
inv.ReferrerJellyfinID = u
inv.UseReferralExpiry = useExpiry
app.storage.SetInvitesKey(inv.Code, inv)
}
}
// @Summary Disable referrals for the given user(s).
// @Produce json
// @Param EnableDisableReferralDTO body EnableDisableReferralDTO true "List of users"
// @Success 200 {object} boolResponse
// @Router /users/referral [delete]
// @Security Bearer
// @tags Users
func (app *appContext) DisableReferralForUsers(gc *gin.Context) {
var req EnableDisableReferralDTO
gc.BindJSON(&req)
for _, u := range req.Users {
// 1. Delete directly bound template
app.storage.db.DeleteMatching(Invite{}, badgerhold.Where("ReferrerJellyfinID").Eq(u))
// 2. Check for and delete profile-attached template
user, ok := app.storage.GetEmailsKey(u)
if !ok {
continue
}
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(u, user)
}
respondBool(200, true, gc)
}
// @Summary Send an announcement via email to a given list of users.
// @Produce json
// @Param announcementDTO body announcementDTO true "Announcement request object"
@@ -719,12 +1017,17 @@ func (app *appContext) GetAnnounceTemplates(gc *gin.Context) {
// @Produce json
// @Success 200 {object} announcementTemplate
// @Failure 400 {object} boolResponse
// @Param name path string true "name of template"
// @Param name path string true "name of template (url encoded if necessary)"
// @Router /users/announce/template/{name} [get]
// @Security Bearer
// @tags Users
func (app *appContext) GetAnnounceTemplate(gc *gin.Context) {
name := gc.Param("name")
escapedName := gc.Param("name")
name, err := url.QueryUnescape(escapedName)
if err != nil {
respondBool(400, false, gc)
return
}
if announcement, ok := app.storage.GetAnnouncementsKey(name); ok {
gc.JSON(200, announcement)
return
@@ -833,13 +1136,15 @@ func (app *appContext) GetUsers(gc *gin.Context) {
}
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
allowAll := app.config.Section("ui").Key("allow_all").MustBool(false)
referralsEnabled := app.config.Section("user_page").Key("referrals").MustBool(false)
i := 0
for _, jfUser := range users {
user := respUser{
ID: jfUser.ID,
Name: jfUser.Name,
Admin: jfUser.Policy.IsAdministrator,
Disabled: jfUser.Policy.IsDisabled,
ID: jfUser.ID,
Name: jfUser.Name,
Admin: jfUser.Policy.IsAdministrator,
Disabled: jfUser.Policy.IsDisabled,
ReferralsEnabled: false,
}
if !jfUser.LastActivityDate.IsZero() {
user.LastActive = jfUser.LastActivityDate.Unix()
@@ -868,6 +1173,18 @@ func (app *appContext) GetUsers(gc *gin.Context) {
user.DiscordID = dcUser.ID
user.NotifyThroughDiscord = dcUser.Contact
}
// FIXME: Send referral data
referrerInv := Invite{}
if referralsEnabled {
// 1. Directly attached invite.
err := app.storage.db.FindOne(&referrerInv, badgerhold.Where("ReferrerJellyfinID").Eq(jfUser.ID))
if err == nil {
user.ReferralsEnabled = true
// 2. Referrals via profile template. Shallow check, doesn't look for the thing in the database.
} else if email, ok := app.storage.GetEmailsKey(jfUser.ID); ok && email.ReferralTemplateKey != "" {
user.ReferralsEnabled = true
}
}
resp.UserList[i] = user
i++
}
@@ -940,6 +1257,44 @@ func (app *appContext) ModifyLabels(gc *gin.Context) {
respondBool(204, true, gc)
}
func (app *appContext) modifyEmail(jfID string, addr string) {
contactPrefChanged := false
emailStore, ok := app.storage.GetEmailsKey(jfID)
// Auto enable contact by email for newly added addresses
if !ok || emailStore.Addr == "" {
emailStore = EmailAddress{
Contact: true,
}
contactPrefChanged = true
}
emailStore.Addr = addr
app.storage.SetEmailsKey(jfID, emailStore)
if app.config.Section("ombi").Key("enabled").MustBool(false) {
ombiUser, code, err := app.getOmbiUser(jfID)
if code == 200 && err == nil {
ombiUser["emailAddress"] = addr
code, err = app.ombi.ModifyUser(ombiUser)
if code != 200 || err != nil {
app.err.Printf("%s: Failed to change ombi email address (%d): %v", ombiUser["userName"].(string), code, err)
}
}
}
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
err := app.js.ModifyMainUserSettings(jfID, jellyseerr.MainUserSettings{Email: addr})
if err != nil {
app.err.Printf("Failed to set Jellyseerr email address: %v\n", err)
} else if contactPrefChanged {
contactMethods := map[jellyseerr.NotificationsField]any{
jellyseerr.FieldEmailEnabled: true,
}
err := app.js.ModifyNotifications(jfID, contactMethods)
if err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
}
}
}
}
// @Summary Modify user's email addresses.
// @Produce json
// @Param modifyEmailsDTO body modifyEmailsDTO true "Map of userIDs to email addresses"
@@ -958,32 +1313,23 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
respond(500, "Couldn't get users", gc)
return
}
ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false)
for _, jfUser := range users {
id := jfUser.ID
if address, ok := req[id]; ok {
var emailStore = EmailAddress{}
oldEmail, ok := app.storage.GetEmailsKey(id)
if ok {
emailStore = oldEmail
}
// Auto enable contact by email for newly added addresses
if !ok || oldEmail.Addr == "" {
emailStore.Contact = true
}
app.modifyEmail(id, address)
emailStore.Addr = address
app.storage.SetEmailsKey(id, emailStore)
if ombiEnabled {
ombiUser, code, err := app.getOmbiUser(id)
if code == 200 && err == nil {
ombiUser["emailAddress"] = address
code, err = app.ombi.ModifyUser(ombiUser)
if code != 200 || err != nil {
app.err.Printf("%s: Failed to change ombi email address (%d): %v", ombiUser["userName"].(string), code, err)
}
}
activityType := ActivityContactLinked
if address == "" {
activityType = ActivityContactUnlinked
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: activityType,
UserID: id,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "email",
Time: time.Now(),
}, gc, false)
}
}
app.info.Println("Email list modified")
@@ -1007,16 +1353,18 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
var configuration mediabrowser.Configuration
var displayprefs map[string]interface{}
var ombi map[string]interface{}
var jellyseerr JellyseerrTemplate
jellyseerr.Enabled = false
if req.From == "profile" {
// Check profile exists & isn't empty
profile, ok := app.storage.GetProfileKey(req.Profile)
if !ok || profile.Policy.BlockedTags == nil {
if !ok {
app.err.Printf("Couldn't find profile \"%s\" or profile was empty", req.Profile)
respond(500, "Couldn't find profile", gc)
return
}
if req.Homescreen {
if profile.Configuration.GroupedFolders == nil || len(profile.Displayprefs) == 0 {
if !profile.Homescreen {
app.err.Printf("No homescreen saved in profile \"%s\"", req.Profile)
respond(500, "No homescreen template available", gc)
return
@@ -1024,12 +1372,19 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
configuration = profile.Configuration
displayprefs = profile.Displayprefs
}
policy = profile.Policy
if app.config.Section("ombi").Key("enabled").MustBool(false) {
if req.Policy {
policy = profile.Policy
}
if req.Ombi && app.config.Section("ombi").Key("enabled").MustBool(false) {
if profile.Ombi != nil && len(profile.Ombi) != 0 {
ombi = profile.Ombi
}
}
if req.Jellyseerr && app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
if profile.Jellyseerr.Enabled {
jellyseerr = profile.Jellyseerr
}
}
} else if req.From == "user" {
applyingFrom = "user"
@@ -1041,7 +1396,9 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
return
}
applyingFrom = "\"" + user.Name + "\""
policy = user.Policy
if req.Policy {
policy = user.Policy
}
if req.Homescreen {
displayprefs, status, err = app.jf.GetDisplayPreferences(req.ID)
if !(status == 200 || status == 204) || err != nil {
@@ -1057,6 +1414,7 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
"policy": map[string]string{},
"homescreen": map[string]string{},
"ombi": map[string]string{},
"jellyseerr": map[string]string{},
}
/* Jellyfin doesn't seem to like too many of these requests sent in succession
and can crash and mess up its database. Issue #160 says this occurs when more
@@ -1067,9 +1425,13 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
app.debug.Println("Adding delay between requests for large batch")
}
for _, id := range req.ApplyTo {
status, err := app.jf.SetPolicy(id, policy)
if !(status == 200 || status == 204) || err != nil {
errors["policy"][id] = fmt.Sprintf("%d: %s", status, err)
var status int
var err error
if req.Policy {
status, err = app.jf.SetPolicy(id, policy)
if !(status == 200 || status == 204) || err != nil {
errors["policy"][id] = fmt.Sprintf("%d: %s", status, err)
}
}
if shouldDelay {
time.Sleep(250 * time.Millisecond)
@@ -1100,17 +1462,7 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
// newUser["userName"] = user["userName"]
// newUser["alias"] = user["alias"]
// newUser["emailAddress"] = user["emailAddress"]
for k, v := range ombi {
switch v.(type) {
case map[string]interface{}, []interface{}:
user[k] = v
default:
if v != user[k] {
user[k] = v
}
}
}
status, err = app.ombi.ModifyUser(user)
status, err = app.applyOmbiProfile(user, ombi)
if status != 200 || err != nil {
errorString += fmt.Sprintf("Apply %d: %v ", status, err)
}
@@ -1119,6 +1471,26 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
errors["ombi"][id] = errorString
}
}
if jellyseerr.Enabled {
errorString := ""
// newUser := ombi
// newUser["id"] = user["id"]
// newUser["userName"] = user["userName"]
// newUser["alias"] = user["alias"]
// newUser["emailAddress"] = user["emailAddress"]
err := app.js.ApplyTemplateToUser(id, jellyseerr.User)
if err != nil {
errorString += fmt.Sprintf("ApplyUser: %v ", err)
}
err = app.js.ApplyNotificationsTemplateToUser(id, jellyseerr.Notifications)
if err != nil {
errorString += fmt.Sprintf("ApplyNotifications: %v ", err)
}
if errorString != "" {
errors["jellyseerr"][id] = errorString
}
}
if shouldDelay {
time.Sleep(250 * time.Millisecond)
}

23
api.go
View File

@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/hrfee/mediabrowser"
"github.com/itchyny/timefmt-go"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
)
@@ -113,6 +114,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
var req ResetPasswordDTO
gc.BindJSON(&req)
validation := app.validator.validate(req.Password)
captcha := app.config.Section("captcha").Key("enabled").MustBool(false)
valid := true
for _, val := range validation {
if !val {
@@ -120,12 +122,18 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
}
}
if !valid || req.PIN == "" {
// 200 bcs idk what i did in js
app.info.Printf("%s: Password reset failed: Invalid password", req.PIN)
gc.JSON(400, validation)
return
}
isInternal := false
if captcha && !app.verifyCaptcha(req.PIN, req.PIN, req.CaptchaText, true) {
app.info.Printf("%s: PWR Failed: Captcha Incorrect", req.PIN)
respond(400, "errorCaptcha", gc)
return
}
var userID, username string
if reset, ok := app.internalPWRs[req.PIN]; ok {
isInternal = true
@@ -137,6 +145,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
}
userID = reset.ID
username = reset.Username
status, err := app.jf.ResetPasswordAdmin(userID)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Password Reset failed (%d): %v", status, err)
@@ -157,6 +166,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
}
username = resp.UsersReset[0]
}
var user mediabrowser.User
var status int
var err error
@@ -170,6 +180,15 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
respondBool(500, false, gc)
return
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityResetPassword,
UserID: user.ID,
SourceType: ActivityUser,
Source: user.ID,
Time: time.Now(),
}, gc, true)
prevPassword := req.PIN
if isInternal {
prevPassword = ""
@@ -182,7 +201,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
}
if app.config.Section("ombi").Key("enabled").MustBool(false) {
// Silently fail for changing ombi passwords
if status != 200 || err != nil {
if (status != 200 && status != 204) || err != nil {
app.err.Printf("Failed to get user \"%s\" from jellyfin/emby (%d): %v", username, status, err)
respondBool(200, true, gc)
return

View File

@@ -23,6 +23,7 @@ func (app *appContext) loadArgs(firstCall bool) {
HOST = flag.String("host", "", "alternate address to host web ui on.")
PORT = flag.Int("port", 0, "alternate port to host web ui on.")
flag.IntVar(PORT, "p", 0, "SHORTHAND")
_LOADBAK = flag.String("restore", "", "path to database backup to restore.")
DEBUG = flag.Bool("debug", false, "Enables debug logging.")
PPROF = flag.Bool("pprof", false, "Exposes pprof profiler on /debug/pprof.")
SWAGGER = flag.Bool("swagger", false, "Enable swagger at /swagger/index.html")
@@ -41,6 +42,9 @@ func (app *appContext) loadArgs(firstCall bool) {
if *PPROF {
os.Setenv("PPROF", "1")
}
if *_LOADBAK != "" {
LOADBAK = *_LOADBAK
}
}
if os.Getenv("SWAGGER") == "1" {

45
auth.go
View File

@@ -18,6 +18,25 @@ const (
REFRESH_TOKEN_VALIDITY_SEC = 3600 * 24
)
func (app *appContext) logIpInfo(gc *gin.Context, user bool, out string) {
if (user && LOGIPU) || (!user && LOGIP) {
out += fmt.Sprintf(" (ip=%s)", gc.ClientIP())
}
app.info.Println(out)
}
func (app *appContext) logIpDebug(gc *gin.Context, user bool, out string) {
if (user && LOGIPU) || (!user && LOGIP) {
out += fmt.Sprintf(" (ip=%s)", gc.ClientIP())
}
app.debug.Println(out)
}
func (app *appContext) logIpErr(gc *gin.Context, user bool, out string) {
if (user && LOGIPU) || (!user && LOGIP) {
out += fmt.Sprintf(" (ip=%s)", gc.ClientIP())
}
app.err.Println(out)
}
func (app *appContext) webAuth() gin.HandlerFunc {
return app.authenticate
}
@@ -133,7 +152,7 @@ type getTokenDTO struct {
Token string `json:"token" example:"kjsdklsfdkljfsjsdfklsdfkldsfjdfskjsdfjklsdf"` // API token for use with everything else.
}
func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, password string, ok bool) {
func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool) (username, password string, ok bool) {
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
auth, _ := base64.StdEncoding.DecodeString(header[1])
creds := strings.SplitN(string(auth), ":", 2)
@@ -141,7 +160,7 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, pas
password = creds[1]
ok = false
if username == "" || password == "" {
app.debug.Println("Auth denied: blank username/password")
app.logIpDebug(gc, userpage, "Auth denied: blank username/password")
respond(401, "Unauthorized", gc)
return
}
@@ -149,17 +168,17 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, pas
return
}
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context) (user mediabrowser.User, ok bool) {
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context, userpage bool) (user mediabrowser.User, ok bool) {
ok = false
user, status, err := app.authJf.Authenticate(username, password)
if status != 200 || err != nil {
if status == 401 || status == 400 {
app.info.Println("Auth denied: Invalid username/password (Jellyfin)")
app.logIpInfo(gc, userpage, "Auth denied: Invalid username/password (Jellyfin)")
respond(401, "Unauthorized", gc)
return
}
if status == 403 {
app.info.Println("Auth denied: Jellyfin account disabled")
app.logIpInfo(gc, userpage, "Auth denied: Jellyfin account disabled")
respond(403, "yourAccountWasDisabled", gc)
return
}
@@ -180,8 +199,8 @@ func (app *appContext) validateJellyfinCredentials(username, password string, gc
// @tags Auth
// @Security getTokenAuth
func (app *appContext) getTokenLogin(gc *gin.Context) {
app.info.Println("Token requested (login attempt)")
username, password, ok := app.decodeValidateLoginHeader(gc)
app.logIpInfo(gc, false, "Token requested (login attempt)")
username, password, ok := app.decodeValidateLoginHeader(gc, false)
if !ok {
return
}
@@ -196,12 +215,12 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
}
}
if !app.jellyfinLogin && !match {
app.info.Println("Auth denied: Invalid username/password")
app.logIpInfo(gc, false, "Auth denied: Invalid username/password")
respond(401, "Unauthorized", gc)
return
}
if !match {
user, ok := app.validateJellyfinCredentials(username, password, gc)
user, ok := app.validateJellyfinCredentials(username, password, gc, false)
if !ok {
return
}
@@ -233,7 +252,8 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
respond(500, "Couldn't generate token", gc)
return
}
gc.SetCookie("refresh", refresh, (3600 * 24), "/", gc.Request.URL.Hostname(), true, true)
host := gc.Request.URL.Hostname()
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
gc.JSON(200, getTokenDTO{token})
}
@@ -284,7 +304,7 @@ func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName s
// @Router /token/refresh [get]
// @tags Auth
func (app *appContext) getTokenRefresh(gc *gin.Context) {
app.debug.Println("Token requested (refresh token)")
app.logIpInfo(gc, false, "Token requested (refresh token)")
claims, ok := app.decodeValidateRefreshCookie(gc, "refresh")
if !ok {
return
@@ -297,6 +317,7 @@ func (app *appContext) getTokenRefresh(gc *gin.Context) {
respond(500, "Couldn't generate token", gc)
return
}
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", gc.Request.URL.Hostname(), true, true)
host := gc.Request.URL.Hostname()
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
gc.JSON(200, getTokenDTO{jwt})
}

173
backups.go Normal file
View File

@@ -0,0 +1,173 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
const (
BACKUP_PREFIX = "jfa-go-db-"
BACKUP_UPLOAD_PREFIX = "upload-"
BACKUP_DATEFMT = "2006-01-02T15-04-05"
BACKUP_SUFFIX = ".bak"
)
type BackupList struct {
files []os.DirEntry
dates []time.Time
count int
}
func (bl BackupList) Len() int { return len(bl.files) }
func (bl BackupList) Swap(i, j int) {
bl.files[i], bl.files[j] = bl.files[j], bl.files[i]
bl.dates[i], bl.dates[j] = bl.dates[j], bl.dates[i]
}
func (bl BackupList) Less(i, j int) bool {
// Push non-backup files to the end of the array,
// Since they didn't have a date parsed.
if bl.dates[i].IsZero() {
return false
}
if bl.dates[j].IsZero() {
return true
}
// Sort by oldest first
return bl.dates[j].After(bl.dates[i])
}
// Get human-readable file size from f.Size() result.
// https://programming.guide/go/formatting-byte-size-to-human-readable-format.html
func fileSize(l int64) string {
const unit = 1000
if l < unit {
return fmt.Sprintf("%dB", l)
}
div, exp := int64(unit), 0
for n := l / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f%c", float64(l)/float64(div), "KMGTPE"[exp])
}
func (app *appContext) getBackups() *BackupList {
path := app.config.Section("backups").Key("path").String()
err := os.MkdirAll(path, 0755)
if err != nil {
app.err.Printf("Failed to create backup directory \"%s\": %v\n", path, err)
return nil
}
items, err := os.ReadDir(path)
if err != nil {
app.err.Printf("Failed to read backup directory \"%s\": %v\n", path, err)
return nil
}
backups := &BackupList{}
backups.files = items
backups.dates = make([]time.Time, len(items))
backups.count = 0
for i, item := range items {
if item.IsDir() || !(strings.HasSuffix(item.Name(), BACKUP_SUFFIX)) {
continue
}
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(item.Name(), BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX), BACKUP_SUFFIX))
if err != nil {
app.debug.Printf("Failed to parse backup filename \"%s\": %v\n", item.Name(), err)
continue
}
backups.dates[i] = t
backups.count++
}
return backups
}
func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) {
toKeep := app.config.Section("backups").Key("keep_n_backups").MustInt(20)
fname := BACKUP_PREFIX + time.Now().Local().Format(BACKUP_DATEFMT) + BACKUP_SUFFIX
path := app.config.Section("backups").Key("path").String()
backups := app.getBackups()
if backups == nil {
return
}
toDelete := backups.count + 1 - toKeep
// fmt.Printf("toDelete: %d, backCount: %d, keep: %d, length: %d\n", toDelete, backups.count, toKeep, len(backups.files))
if toDelete > 0 && toDelete <= backups.count {
sort.Sort(backups)
for _, item := range backups.files[:toDelete] {
fullpath := filepath.Join(path, item.Name())
app.debug.Printf("Deleting old backup \"%s\"\n", item.Name())
err := os.Remove(fullpath)
if err != nil {
app.err.Printf("Failed to delete old backup \"%s\": %v\n", fullpath, err)
return
}
}
}
fullpath := filepath.Join(path, fname)
f, err := os.Create(fullpath)
if err != nil {
app.err.Printf("Failed to open backup file \"%s\": %v\n", fullpath, err)
return
}
defer f.Close()
_, err = app.storage.db.Badger().Backup(f, 0)
if err != nil {
app.err.Printf("Failed to create backup: %v\n", err)
return
}
fstat, err := f.Stat()
if err != nil {
app.err.Printf("Failed to get info on new backup: %v\n", err)
return
}
fileDetails.Size = fileSize(fstat.Size())
fileDetails.Name = fname
fileDetails.Path = fullpath
// fmt.Printf("Created backup %+v\n", fileDetails)
return
}
func (app *appContext) loadPendingBackup() {
if LOADBAK == "" {
return
}
oldPath := filepath.Join(app.dataPath, "db-"+string(time.Now().Unix())+"-pre-"+filepath.Base(LOADBAK))
app.info.Printf("Moving existing database to \"%s\"\n", oldPath)
err := os.Rename(app.storage.db_path, oldPath)
if err != nil {
app.err.Fatalf("Failed to move existing database: %v\n", err)
}
app.ConnectDB()
defer app.storage.db.Close()
f, err := os.Open(LOADBAK)
if err != nil {
app.err.Fatalf("Failed to open backup file \"%s\": %v\n", LOADBAK, err)
}
err = app.storage.db.Badger().Load(f, 256)
f.Close()
if err != nil {
app.err.Fatalf("Failed to restore backup file \"%s\": %v\n", LOADBAK, err)
}
app.info.Printf("Restored backup \"%s\".", LOADBAK)
LOADBAK = ""
}
func newBackupDaemon(app *appContext) *GenericDaemon {
interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute
d := NewGenericDaemon(interval, app,
func(app *appContext) {
app.debug.Println("Backups: Creating backup")
app.makeBackup()
},
)
return d
}

View File

@@ -8,6 +8,7 @@ import (
"strconv"
"strings"
"github.com/hrfee/jfa-go/easyproxy"
"gopkg.in/ini.v1"
)
@@ -75,6 +76,10 @@ func (app *appContext) loadConfig() error {
app.MustSetValue("smtp", "hello_hostname", "localhost")
app.MustSetValue("smtp", "cert_validation", "true")
app.MustSetValue("smtp", "auth_type", "4")
app.MustSetValue("activity_log", "keep_n_records", "1000")
app.MustSetValue("activity_log", "delete_after_days", "90")
sc := app.config.Section("discord").Key("start_command").MustString("start")
app.config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
@@ -100,6 +105,9 @@ func (app *appContext) loadConfig() error {
app.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html")
app.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt")
app.MustSetValue("user_expiry", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html")
app.MustSetValue("user_expiry", "adjustment_email_text", "jfa-go:"+"expiry-adjusted.txt")
app.MustSetValue("matrix", "topic", "Jellyfin notifications")
app.MustSetValue("matrix", "show_on_reg", "true")
@@ -107,16 +115,37 @@ func (app *appContext) loadConfig() error {
app.MustSetValue("telegram", "show_on_reg", "true")
app.MustSetValue("backups", "every_n_minutes", "1440")
app.MustSetValue("backups", "path", filepath.Join(app.dataPath, "backups"))
app.MustSetValue("backups", "keep_n_backups", "20")
app.config.Section("jellyfin").Key("version").SetValue(version)
app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
LOGIP = app.config.Section("advanced").Key("log_ips").MustBool(false)
LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false)
// These two settings are pretty much the same
url1 := app.config.Section("invite_emails").Key("url_base").String()
url2 := app.config.Section("password_resets").Key("url_base").String()
app.MustSetValue("password_resets", "url_base", strings.TrimSuffix(url1, "/invite"))
app.MustSetValue("invite_emails", "url_base", url2)
pwrMethods := []string{"allow_pwr_username", "allow_pwr_email", "allow_pwr_contact_method"}
allDisabled := true
for _, v := range pwrMethods {
if app.config.Section("user_page").Key(v).MustBool(true) {
allDisabled = false
}
}
if allDisabled {
fmt.Println("SETALLTRUE")
for _, v := range pwrMethods {
app.config.Section("user_page").Key(v).SetValue("true")
}
}
messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false)
telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false)
discordEnabled = app.config.Section("discord").Key("enabled").MustBool(false)
@@ -135,6 +164,22 @@ func (app *appContext) loadConfig() error {
messagesEnabled = false
}
if app.proxyEnabled = app.config.Section("advanced").Key("proxy").MustBool(false); app.proxyEnabled {
app.proxyConfig = easyproxy.ProxyConfig{}
app.proxyConfig.Protocol = easyproxy.HTTP
if strings.Contains(app.config.Section("advanced").Key("proxy_protocol").MustString("http"), "socks") {
app.proxyConfig.Protocol = easyproxy.SOCKS5
}
app.proxyConfig.Addr = app.config.Section("advanced").Key("proxy_address").MustString("")
app.proxyConfig.User = app.config.Section("advanced").Key("proxy_user").MustString("")
app.proxyConfig.Password = app.config.Section("advanced").Key("proxy_password").MustString("")
app.proxyTransport, err = easyproxy.NewTransport(app.proxyConfig)
if err != nil {
app.err.Printf("Failed to initialize Proxy: %v\n", err)
}
app.proxyEnabled = true
}
app.MustSetValue("updates", "enabled", "true")
releaseChannel := app.config.Section("updates").Key("channel").String()
if app.config.Section("updates").Key("enabled").MustBool(false) {
@@ -147,6 +192,9 @@ func (app *appContext) loadConfig() error {
v = "git"
}
app.updater = newUpdater(baseURL, namespace, repo, v, commit, updater)
if app.proxyEnabled {
app.updater.SetTransport(app.proxyTransport)
}
}
if releaseChannel == "" {
if version == "git" {

View File

@@ -173,22 +173,22 @@
"description": "Enable this to use Jellyfin users instead of the below username and pw."
},
"admin_only": {
"name": "Allow admin users only",
"name": "Allow admin users only on \"Admin\" pages",
"required": false,
"requires_restart": true,
"depends_true": "jellyfin_login",
"type": "bool",
"value": true,
"description": "Allows only admin users on Jellyfin to access the admin page."
"description": "Allows only admin users on Jellyfin to access the admin page. Doesn't apply to the \"My Accounts\" page."
},
"allow_all": {
"name": "Allow all users to login",
"name": "Allow all users to login to \"Admin\" pages",
"required": false,
"requires_restart": true,
"depends_true": "jellyfin_login",
"type": "bool",
"value": false,
"description": "Allow all Jellyfin users to access jfa-go. Not recommended, add individual users in the Accounts tab instead."
"description": "Allow all Jellyfin users to access jfa-go. Not recommended, add individual users in the Accounts tab instead. Doesn't apply to the \"My Accounts\" page."
},
"username": {
"name": "Web Username",
@@ -247,7 +247,7 @@
"requires_restart": false,
"type": "text",
"value": "Your account has been created. Click below to continue to Jellyfin.",
"description": "Displayed when a user creates an account"
"description": "Displayed when a user creates an account. Use the \"post-signup card\" in the Message editor for more control."
},
"url_base": {
"name": "Reverse Proxy subfolder",
@@ -273,7 +273,19 @@
"type": "bool",
"value": false,
"advanced": true,
"description": "Navigate directly to the above URL instead of needing the user to click \"Continue\"."
"description": "Navigate directly to the above URL instead of needing the user to click \"Continue\". Overrides the post-signup card."
},
"login_appearance": {
"name": "Login screen appearance",
"required": false,
"requires_restart": false,
"type": "select",
"options": [
["clear", "Transparent"],
["opaque", "Opaque"]
],
"value": "clear",
"description": "Appearance of the Admin login screen."
}
}
},
@@ -281,9 +293,33 @@
"order": [],
"meta": {
"name": "Advanced",
"description": "Advanced settings."
"description": "Advanced settings.",
"advanced": true
},
"settings": {
"log_ips": {
"name": "Log IPs accessing Admin Page",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false,
"description": "Log IP addresses of admins and admin page requests in console and in activities. See notice below on legality."
},
"log_ips_users": {
"name": "Log IPs accessing User Page",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false,
"description": "Log IP addresses of users in console and in activities. See notice below on legality."
},
"ip_note": {
"name": "Logging IPs:",
"type": "note",
"value": "",
"required": "false",
"description": "Logging IP addresses through jfa-go may violate GDPR or other privacy regulations, as IPs are linked to account information. Enable at your own risk."
},
"tls": {
"name": "TLS/HTTP2",
"required": false,
@@ -318,6 +354,212 @@
"type": "text",
"value": "",
"description": "Path to .key file. See jfa-go wiki for more info."
},
"auth_retry_count": {
"name": "Initial auth retry count",
"required": false,
"requires_restart": true,
"type": "number",
"value": 6,
"description": "Number of times to retry initial connection to Jellyfin before failing."
},
"auth_retry_gap": {
"name": "Initial auth retry gap (seconds)",
"required": false,
"requires_restart": true,
"type": "number",
"value": 10,
"description": "Duration in seconds to wait between connection retries."
},
"proxy": {
"name": "Use Proxy",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false,
"description": "Whether or not to use a HTTP/SOCKS5 Proxy."
},
"proxy_protocol": {
"name": "Proxy Protocol",
"depends_true": "proxy",
"required": false,
"requires_restart": true,
"type": "select",
"options": [
["http", "HTTP"],
["socks", "SOCKS5"]
],
"value": "http",
"description": "Protocol to use for proxy connection."
},
"proxy_address": {
"name": "Proxy Address",
"depends_true": "proxy",
"required": false,
"requires_restart": true,
"type": "text",
"value": "",
"description": "Proxy address, including port."
},
"proxy_user": {
"name": "Proxy Username",
"depends_true": "proxy",
"required": false,
"requires_restart": true,
"type": "text",
"value": "",
"description": "Leave blank for no Authentication."
},
"proxy_password": {
"name": "Proxy Password",
"depends_true": "proxy",
"required": false,
"requires_restart": true,
"type": "password",
"value": "",
"description": "Leave blank for no Authentication."
},
"debug_log_emails": {
"name": "Debug Storage Logging: Emails",
"required": false,
"requires_restart": true,
"type": "select",
"options": [
["none", "None"],
["all", "All Writes"],
["deletion", "Deletion Only*"]
],
"value": "none",
"description": "Extra debug logging for writes to the database. *: Deletion also includes blanking out major fields, e.g. an email address."
},
"debug_log_discord": {
"name": "Debug Storage Logging: Discord",
"required": false,
"requires_restart": true,
"type": "select",
"options": [
["none", "None"],
["all", "All Writes"],
["deletion", "Deletion Only*"]
],
"value": "none",
"description": "Extra debug logging for writes to the database. *: Deletion also includes blanking out major fields, e.g. an email address."
},
"debug_log_telegram": {
"name": "Debug Storage Logging: Telegram",
"required": false,
"requires_restart": true,
"type": "select",
"options": [
["none", "None"],
["all", "All Writes"],
["deletion", "Deletion Only*"]
],
"value": "none",
"description": "Extra debug logging for writes to the database. *: Deletion also includes blanking out major fields, e.g. an email address."
},
"debug_log_matrix": {
"name": "Debug Storage Logging: Matrix",
"required": false,
"requires_restart": true,
"type": "select",
"options": [
["none", "None"],
["all", "All Writes"],
["deletion", "Deletion Only*"]
],
"value": "none",
"description": "Extra debug logging for writes to the database. *: Deletion also includes blanking out major fields, e.g. an email address."
},
"debug_log_invites": {
"name": "Debug Storage Logging: Invites",
"required": false,
"requires_restart": true,
"type": "select",
"options": [
["none", "None"],
["all", "All Writes"],
["deletion", "Deletion Only*"]
],
"value": "none",
"description": "Extra debug logging for writes to the database. *: Deletion also includes blanking out major fields, e.g. an email address."
},
"debug_log_announcements": {
"name": "Debug Storage Logging: Announcements",
"required": false,
"requires_restart": true,
"type": "select",
"options": [
["none", "None"],
["all", "All Writes"],
["deletion", "Deletion Only*"]
],
"value": "none",
"description": "Extra debug logging for writes to the database. *: Deletion also includes blanking out major fields, e.g. an email address."
},
"debug_log_expiries": {
"name": "Debug Storage Logging: User Expiries",
"required": false,
"requires_restart": true,
"type": "select",
"options": [
["none", "None"],
["all", "All Writes"],
["deletion", "Deletion Only*"]
],
"value": "none",
"description": "Extra debug logging for writes to the database. *: Deletion also includes blanking out major fields, e.g. an email address."
},
"debug_log_profiles": {
"name": "Debug Storage Logging: Profiles",
"required": false,
"requires_restart": true,
"type": "select",
"options": [
["none", "None"],
["all", "All Writes"],
["deletion", "Deletion Only*"]
],
"value": "none",
"description": "Extra debug logging for writes to the database. *: Deletion also includes blanking out major fields, e.g. an email address."
},
"debug_log_custom_content": {
"name": "Debug Storage Logging: Custom Message Content",
"required": false,
"requires_restart": true,
"type": "select",
"options": [
["none", "None"],
["all", "All Writes"],
["deletion", "Deletion Only*"]
],
"value": "none",
"description": "Extra debug logging for writes to the database. *: Deletion also includes blanking out major fields, e.g. an email address."
}
}
},
"activity_log": {
"order": [],
"meta": {
"name": "Activity Log",
"description": "Settings for data retention of the activity log."
},
"settings": {
"keep_n_records": {
"name": "Number of records to keep",
"required": false,
"requires_restart": true,
"type": "number",
"value": 1000,
"description": "How many of the most recent activities to keep. Set to 0 to disable."
},
"delete_after_days": {
"name": "Delete activities older than (days):",
"required": false,
"requires_restart": true,
"type": "number",
"value": 90,
"description": "If an activity was created this many days ago, it will be deleted. Set to 0 to disable."
}
}
},
@@ -341,6 +583,7 @@
"required": false,
"requires_restart": true,
"type": "bool",
"depends_true": "enabled",
"value": false,
"description": "More reliable, but requires some setup. See jfa-go wiki for more info."
},
@@ -376,15 +619,15 @@
"user_page": {
"order": [],
"meta": {
"name": "User Page",
"description": "The User Page (My Account) allows users to access and modify info directly, such as changing/adding contact methods, seeing their expiry date, or changing their password. Password resets can also be initiated from here, given a contact method or username. ",
"name": "User Page/\"My Account\"",
"description": "The User Page (My Account) allows users to access and modify info directly, such as changing/adding contact methods, seeing their expiry date, sending referrals or changing their password. Password resets can also be initiated from here, given a contact method or username. Access control settings set in \"General\" do not apply to this page, nor does \"Access jfa-go\" in the Accounts tab.",
"depends_true": "ui|jellyfin_login"
},
"settings": {
"enabled": {
"name": "Enabled",
"required": false,
"requires_restart": false,
"requires_restart": true,
"type": "bool",
"value": true
},
@@ -404,6 +647,67 @@
"depends_true": "enabled",
"required": "false",
"description": "Click the edit icon next to the \"User Page\" Setting to add custom Markdown messages that will be shown to the user. Note message cards are not private, little effort is required for anyone to view them."
},
"show_link": {
"name": "Show Link on Admin Login page",
"required": false,
"requires_restart": false,
"depends_true": "enabled",
"type": "bool",
"value": true,
"description": "Whether or not to show a link to the \"My Account\" page on the admin login screen, to direct lost users."
},
"referrals": {
"name": "User Referrals",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "bool",
"value": true,
"description": "Users are given their own \"invite\" to send to others."
},
"referrals_note": {
"name": "Using Referrals:",
"type": "note",
"value": "",
"depends_true": "referrals",
"required": "false",
"description": "Create an invite with your desired settings, then either assign it to a user in the accounts tab, or to a profile in settings."
},
"allow_pwr_username": {
"name": "Allow PWR with username",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "bool",
"value": true,
"description": "Allow users to start a Password Reset by inputting their username."
},
"allow_pwr_email": {
"name": "Allow PWR with email address",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "bool",
"value": true,
"description": "Allow users to start a Password Reset by inputting their email address."
},
"allow_pwr_contact_method": {
"name": "Allow PWR with Discord/Telegram/Matrix",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "bool",
"value": true,
"description": "Allow users to start a Password Reset by inputting their Discord/Telegram/Matrix username/id."
},
"pwr_note": {
"name": "PWR Methods:",
"type": "note",
"depends_true": "enabled",
"value": "",
"required": "false",
"description": "Select at least one PWR initiation method. If none are selected, all will be enabled."
}
}
},
@@ -699,6 +1003,22 @@
"type": "bool",
"value": true,
"description": "Warning, disabling this makes you much more vulnerable to man-in-the-middle attacks"
},
"auth_type": {
"name": "Authentication type",
"required": false,
"requires_restart": false,
"advanced": false,
"type": "select",
"options": [
["0", "Plain"],
["1", "Login"],
["2", "CRAM-MD5"],
["3", "None"],
["4", "Auto"]
],
"value": 4,
"description": "SMTP authentication method"
}
}
},
@@ -995,6 +1315,14 @@
"value": true,
"description": "Enable to store provided email addresses, monitor Jellyfin directory for pw-resets, and send reset pins"
},
"pwr_note": {
"name": "Setup:",
"type": "note",
"value": "",
"depends_true": "enabled",
"required": "false",
"description": "There are multiple ways password resets can be set up. See the <a href=\"https://wiki.jfa-go.com/docs/pwr/\" target=\"_blank\">wiki page</a> for more information."
},
"watch_directory": {
"name": "Jellyfin directory",
"required": false,
@@ -1252,6 +1580,108 @@
}
}
},
"jellyseerr": {
"order": [],
"meta": {
"name": "Jellyseerr Integration",
"description": "Connect to Jellyseerr to automatically trigger the import of users on account creation, and to automatically link contact methods (email, discord and telegram). A template must be added to a User Profile for accounts to be created."
},
"settings": {
"enabled": {
"name": "Enabled",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false,
"description": "Enable the Jellyseerr integration."
},
"usertype_note": {
"name": "Password Changes:",
"type": "note",
"value": "",
"depends_true": "enabled",
"required": "false",
"description": "Ensure existing users on Jellyseerr are \"Jellyfin User\"s not \"Local User\"s, as password changes are not synced with Jellyseerr."
},
"server": {
"name": "URL",
"required": false,
"requires_restart": true,
"type": "text",
"value": "localhost:5000",
"depends_true": "enabled",
"description": "Jellyseerr server URL."
},
"api_key": {
"name": "API Key",
"required": false,
"requires_restart": true,
"type": "text",
"value": "",
"depends_true": "enabled",
"description": "API Key. Get this from the first tab in Jellyseerr's settings."
},
"import_existing": {
"name": "Import existing users to Jellyseerr",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false,
"depends_true": "enabled",
"description": "Existing users (and those created outside jfa-go) will have their contact info imported to Jellyseerr."
},
"constraints_note": {
"name": "Unique Emails:",
"type": "note",
"value": "",
"depends_true": "import_existing",
"required": "false",
"description": "Jellyseerr requires email addresses to be unique. If this is not the case, you may see errors in jfa-go's logs. You can require unique addresses in Settings > Email."
}
}
},
"backups": {
"order": [],
"meta": {
"name": "Backups",
"description": "Settings for database backups. Press the \"Backups\" button above to create, download and restore backups."
},
"settings": {
"enabled": {
"name": "Scheduled Backups",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false,
"description": "Enable to generate database backups on a schedule."
},
"path": {
"name": "Backup Path",
"required": false,
"requires_restart": true,
"type": "text",
"value": "",
"description": "Path to directory to store backups in. defaults to <data_directory>/backups."
},
"every_n_minutes": {
"name": "Backup frequency (Minutes)",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "number",
"value": 1440,
"description": "Backup after this many minutes has passed since the last. Resets every restart."
},
"keep_n_backups": {
"name": "Number of backups to keep",
"required": false,
"requires_restart": true,
"type": "number",
"value": 20,
"description": "Number of most recent backups to keep. Once this is hit, the oldest backup will be deleted before doing a new one."
}
}
},
"welcome_email": {
"order": [],
"meta": {
@@ -1343,7 +1773,7 @@
"order": [],
"meta": {
"name": "User Expiry",
"description": "When set on an invite, users will be deleted or disabled a specified amount of time after they create their account."
"description": "When set on an invite, users will be deleted or disabled a specified amount of time after they create their account. Expiries can also be set and extended for invididual users, optionally with a message why."
},
"settings": {
"behaviour": {
@@ -1395,6 +1825,35 @@
"type": "text",
"value": "",
"description": "Path to custom email in plain text"
},
"adjustment_subject": {
"name": "Adjustment: email subject",
"required": false,
"requires_restart": false,
"depends_true": "messages|enabled",
"type": "text",
"value": "",
"description": "Subject of adjustment emails, sent optionally when setting/extending an expiry."
},
"adjustment_email_html": {
"name": "Adjustment: Custom email (HTML)",
"required": false,
"requires_restart": false,
"advanced": true,
"depends_true": "messages|enabled",
"type": "text",
"value": "",
"description": "Path to custom email html"
},
"adjustment_email_text": {
"name": "Adjustment: Custom email (plaintext)",
"required": false,
"requires_restart": false,
"advanced": true,
"depends_true": "messages|enabled",
"type": "text",
"value": "",
"description": "Path to custom email in plain text"
}
}
},

View File

@@ -15,6 +15,9 @@
--border-width-4: 5px;
--border-width-8: 8px;
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
--bg-light: #fff;
--bg-dark: #101010;
}
.light {
@@ -26,11 +29,11 @@
}
.dark body {
background-color: #101010;
background-color: var(--bg-dark);
}
html:not(.dark) body {
background-color: #fff;
background-color: var(--bg-light);
}
.dark select, .dark option, .dark input {
@@ -103,48 +106,6 @@ div.card:contains(section.banner.footer) {
padding-bottom: 0px;
}
.tab-button {
font-size: 2rem;
}
.al {
text-align: left;
}
.ar {
text-align: right;
}
.ac {
text-align: center;
}
.w-100 {
width: 100%;
}
.h-100 {
height: 100%;
}
.inline-block {
display: inline-block;
}
.align-top {
align-items: top;
}
.flex-expand {
display: flex;
justify-content: space-between;
}
.flex-row-group {
display: block;
flex-grow: 1;
}
.row {
display: flex;
flex-wrap: wrap;
@@ -169,23 +130,7 @@ span.sm:not(.heading) {
margin: .25rem;
}
.flex-col {
display: flex;
flex-direction: column;
}
.flex-form {
display: flex;
flex-direction: column;
}
@media screen and (min-width: 768px) {
.flex-form {
flex: 1;
margin: 0.5rem;
}
}
/* Who knows for half of these to be honest */
@media screen and (max-width: 400px) {
.row {
flex-direction: column;
@@ -216,69 +161,6 @@ sup.\~critical, .text-critical {
font-size: 1rem;
}
.inv-created-users strong,p {
padding-left: 0.5rem;
padding-bottom: 0.2rem;
}
.inv-created-users.empty strong,p {
padding: 0;
}
.inv {
overflow: visible;
}
.inv-table {
font-size: 0.8rem;
}
.inv-profilearea {
min-width: 20%;
}
.inv-profileselect {
min-width: 100%;
}
.inv-codearea {
max-width: 40%;
min-width: 10rem;
display: flex;
justify-content: start;
align-items: center;
}
.inv-empty .inv-codearea {
justify-content: start;
}
.invite-link {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: auto;
}
.ellipsis {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.no-pad {
padding: 0px 0px 0px 0px;
}
.elem-pad > * {
margin: var(--spacing-4, 1rem);
}
.icon.clickable {
padding: 0.5rem 0.6rem;
}
.input {
box-sizing: border-box; /* fixes weird length issue with inputs */
}
@@ -297,10 +179,6 @@ sup.\~critical, .text-critical {
width: 100%;
}
.flex-auto {
flex: auto;
}
.center {
justify-content: center;
}
@@ -309,14 +187,6 @@ sup.\~critical, .text-critical {
align-items: center;
}
.no-lp {
padding-left: 0px;
}
.block {
display: block;
}
.focused {
display: block;
}
@@ -413,7 +283,16 @@ table {
color: var(--color-content);
}
table.table.manual-pad th, table.table.manual-pad td {
padding: 0;
}
table.table-p-0 th, table.table-p-0 td {
padding-left: 0 !important;
padding-right: 0 !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
p.top {
margin-top: 0px;
@@ -572,7 +451,6 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
cursor: pointer;
}
.g-recaptcha {
overflow: hidden;
width: 296px;
@@ -584,3 +462,8 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
.g-recaptcha iframe {
margin: -2px 0px 0px -4px;
}
.dropdown-manual-toggle {
margin-bottom: -0.5rem;
padding-bottom: 0.5rem;
}

View File

@@ -22,7 +22,7 @@
font-family: 'Hanken Grotesk';
font-style: italic;
font-weight: 500;
src: url('../fonts/hanken-grotesk-v8-cyrillic-ext_latin_vietnamese-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ *
src: url('../fonts/hanken-grotesk-v8-cyrillic-ext_latin_vietnamese-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* hanken-grotesk-700 - cyrillic-ext_latin_vietnamese */

View File

@@ -3,6 +3,10 @@
color: rgba(0, 0, 0, 0) !important;
}
.loader.rel {
position: relative;
}
.loader .dot {
--diameter: 0.5rem;
--radius: calc(var(--diameter) / 2);
@@ -15,6 +19,12 @@
left: calc(50% - var(--radius));
animation: osc 1s cubic-bezier(.72,.16,.31,.97) infinite;
}
.loader.rel .dot {
position: absolute;
top: 50%;
}
.loader.loader-sm .dot {
--deviation: 10%;
}

View File

@@ -10,6 +10,24 @@
background-color: rgba(0,0,0,40%);
}
.wall {
position: fixed;
z-index: 11;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: auto;
}
html.dark .wall {
background-color: var(--bg-dark);
}
html:not(.dark) .wall {
background-color: var(--bg-light);
}
.modal-close {
float: right;
color: #aaa;

View File

@@ -5,6 +5,7 @@
.tooltip .content {
visibility: hidden;
opacity: 0;
max-width: 10rem;
min-width: 6rem;
background-color: rgba(0, 0, 0, 0.6);
@@ -13,12 +14,23 @@
border-radius: 6px;
overflow-wrap: break-word;
text-align: center;
transition: opacity 100ms;
position: absolute;
z-index: 1;
top: -1rem;
}
.tooltip.below .content {
top: 2.5rem;
left: 0;
right: 0;
}
.tooltip.darker .content {
background-color: rgba(0, 0, 0, 0.8);
}
.tooltip.right .content {
left: 120%;
}
@@ -31,6 +43,10 @@
font-size: 0.8rem;
}
.tooltip:hover .content {
.tooltip:hover .content,
.tooltip:focus .content,
.tooltip:focus-within .content
{
visibility: visible;
opacity: 1;
}

158
daemon.go
View File

@@ -1,6 +1,12 @@
package main
import "time"
import (
"time"
"github.com/dgraph-io/badger/v3"
"github.com/hrfee/mediabrowser"
"github.com/timshannon/badgerhold/v4"
)
// clearEmails removes stored emails for users which no longer exist.
// meant to be called with other such housekeeping functions, so assumes
@@ -9,11 +15,14 @@ func (app *appContext) clearEmails() {
app.debug.Println("Housekeeping: removing unused email addresses")
emails := app.storage.GetEmails()
for _, email := range emails {
_, status, err := app.jf.UserByID(email.JellyfinID, false)
if status == 200 && err != nil {
_, _, err := app.jf.UserByID(email.JellyfinID, false)
// Make sure the user doesn't exist, and no other error has occured
switch err.(type) {
case mediabrowser.ErrUserNotFound:
app.storage.DeleteEmailsKey(email.JellyfinID)
default:
continue
}
app.storage.DeleteEmailsKey(email.JellyfinID)
}
}
@@ -22,11 +31,14 @@ func (app *appContext) clearDiscord() {
app.debug.Println("Housekeeping: removing unused Discord IDs")
discordUsers := app.storage.GetDiscord()
for _, discordUser := range discordUsers {
_, status, err := app.jf.UserByID(discordUser.JellyfinID, false)
if status == 200 && err != nil {
_, _, err := app.jf.UserByID(discordUser.JellyfinID, false)
// Make sure the user doesn't exist, and no other error has occured
switch err.(type) {
case mediabrowser.ErrUserNotFound:
app.storage.DeleteDiscordKey(discordUser.JellyfinID)
default:
continue
}
app.storage.DeleteDiscordKey(discordUser.JellyfinID)
}
}
@@ -35,11 +47,14 @@ func (app *appContext) clearMatrix() {
app.debug.Println("Housekeeping: removing unused Matrix IDs")
matrixUsers := app.storage.GetMatrix()
for _, matrixUser := range matrixUsers {
_, status, err := app.jf.UserByID(matrixUser.JellyfinID, false)
if status == 200 && err != nil {
_, _, err := app.jf.UserByID(matrixUser.JellyfinID, false)
// Make sure the user doesn't exist, and no other error has occured
switch err.(type) {
case mediabrowser.ErrUserNotFound:
app.storage.DeleteMatrixKey(matrixUser.JellyfinID)
default:
continue
}
app.storage.DeleteMatrixKey(matrixUser.JellyfinID)
}
}
@@ -48,88 +63,95 @@ func (app *appContext) clearTelegram() {
app.debug.Println("Housekeeping: removing unused Telegram IDs")
telegramUsers := app.storage.GetTelegram()
for _, telegramUser := range telegramUsers {
_, status, err := app.jf.UserByID(telegramUser.JellyfinID, false)
if status == 200 && err != nil {
_, _, err := app.jf.UserByID(telegramUser.JellyfinID, false)
// Make sure the user doesn't exist, and no other error has occured
switch err.(type) {
case mediabrowser.ErrUserNotFound:
app.storage.DeleteTelegramKey(telegramUser.JellyfinID)
default:
continue
}
app.storage.DeleteTelegramKey(telegramUser.JellyfinID)
}
}
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
type housekeepingDaemon struct {
Stopped bool
ShutdownChannel chan string
Interval time.Duration
period time.Duration
jobs []func(app *appContext)
app *appContext
func (app *appContext) clearPWRCaptchas() {
app.debug.Println("Housekeeping: Clearing old PWR Captchas")
captchas := map[string]Captcha{}
for k, capt := range app.pwrCaptchas {
if capt.Generated.Add(CAPTCHA_VALIDITY * time.Second).After(time.Now()) {
captchas[k] = capt
}
}
app.pwrCaptchas = captchas
}
func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemon {
daemon := housekeepingDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
func (app *appContext) clearActivities() {
app.debug.Println("Housekeeping: Cleaning up Activity log...")
keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000)
maxAgeDays := app.config.Section("activity_log").Key("delete_after_days").MustInt(90)
minAge := time.Now().AddDate(0, 0, -maxAgeDays)
err := error(nil)
errorSource := 0
if maxAgeDays != 0 {
err = app.storage.db.DeleteMatching(&Activity{}, badgerhold.Where("Time").Lt(minAge))
}
daemon.jobs = []func(app *appContext){func(app *appContext) {
app.debug.Println("Housekeeping: Checking for expired invites")
app.checkInvites()
}}
if err == nil && keepCount != 0 {
// app.debug.Printf("Keeping %d records", keepCount)
err = app.storage.db.DeleteMatching(&Activity{}, (&badgerhold.Query{}).Reverse().SortBy("Time").Skip(keepCount))
if err != nil {
errorSource = 1
}
}
if err == badger.ErrTxnTooBig {
app.debug.Printf("Activities: Delete txn was too big, doing it manually.")
list := []Activity{}
if errorSource == 0 {
app.storage.db.Find(&list, badgerhold.Where("Time").Lt(minAge))
} else {
app.storage.db.Find(&list, (&badgerhold.Query{}).Reverse().SortBy("Time").Skip(keepCount))
}
for _, record := range list {
app.storage.DeleteActivityKey(record.ID)
}
}
}
func newHousekeepingDaemon(interval time.Duration, app *appContext) *GenericDaemon {
d := NewGenericDaemon(interval, app,
func(app *appContext) {
app.debug.Println("Housekeeping: Checking for expired invites")
app.checkInvites()
},
func(app *appContext) { app.clearActivities() },
)
d.Name("Housekeeping daemon")
clearEmail := app.config.Section("email").Key("require_unique").MustBool(false)
clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false)
clearTelegram := app.config.Section("telegram").Key("require_unique").MustBool(false)
clearMatrix := app.config.Section("matrix").Key("require_unique").MustBool(false)
clearPWR := app.config.Section("captcha").Key("enabled").MustBool(false) && !app.config.Section("captcha").Key("recaptcha").MustBool(false)
if clearEmail || clearDiscord || clearTelegram || clearMatrix {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.jf.CacheExpiry = time.Now() })
d.appendJobs(func(app *appContext) { app.jf.CacheExpiry = time.Now() })
}
if clearEmail {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearEmails() })
d.appendJobs(func(app *appContext) { app.clearEmails() })
}
if clearDiscord {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearDiscord() })
d.appendJobs(func(app *appContext) { app.clearDiscord() })
}
if clearTelegram {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearTelegram() })
d.appendJobs(func(app *appContext) { app.clearTelegram() })
}
if clearMatrix {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearMatrix() })
d.appendJobs(func(app *appContext) { app.clearMatrix() })
}
if clearPWR {
d.appendJobs(func(app *appContext) { app.clearPWRCaptchas() })
}
return &daemon
}
func (rt *housekeepingDaemon) run() {
rt.app.info.Println("Invite daemon started")
for {
select {
case <-rt.ShutdownChannel:
rt.ShutdownChannel <- "Down"
return
case <-time.After(rt.period):
break
}
started := time.Now()
for _, job := range rt.jobs {
job(rt.app)
}
finished := time.Now()
duration := finished.Sub(started)
rt.period = rt.Interval - duration
}
}
func (rt *housekeepingDaemon) Shutdown() {
rt.Stopped = true
rt.ShutdownChannel <- "Down"
<-rt.ShutdownChannel
close(rt.ShutdownChannel)
return d
}

View File

@@ -24,6 +24,7 @@ type DiscordDaemon struct {
app *appContext
commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string)
commandIDs []string
commandDescriptions []*dg.ApplicationCommand
}
func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
@@ -50,6 +51,7 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
dd.commandHandlers[app.config.Section("discord").Key("start_command").MustString("start")] = dd.cmdStart
dd.commandHandlers["lang"] = dd.cmdLang
dd.commandHandlers["pin"] = dd.cmdPIN
dd.commandHandlers["inv"] = dd.cmdInvite
for _, user := range app.storage.GetDiscord() {
dd.users[user.ID] = user
}
@@ -127,6 +129,7 @@ func (d *DiscordDaemon) run() {
d.inviteChannelName = invChannel
}
}
err = d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start"))
defer d.deregisterCommands()
defer d.bot.Close()
@@ -220,9 +223,7 @@ func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconU
d.app.err.Printf("Discord: Failed to get guild: %v", err)
return
}
// FIXME: Fix CSS, and handle no icon
iconURL = guild.IconURL("256")
fmt.Println("GOT ICON", iconURL)
return
}
@@ -309,7 +310,7 @@ func (d *DiscordDaemon) Shutdown() {
}
func (d *DiscordDaemon) registerCommands() {
commands := []*dg.ApplicationCommand{
d.commandDescriptions = []*dg.ApplicationCommand{
{
Name: d.app.config.Section("discord").Key("start_command").MustString("start"),
Description: "Start the Discord linking process. The bot will send further instructions.",
@@ -339,26 +340,73 @@ func (d *DiscordDaemon) registerCommands() {
},
},
},
{
Name: "inv",
Description: "Send an invite to a discord user (admin only).",
Options: []*dg.ApplicationCommandOption{
{
Type: dg.ApplicationCommandOptionUser,
Name: "user",
Description: "User to Invite.",
Required: true,
},
{
Type: dg.ApplicationCommandOptionInteger,
Name: "expiry",
Description: "Time in minutes before expiration.",
Required: false,
},
/* Label should be automatically set to something like "Discord invite for @username"
{
Type: dg.ApplicationCommandOptionString,
Name: "label",
Description: "Label given to this invite (shown on the Admin page)",
Required: false,
}, */
{
Type: dg.ApplicationCommandOptionString,
Name: "user_label",
Description: "Label given to users created with this invite.",
Required: false,
},
{
Type: dg.ApplicationCommandOptionString,
Name: "profile",
Description: "Profile to apply to the created user.",
Required: false,
},
},
},
}
commands[1].Options[0].Choices = make([]*dg.ApplicationCommandOptionChoice, len(d.app.storage.lang.Telegram))
d.commandDescriptions[1].Options[0].Choices = make([]*dg.ApplicationCommandOptionChoice, len(d.app.storage.lang.Telegram))
i := 0
for code := range d.app.storage.lang.Telegram {
d.app.debug.Printf("Registering choice \"%s\":\"%s\"\n", d.app.storage.lang.Telegram[code].Meta.Name, code)
commands[1].Options[0].Choices[i] = &dg.ApplicationCommandOptionChoice{
d.app.debug.Printf("Discord: registering lang choice \"%s\":\"%s\"\n", d.app.storage.lang.Telegram[code].Meta.Name, code)
d.commandDescriptions[1].Options[0].Choices[i] = &dg.ApplicationCommandOptionChoice{
Name: d.app.storage.lang.Telegram[code].Meta.Name,
Value: code,
}
i++
}
profiles := d.app.storage.GetProfiles()
d.commandDescriptions[3].Options[3].Choices = make([]*dg.ApplicationCommandOptionChoice, len(profiles))
for i, profile := range profiles {
d.app.debug.Printf("Discord: registering profile choice \"%s\"", profile.Name)
d.commandDescriptions[3].Options[3].Choices[i] = &dg.ApplicationCommandOptionChoice{
Name: profile.Name,
Value: profile.Name,
}
}
// d.deregisterCommands()
d.commandIDs = make([]string, len(commands))
d.commandIDs = make([]string, len(d.commandDescriptions))
// cCommands, err := d.bot.ApplicationCommandBulkOverwrite(d.bot.State.User.ID, d.guildID, commands)
// if err != nil {
// d.app.err.Printf("Discord: Cannot create commands: %v", err)
// }
for i, cmd := range commands {
for i, cmd := range d.commandDescriptions {
command, err := d.bot.ApplicationCommandCreate(d.bot.State.User.ID, d.guildID, cmd)
if err != nil {
d.app.err.Printf("Discord: Cannot create command \"%s\": %v", cmd.Name, err)
@@ -376,12 +424,32 @@ func (d *DiscordDaemon) deregisterCommands() {
return
}
for _, cmd := range existingCommands {
if err := d.bot.ApplicationCommandDelete(d.bot.State.User.ID, "", cmd.ID); err != nil {
d.app.err.Printf("Failed to deregister command: %v", err)
if err := d.bot.ApplicationCommandDelete(d.bot.State.User.ID, d.guildID, cmd.ID); err != nil {
d.app.err.Printf("Discord: Failed to deregister command: %v", err)
}
}
}
// UpdateCommands updates commands which have defined lists of options, to be used when changes occur.
func (d *DiscordDaemon) UpdateCommands() {
// Reload Profile List
profiles := d.app.storage.GetProfiles()
d.commandDescriptions[3].Options[3].Choices = make([]*dg.ApplicationCommandOptionChoice, len(profiles))
for i, profile := range profiles {
d.app.debug.Printf("Discord: registering profile choice \"%s\"", profile.Name)
d.commandDescriptions[3].Options[3].Choices[i] = &dg.ApplicationCommandOptionChoice{
Name: profile.Name,
Value: profile.Name,
}
}
cmd, err := d.bot.ApplicationCommandEdit(d.bot.State.User.ID, d.guildID, d.commandIDs[3], d.commandDescriptions[3])
if err != nil {
d.app.err.Printf("Discord: Failed to update profile list: %v\n", err)
} else {
d.commandIDs[3] = cmd.ID
}
}
func (d *DiscordDaemon) commandHandler(s *dg.Session, i *dg.InteractionCreate) {
if h, ok := d.commandHandlers[i.ApplicationCommandData().Name]; ok {
if i.GuildID != "" && d.channelName != "" {
@@ -504,6 +572,124 @@ func (d *DiscordDaemon) cmdLang(s *dg.Session, i *dg.InteractionCreate, lang str
}
}
func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang string) {
channel, err := s.UserChannelCreate(i.Interaction.Member.User.ID)
if err != nil {
d.app.err.Printf("Discord: Failed to create private channel with \"%s\": %v", i.Interaction.Member.User.Username, err)
return
}
requester := d.MustGetUser(channel.ID, i.Interaction.Member.User.ID, i.Interaction.Member.User.Discriminator, i.Interaction.Member.User.Username)
d.users[i.Interaction.Member.User.ID] = requester
recipient := i.ApplicationCommandData().Options[0].UserValue(s)
// d.app.debug.Println(invuser)
//label := i.ApplicationCommandData().Options[2].StringValue()
//profile := i.ApplicationCommandData().Options[3].StringValue()
//mins, err := strconv.Atoi(i.ApplicationCommandData().Options[1].StringValue())
//if mins > 0 {
// expmin = mins
//}
// Check whether requestor is linked to the admin account
requesterEmail, ok := d.app.storage.GetEmailsKey(requester.JellyfinID)
if !ok {
d.app.err.Printf("Failed to verify admin")
}
if !requesterEmail.Admin {
d.app.err.Printf("User is not admin")
//add response message
return
}
var expiryMinutes int64 = 30
userLabel := ""
profileName := ""
for i, opt := range i.ApplicationCommandData().Options {
if i == 0 {
continue
}
switch opt.Name {
case "expiry":
expiryMinutes = opt.IntValue()
case "user_label":
userLabel = opt.StringValue()
case "profile":
profileName = opt.StringValue()
}
}
currentTime := time.Now()
validTill := currentTime.Add(time.Minute * time.Duration(expiryMinutes))
invite := Invite{
Code: GenerateInviteCode(),
Created: currentTime,
RemainingUses: 1,
UserExpiry: false,
ValidTill: validTill,
UserLabel: userLabel,
Profile: "Default",
Label: fmt.Sprintf("Discord: %s", RenderDiscordUsername(recipient)),
}
if profileName != "" {
if _, ok := d.app.storage.GetProfileKey(profileName); ok {
invite.Profile = profileName
}
}
if recipient != nil && d.app.config.Section("invite_emails").Key("enabled").MustBool(false) {
d.app.debug.Printf("%s: Sending invite message", invite.Code)
invname, err := d.bot.GuildMember(d.guildID, recipient.ID)
invite.SendTo = invname.User.Username
msg, err := d.app.email.constructInvite(invite.Code, invite, d.app, false)
if err != nil {
invite.SendTo = fmt.Sprintf("Failed to send to %s", RenderDiscordUsername(recipient))
d.app.err.Printf("%s: Failed to construct invite message: %v", invite.Code, err)
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInviteFailure"),
Flags: 64, // Ephemeral
},
})
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", RenderDiscordUsername(requester), err)
}
} else {
var err error
err = d.app.discord.SendDM(msg, recipient.ID)
if err != nil {
invite.SendTo = fmt.Sprintf("Failed to send to %s", RenderDiscordUsername(recipient))
d.app.err.Printf("%s: %s: %v", invite.Code, invite.SendTo, err)
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInviteFailure"),
Flags: 64, // Ephemeral
},
})
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", RenderDiscordUsername(requester), err)
}
} else {
d.app.info.Printf("%s: Sent invite email to \"%s\"", invite.Code, RenderDiscordUsername(recipient))
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInvite"),
Flags: 64, // Ephemeral
},
})
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", RenderDiscordUsername(requester), err)
}
}
}
}
//if profile != "" {
d.app.storage.SetInvitesKey(invite.Code, invite)
}
func (d *DiscordDaemon) messageHandler(s *dg.Session, m *dg.MessageCreate) {
if m.GuildID != "" && d.channelName != "" {
if d.channelID == "" {

83
easyproxy/easyproxy.go Normal file
View File

@@ -0,0 +1,83 @@
// Package easyproxy provides a method to quickly create a http.Transport or net.Conn using given proxy details (SOCKS5 or HTTP).
package easyproxy
import (
"crypto/tls"
"net/http"
"net/url"
"github.com/magisterquis/connectproxy"
"golang.org/x/net/proxy"
)
type Protocol int
const (
SOCKS5 Protocol = iota // SOCKS5
HTTP // HTTP
)
type ProxyConfig struct {
Protocol Protocol
Addr string
User string
Password string
}
// NewTransport returns a http.Transport using the given proxy details. Leave user/pass blank if not needed.
func NewTransport(c ProxyConfig) (*http.Transport, error) {
t := &http.Transport{}
if c.Protocol == HTTP {
u := &url.URL{
Scheme: "http",
Host: c.Addr,
}
if c.User != "" && c.Password != "" {
u.User = url.UserPassword(c.User, c.Password)
}
t.Proxy = http.ProxyURL(u)
return t, nil
}
var auth *proxy.Auth = nil
if c.User != "" && c.Password != "" {
auth = &proxy.Auth{User: c.User, Password: c.Password}
}
dialer, err := proxy.SOCKS5("tcp", c.Addr, auth, proxy.Direct)
if err != nil {
return nil, err
}
t.Dial = dialer.Dial
return t, nil
}
// NewConn returns a tls.Conn to "addr" using the given proxy details. Leave user/pass blank if not needed.
func NewConn(c ProxyConfig, addr string, tlsConf *tls.Config) (*tls.Conn, error) {
var proxyDialer proxy.Dialer
var err error
if c.Protocol == SOCKS5 {
var auth *proxy.Auth = nil
if c.User != "" && c.Password != "" {
auth = &proxy.Auth{User: c.User, Password: c.Password}
}
proxyDialer, err = proxy.SOCKS5("tcp", c.Addr, auth, proxy.Direct)
if err != nil {
return nil, err
}
} else {
u := &url.URL{
Scheme: "http",
Host: c.Addr,
}
if c.User != "" && c.Password != "" {
u.User = url.UserPassword(c.User, c.Password)
}
proxyDialer, err = connectproxy.New(u, proxy.Direct)
}
dialer, err := proxyDialer.Dial("tcp", addr)
if err != nil {
return nil, err
}
conn := tls.Client(dialer, tlsConf)
return conn, nil
}

7
easyproxy/go.mod Normal file
View File

@@ -0,0 +1,7 @@
module github.com/hrfee/jfa-go/easyproxy
go 1.20
require golang.org/x/net v0.15.0
require github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b // indirect

4
easyproxy/go.sum Normal file
View File

@@ -0,0 +1,4 @@
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b h1:xZ59n7Frzh8CwyfAapUZLSg+gXH5m63YEaFCMpDHhpI=
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b/go.mod h1:uDd4sYVYsqcxAB8j+Q7uhL6IJCs/r1kxib1HV4bgOMg=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=

171
email.go
View File

@@ -19,6 +19,7 @@ import (
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/hrfee/jfa-go/easyproxy"
"github.com/hrfee/mediabrowser"
"github.com/itchyny/timefmt-go"
"github.com/mailgun/mailgun-go/v4"
@@ -87,7 +88,12 @@ func NewEmailer(app *appContext) *Emailer {
if username == "" && password != "" {
username = emailer.fromAddr
}
err := emailer.NewSMTP(app.config.Section("smtp").Key("server").String(), app.config.Section("smtp").Key("port").MustInt(465), username, password, sslTLS, app.config.Section("smtp").Key("ssl_cert").MustString(""), app.config.Section("smtp").Key("hello_hostname").String(), app.config.Section("smtp").Key("cert_validation").MustBool(true))
var proxyConf *easyproxy.ProxyConfig = nil
if app.proxyEnabled {
proxyConf = &app.proxyConfig
}
authType := sMail.AuthType(app.config.Section("smtp").Key("auth_type").MustInt(4))
err := emailer.NewSMTP(app.config.Section("smtp").Key("server").String(), app.config.Section("smtp").Key("port").MustInt(465), username, password, sslTLS, app.config.Section("smtp").Key("ssl_cert").MustString(""), app.config.Section("smtp").Key("hello_hostname").String(), app.config.Section("smtp").Key("cert_validation").MustBool(true), authType, proxyConf)
if err != nil {
app.err.Printf("Error while initiating SMTP mailer: %v", err)
}
@@ -113,7 +119,7 @@ type SMTP struct {
}
// NewSMTP returns an SMTP emailClient.
func (emailer *Emailer) NewSMTP(server string, port int, username, password string, sslTLS bool, certPath string, helloHostname string, validateCertificate bool) (err error) {
func (emailer *Emailer) NewSMTP(server string, port int, username, password string, sslTLS bool, certPath string, helloHostname string, validateCertificate bool, authType sMail.AuthType, proxy *easyproxy.ProxyConfig) (err error) {
sender := &SMTP{}
sender.Client = sMail.NewSMTPClient()
if sslTLS {
@@ -122,7 +128,7 @@ func (emailer *Emailer) NewSMTP(server string, port int, username, password stri
sender.Client.Encryption = sMail.EncryptionSTARTTLS
}
if username != "" || password != "" {
sender.Client.Authentication = sMail.AuthLogin
sender.Client.Authentication = authType
sender.Client.Username = username
sender.Client.Password = password
}
@@ -131,12 +137,16 @@ func (emailer *Emailer) NewSMTP(server string, port int, username, password stri
sender.Client.Host = server
sender.Client.Port = port
sender.Client.KeepAlive = false
// x509.SystemCertPool is unavailable on windows
if PLATFORM == "windows" {
sender.Client.TLSConfig = &tls.Config{
InsecureSkipVerify: !validateCertificate,
ServerName: server,
}
if proxy != nil {
sender.Client.CustomConn, err = easyproxy.NewConn(*proxy, fmt.Sprintf("%s:%d", server, port), sender.Client.TLSConfig)
}
emailer.sender = sender
return
}
@@ -156,6 +166,9 @@ func (emailer *Emailer) NewSMTP(server string, port int, username, password stri
ServerName: server,
RootCAs: rootCAs,
}
if proxy != nil {
sender.Client.CustomConn, err = easyproxy.NewConn(*proxy, fmt.Sprintf("%s:%d", server, port), sender.Client.TLSConfig)
}
emailer.sender = sender
return
}
@@ -728,6 +741,72 @@ func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub b
return email, nil
}
func (emailer *Emailer) expiryAdjustedValues(username string, expiry time.Time, reason string, app *appContext, noSub bool, custom bool) map[string]interface{} {
template := map[string]interface{}{
"yourExpiryWasAdjusted": emailer.lang.UserExpiryAdjusted.get("yourExpiryWasAdjusted"),
"ifPreviouslyDisabled": emailer.lang.UserExpiryAdjusted.get("ifPreviouslyDisabled"),
"reasonString": emailer.lang.Strings.get("reason"),
"newExpiry": "",
"message": "",
}
if noSub {
template["helloUser"] = emailer.lang.Strings.get("helloUser")
empty := []string{"reason", "newExpiry"}
for _, v := range empty {
template[v] = "{" + v + "}"
}
} else {
template["reason"] = reason
template["message"] = app.config.Section("messages").Key("message").String()
template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username})
exp := app.formatDatetime(expiry)
if !expiry.IsZero() {
if custom {
template["newExpiry"] = exp
} else if !expiry.IsZero() {
template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
"date": exp,
})
}
}
}
return template
}
func (emailer *Emailer) constructExpiryAdjusted(username string, expiry time.Time, reason string, app *appContext, noSub bool) (*Message, error) {
email := &Message{
Subject: app.config.Section("user_expiry").Key("adjustment_subject").MustString(emailer.lang.UserExpiryAdjusted.get("title")),
}
var err error
var template map[string]interface{}
message := app.storage.MustGetCustomContentKey("UserExpiryAdjusted")
if message.Enabled {
template = emailer.expiryAdjustedValues(username, expiry, reason, app, noSub, true)
} else {
template = emailer.expiryAdjustedValues(username, expiry, reason, app, noSub, false)
}
if noSub {
template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
"date": "{newExpiry}",
})
}
if message.Enabled {
content := templateEmail(
message.Content,
message.Variables,
nil,
template,
)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "user_expiry", "adjustment_email_", template)
}
if err != nil {
return nil, err
}
return email, nil
}
func (emailer *Emailer) welcomeValues(username string, expiry time.Time, app *appContext, noSub bool, custom bool) map[string]interface{} {
template := map[string]interface{}{
"welcome": emailer.lang.WelcomeEmail.get("welcome"),
@@ -886,55 +965,65 @@ func (app *appContext) getAddressOrName(jfID string) string {
// ReverseUserSearch returns the jellyfin ID of the user with the given username, email, or contact method username.
// returns "" if none found. returns only the first match, might be an issue if there are users with the same contact method usernames.
func (app *appContext) ReverseUserSearch(address string) (user mediabrowser.User, ok bool) {
func (app *appContext) ReverseUserSearch(address string, matchUsername, matchEmail, matchContactMethod bool) (user mediabrowser.User, ok bool) {
ok = false
user, status, err := app.jf.UserByName(address, false)
if status == 200 && err == nil {
ok = true
return
var status int
var err error = nil
if matchUsername {
user, status, err = app.jf.UserByName(address, false)
if status == 200 && err == nil {
ok = true
return
}
}
emailAddresses := []EmailAddress{}
err = app.storage.db.Find(&emailAddresses, badgerhold.Where("Addr").Eq(address))
if err == nil && len(emailAddresses) > 0 {
for _, emailUser := range emailAddresses {
user, status, err = app.jf.UserByID(emailUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
if matchEmail {
emailAddresses := []EmailAddress{}
err = app.storage.db.Find(&emailAddresses, badgerhold.Where("Addr").Eq(address))
if err == nil && len(emailAddresses) > 0 {
for _, emailUser := range emailAddresses {
user, status, err = app.jf.UserByID(emailUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
}
}
}
}
// Dont know how we'd use badgerhold when we need to render each username,
// Apart from storing the rendered name in the db.
for _, dcUser := range app.storage.GetDiscord() {
if RenderDiscordUsername(dcUser) == strings.ToLower(address) {
user, status, err = app.jf.UserByID(dcUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
if matchContactMethod {
for _, dcUser := range app.storage.GetDiscord() {
if RenderDiscordUsername(dcUser) == strings.ToLower(address) {
user, status, err = app.jf.UserByID(dcUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
}
}
}
}
tgUsername := strings.TrimPrefix(address, "@")
telegramUsers := []TelegramUser{}
err = app.storage.db.Find(&telegramUsers, badgerhold.Where("Username").Eq(tgUsername))
if err == nil && len(telegramUsers) > 0 {
for _, telegramUser := range telegramUsers {
user, status, err = app.jf.UserByID(telegramUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
tgUsername := strings.TrimPrefix(address, "@")
telegramUsers := []TelegramUser{}
err = app.storage.db.Find(&telegramUsers, badgerhold.Where("Username").Eq(tgUsername))
if err == nil && len(telegramUsers) > 0 {
for _, telegramUser := range telegramUsers {
user, status, err = app.jf.UserByID(telegramUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
}
}
}
}
matrixUsers := []MatrixUser{}
err = app.storage.db.Find(&matrixUsers, badgerhold.Where("UserID").Eq(address))
if err == nil && len(matrixUsers) > 0 {
for _, matrixUser := range matrixUsers {
user, status, err = app.jf.UserByID(matrixUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
matrixUsers := []MatrixUser{}
err = app.storage.db.Find(&matrixUsers, badgerhold.Where("UserID").Eq(address))
if err == nil && len(matrixUsers) > 0 {
for _, matrixUser := range matrixUsers {
user, status, err = app.jf.UserByID(matrixUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
}
}
}
}

65
genericdaemon.go Normal file
View File

@@ -0,0 +1,65 @@
package main
import "time"
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
type GenericDaemon struct {
Stopped bool
ShutdownChannel chan string
Interval time.Duration
period time.Duration
jobs []func(app *appContext)
app *appContext
name string
}
func (d *GenericDaemon) appendJobs(jobs ...func(app *appContext)) {
d.jobs = append(d.jobs, jobs...)
}
// NewGenericDaemon returns a daemon which can be given jobs that utilize appContext.
func NewGenericDaemon(interval time.Duration, app *appContext, jobs ...func(app *appContext)) *GenericDaemon {
d := GenericDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
name: "Generic Daemon",
}
d.jobs = jobs
return &d
}
func (d *GenericDaemon) Name(name string) { d.name = name }
func (d *GenericDaemon) run() {
d.app.info.Printf("%s started", d.name)
for {
select {
case <-d.ShutdownChannel:
d.ShutdownChannel <- "Down"
return
case <-time.After(d.period):
break
}
started := time.Now()
for _, job := range d.jobs {
job(d.app)
}
finished := time.Now()
duration := finished.Sub(started)
d.period = d.Interval - duration
}
}
func (d *GenericDaemon) Shutdown() {
d.Stopped = true
d.ShutdownChannel <- "Down"
<-d.ShutdownChannel
close(d.ShutdownChannel)
}

63
go.mod
View File

@@ -14,8 +14,13 @@ replace github.com/hrfee/jfa-go/linecache => ./linecache
replace github.com/hrfee/jfa-go/api => ./api
replace github.com/hrfee/jfa-go/easyproxy => ./easyproxy
replace github.com/hrfee/jfa-go/jellyseerr => ./jellyseerr
require (
github.com/bwmarrin/discordgo v0.27.1
github.com/dgraph-io/badger/v3 v3.2103.5
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a
github.com/fatih/color v1.15.0
github.com/fsnotify/fsnotify v1.6.0
@@ -26,34 +31,35 @@ require (
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a
github.com/hrfee/jfa-go/common v0.0.0-20230421170108-d800b97f69b6
github.com/hrfee/jfa-go/docs v0.0.0-20230421170108-d800b97f69b6
github.com/hrfee/jfa-go/linecache v0.0.0-20230421170108-d800b97f69b6
github.com/hrfee/jfa-go/logger v0.0.0-20230421170108-d800b97f69b6
github.com/hrfee/jfa-go/ombi v0.0.0-20230421170108-d800b97f69b6
github.com/hrfee/mediabrowser v0.3.8
github.com/hrfee/jfa-go/common v0.0.0-20240728190513-dabef831d769
github.com/hrfee/jfa-go/docs v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/jfa-go/easyproxy v0.0.0-00010101000000-000000000000
github.com/hrfee/jfa-go/linecache v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/jfa-go/logger v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/jfa-go/ombi v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/mediabrowser v0.3.13
github.com/itchyny/timefmt-go v0.1.5
github.com/lithammer/shortuuid/v3 v3.0.7
github.com/mailgun/mailgun-go/v4 v4.9.0
github.com/mailgun/mailgun-go/v4 v4.9.1
github.com/robert-nix/ansihtml v1.0.1
github.com/steambap/captcha v1.4.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/timshannon/badgerhold/v4 v4.0.2
github.com/writeas/go-strip-markdown v2.0.1+incompatible
github.com/xhit/go-simple-mail/v2 v2.13.0
github.com/xhit/go-simple-mail/v2 v2.16.0
gopkg.in/ini.v1 v1.67.0
maunium.net/go/mautrix v0.15.2
maunium.net/go/mautrix v0.15.3
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/bytedance/sonic v1.9.2 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.1.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dgraph-io/badger/v3 v3.2103.1 // indirect
github.com/dgraph-io/ristretto v0.1.0 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/dgraph-io/ristretto v0.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect
github.com/getlantern/errors v1.0.3 // indirect
@@ -76,19 +82,21 @@ require (
github.com/goccy/go-json v0.10.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/glog v0.0.0-20210429001901-424d2337a529 // indirect
github.com/golang/glog v1.1.1 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/flatbuffers v2.0.0+incompatible // indirect
github.com/google/flatbuffers v23.5.26+incompatible // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/mux v1.8.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/hrfee/jfa-go/jellyseerr v0.0.0-00010101000000-000000000000 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.13.1 // indirect
github.com/klauspost/compress v1.16.6 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
@@ -104,11 +112,10 @@ require (
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/timshannon/badgerhold/v4 v4.0.2 // indirect
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
go.opencensus.io v0.23.0 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/otel v1.16.0 // indirect
go.opentelemetry.io/otel/metric v1.16.0 // indirect
go.opentelemetry.io/otel/trace v1.16.0 // indirect
@@ -116,14 +123,14 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect
golang.org/x/image v0.7.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
golang.org/x/tools v0.9.3 // indirect
google.golang.org/protobuf v1.30.0 // indirect
golang.org/x/crypto v0.13.0 // indirect
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect
golang.org/x/image v0.8.0 // indirect
golang.org/x/net v0.15.0 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/tools v0.10.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/maulogger/v2 v2.4.1 // indirect
)

94
go.sum
View File

@@ -3,6 +3,7 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
@@ -14,13 +15,14 @@ github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZx
github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY=
github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/bytedance/sonic v1.9.2 h1:GDaNjuWSGu09guE9Oql0MSTNhNCLlWwO8y/xM5BzcbM=
github.com/bytedance/sonic v1.9.2/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
@@ -36,13 +38,17 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v3 v3.2103.1 h1:zaX53IRg7ycxVlkd5pYdCeFp1FynD6qBGQoQql3R3Hk=
github.com/dgraph-io/badger/v3 v3.2103.1/go.mod h1:dULbq6ehJ5K0cGW/1TQ9iSfUk0gbSiToDWmWmTsJ53E=
github.com/dgraph-io/ristretto v0.1.0 h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/LuerPI=
github.com/dgraph-io/badger/v3 v3.2103.5 h1:ylPa6qzbjYRQMU6jokoj4wzcaweHylt//CH0AKt0akg=
github.com/dgraph-io/badger/v3 v3.2103.5/go.mod h1:4MPiseMeDQ3FNCYwRbbcBOGJLf5jsE0PPFzRiKjtcdw=
github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug=
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a h1:M88ob4TyDnEqNuL3PgsE/p3bDujfspnulR+0dQWNYZs=
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a/go.mod h1:buzQsO8HHkZX2Q45fdfGH1xejPjuDQaXH8btcYMFzPM=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -164,8 +170,9 @@ github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzq
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v0.0.0-20210429001901-424d2337a529 h1:2voWjNECnrZRbfwXxHB1/j8wa6xdKn85B5NzgVL/pTU=
github.com/golang/glog v0.0.0-20210429001901-424d2337a529/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v1.1.1 h1:jxpi2eWoU84wbX9iIEyAeeoac3FLuifZpY9tcNUD9kw=
github.com/golang/glog v1.1.1/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
@@ -183,16 +190,19 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a h1:AWZzzFrqyjYlRloN6edwTLTUbKxf5flLXNuTBDm3Ews=
github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/flatbuffers v1.12.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v2.0.0+incompatible h1:dicJ2oXwypfwUGnB2/TYWYEKiuk9eYQlQO/AnOHl5mI=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v2.0.0+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg=
github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -214,8 +224,8 @@ github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hrfee/mediabrowser v0.3.8 h1:y0iBCb6jE3QKcsiCJSYva2fFPHRn4UA+sGRzoPuJ/Dk=
github.com/hrfee/mediabrowser v0.3.8/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/hrfee/mediabrowser v0.3.13 h1:NgQNbq+JWwsP68BdWXL/rwbpfE/oO5LJ5KVkE+aNbX8=
github.com/hrfee/mediabrowser v0.3.13/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
@@ -230,8 +240,9 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.13.1 h1:wXr2uRxZTJXHLly6qhJabee5JqIhTRoLBhDOA74hDEQ=
github.com/klauspost/compress v1.13.1/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.16.6 h1:91SKEy4K37vkp255cJ8QesJhjyRO0hn9i9G0GoUwLsk=
github.com/klauspost/compress v1.16.6/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
@@ -253,8 +264,10 @@ github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaK
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mailgun/mailgun-go/v4 v4.9.0 h1:wRbxvVQ5QObFewLxc1uVvipA16D8gxeiO+cBOca51Iw=
github.com/mailgun/mailgun-go/v4 v4.9.0/go.mod h1:FJlF9rI5cQT+mrwujtJjPMbIVy3Ebor9bKTVsJ0QU40=
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b h1:xZ59n7Frzh8CwyfAapUZLSg+gXH5m63YEaFCMpDHhpI=
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b/go.mod h1:uDd4sYVYsqcxAB8j+Q7uhL6IJCs/r1kxib1HV4bgOMg=
github.com/mailgun/mailgun-go/v4 v4.9.1 h1:D/jhJXYod4RqRsNOOSrjrtAcMEnz8mPYJmeA5cueHKY=
github.com/mailgun/mailgun-go/v4 v4.9.1/go.mod h1:FJlF9rI5cQT+mrwujtJjPMbIVy3Ebor9bKTVsJ0QU40=
github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@@ -273,7 +286,7 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -311,6 +324,7 @@ github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdh
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
@@ -333,8 +347,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
@@ -357,6 +371,7 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/timshannon/badgerhold/v3 v3.0.0-20210909134927-2b6764d68c1e h1:zWSVsQaifg0cVH9VvR+cMguV7exK6U+SoW8YD1cZpR4=
github.com/timshannon/badgerhold/v3 v3.0.0-20210909134927-2b6764d68c1e/go.mod h1:/Seq5xGNo8jLhSbDX3jdbeZrp4yFIpQ6/7n4TjziEWs=
github.com/timshannon/badgerhold/v4 v4.0.2 h1:83OLY/NFnEaMnHEPd84bYtkLipVkjTsMbzQRYbk47g4=
github.com/timshannon/badgerhold/v4 v4.0.2/go.mod h1:rh6RyXLQFsvrvcKondPQQFZnNovpRzu+gS0FlLxYuHY=
@@ -379,16 +394,17 @@ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijb
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE=
github.com/xhit/go-simple-mail/v2 v2.13.0 h1:OANWU9jHZrVfBkNkvLf8Ww0fexwpQVF/v/5f96fFTLI=
github.com/xhit/go-simple-mail/v2 v2.13.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA=
github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/otel v1.9.0/go.mod h1:np4EoPGzoPs3O67xUVNoPPcmSvsfOxNlNA4F4AC+0Eo=
go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s=
go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4=
@@ -419,14 +435,14 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw=
golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg=
golang.org/x/image v0.8.0 h1:agUcRXV/+w6L9ryntYYsF2x9fQTMd4T8fiiYXAVW6Jg=
golang.org/x/image v0.8.0/go.mod h1:PwLxp3opCYg4WR2WO9P0L6ESnsD6bLTWcw8zanLMVFM=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@@ -436,7 +452,7 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -458,8 +474,8 @@ golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -497,11 +513,12 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -511,8 +528,9 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -527,8 +545,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.9.3 h1:Gn1I8+64MsuTb/HpH+LmQtNas23LhUVr3rYZ0eKuaMM=
golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg=
golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -558,8 +576,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -586,6 +604,6 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
maunium.net/go/mautrix v0.15.2 h1:fUiVajeoOR92uJoSShHbCvh7uG6lDY4ZO4Mvt90LbjU=
maunium.net/go/mautrix v0.15.2/go.mod h1:h4NwfKqE4YxGTLSgn/gawKzXAb2sF4qx8agL6QEFtGg=
maunium.net/go/mautrix v0.15.3 h1:C9BHSUM0gYbuZmAtopuLjIcH5XHLb/ZjTEz7nN+0jN0=
maunium.net/go/mautrix v0.15.3/go.mod h1:zLrQqdxJlLkurRCozTc9CL6FySkgZlO/kpCYxBILSLE=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -10,6 +10,7 @@
window.discordEnabled = {{ .discordEnabled }};
window.matrixEnabled = {{ .matrixEnabled }};
window.ombiEnabled = {{ .ombiEnabled }};
window.jellyseerrEnabled = {{ .jellyseerrEnabled }};
window.usernameEnabled = {{ .username }};
window.langFile = JSON.parse({{ .language }});
window.linkResetEnabled = {{ .linkResetEnabled }};
@@ -17,6 +18,8 @@
window.jellyfinLogin = {{ .jellyfinLogin }};
window.jfAdminOnly = {{ .jfAdminOnly }};
window.jfAllowAll = {{ .jfAllowAll }};
window.referralsEnabled = {{ .referralsEnabled }};
window.loginAppearance = "{{ .loginAppearance }}";
</script>
<title>Admin - jfa-go</title>
{{ template "header.html" . }}
@@ -24,11 +27,16 @@
<body class="max-w-full overflow-x-hidden section">
{{ template "login-modal.html" . }}
<div id="modal-add-user" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-add-user" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-add-user" href="">
<span class="heading">{{ .strings.newUser }} <span class="modal-close">&times;</span></span>
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.username }}" id="add-user-user">
<input type="email" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.emailAddress }}">
<input type="password" class="field input ~neutral @high mb-4" placeholder="{{ .strings.password }}" id="add-user-password">
<label class="label supra">{{ .strings.profile }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="add-user-profile">
</select>
</div>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.create }}</span>
@@ -36,31 +44,31 @@
</form>
</div>
<div id="modal-about" class="modal">
<div class="relative mx-auto my-[10%] w-4/5 lg:w-1/3 content card">
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 content card">
<img src="{{ .urlBase }}/banner.svg" class="banner header" alt="jfa-go banner">
<span class="heading"><span class="modal-close">&times;</span></span>
<p>{{ .strings.version }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .version }}</span></p>
<p>{{ .strings.commitNoun }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .commit }}</span></p>
<p>{{ .strings.buildTime }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .buildTime }}</span></p>
<p>{{ .strings.builtBy }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .builtBy }}</span></p>
<div class="row col flex">
<a class="button ~neutral mr-2 mt-4 mb-4 lang-link" href="https://github.com/hrfee/jfa-go"><i class="ri-github-line mr-2"></i>github</a>
<a class="button ~urge mt-4 mb-4 mr-2 lang-link" href="https://wiki.jfa-go.com">wiki/docs</a>
<a class="button ~positive mt-4 mb-4 mr-2 lang-link" href="https://weblate.jfa-go.com">translation</a>
<div class="dropdown mr-2" tabindex="0">
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~info mt-4 mb-4 dropdown-button lang-link">
<div class="flex flex-row flex-wrap gap-2 my-2">
<a class="button ~neutral lang-link" href="https://github.com/hrfee/jfa-go"><i class="ri-github-line mr-2"></i>github</a>
<a class="button ~urge lang-link" href="https://wiki.jfa-go.com">wiki/docs</a>
<a class="button ~positive lang-link" href="https://weblate.jfa-go.com">translation</a>
<div class="dropdown" tabindex="0">
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~info dropdown-button lang-link">
<i class="ri-hand-heart-line mr-2"></i>
donate
<span class="ml-2 chev"></span>
</a>
<div class="dropdown-display">
<div class="card ~neutral @low">
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~neutral mb-2 w-100 lang-link">GitHub</a>
<a href="https://ko-fi.com/hrfee" target="_blank" class="button ~neutral mb-2 w-100 lang-link">Ko-fi</a>
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~neutral mb-2 w-full lang-link">GitHub</a>
<a href="https://ko-fi.com/hrfee" target="_blank" class="button ~neutral mb-2 w-full lang-link">Ko-fi</a>
</div>
</div>
</div>
<a class="button ~urge mt-4 mb-4 @low discord lang-link" href="https://discord.com/invite/MrtvuQmyhP" target="_blank"><i class="ri-discord-line mr-2"></i>discord</a>
<a class="button ~urge @low discord lang-link" href="https://discord.com/invite/MrtvuQmyhP" target="_blank"><i class="ri-discord-line mr-2"></i>discord</a>
</div>
<p><a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE">Available under the MIT License. Font "Hanken Grotesk" available under SIL OFL 1.1 License.</a></p>
<pre class="font-mono bg-inherit">{{ .license }}</pre>
@@ -73,28 +81,74 @@
</div>
</div>
<div id="modal-modify-user" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-modify-user" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-modify-user" href="">
<span class="heading"><span id="header-modify-user"></span> <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.modifySettingsDescription }}</p>
<div class="flex-row mb-4">
<label class="flex-row-group mr-2">
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-profile" checked>
<div class="flex flex-col gap-4 my-2">
<div class="flex flex-row gap-2">
<label class="grow">
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-profile" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
</label>
<label class="grow">
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-user">
<span class="button ~neutral @low supra full-width center">{{ .strings.user }}</span>
</label>
</div>
<div class="select ~neutral @low">
<select id="modify-user-profiles"></select>
</div>
<div class="select ~neutral @low unfocused">
<select id="modify-user-users"></select>
</div>
<label class="switch">
<input type="checkbox" id="modify-user-configuration" checked>
<span>{{ .strings.applyConfigurationAndPolicy }}</span>
</label>
<label class="switch">
<input type="checkbox" id="modify-user-homescreen" checked>
<span>{{ .strings.applyHomescreenLayout }}</span>
</label>
<label class="switch">
<input type="checkbox" id="modify-user-ombi" checked>
<span>{{ .strings.applyOmbi }}</span>
</label>
<label class="switch">
<input type="checkbox" id="modify-user-jellyseerr" checked>
<span>{{ .strings.applyJellyseerr }}</span>
</label>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
</label>
</div>
</form>
</div>
{{ if .referralsEnabled }}
<div id="modal-enable-referrals-user" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-enable-referrals-user" href="">
<span class="heading"><span id="header-enable-referrals-user"></span> <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.enableReferralsDescription }}</p>
<div class="flex flex-row mb-4">
<label class="grow mr-2">
<input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-profile" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
</label>
<label class="flex-row-group ml-2">
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-user">
<span class="button ~neutral @low supra full-width center">{{ .strings.user }}</span>
<label class="grow ml-2">
<input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-invite">
<span class="button ~neutral @low supra full-width center">{{ .strings.invite }}</span>
</label>
</div>
<div class="select ~neutral @low mb-4">
<select id="modify-user-profiles"></select>
<select id="enable-referrals-user-profiles"></select>
</div>
<div class="select ~neutral @low mb-4 unfocused">
<select id="modify-user-users"></select>
<select id="enable-referrals-user-invites"></select>
</div>
<label class="switch mb-4">
<input type="checkbox" id="modify-user-homescreen" checked>
<span>{{ .strings.applyHomescreenLayout }}</span>
<input type="checkbox" id="enable-referrals-user-expiry">
<span>{{ .strings.useInviteExpiry }}</span>
<span class="flex flex-row support mt-2">{{ .strings.useInviteExpiryNote }}</span>
</label>
<label>
<input type="submit" class="unfocused">
@@ -102,8 +156,28 @@
</label>
</form>
</div>
<div id="modal-enable-referrals-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-enable-referrals-profile" href="">
<span class="heading"><span id="header-enable-referrals-profile">{{ .strings.enableReferrals }}</span> <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.enableReferralsProfileDescription }}</p>
<label class="supra" for="enable-referrals-profile-invites">{{ .strings.invite }}</label>
<div class="select ~neutral @low mb-4 mt-2">
<select id="enable-referrals-profile-invites"></select>
</div>
<label class="switch mb-4">
<input type="checkbox" id="enable-referrals-profile-expiry">
<span>{{ .strings.useInviteExpiry }}</span>
<span class="flex flex-row support mt-2">{{ .strings.useInviteExpiryNote }}</span>
</label>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
</label>
</form>
</div>
{{ end }}
<div id="modal-delete-user" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-delete-user" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-delete-user" href="">
<span class="heading"><span id="header-delete-user"></span> <span class="modal-close">&times;</span></span>
<div class="content mt-8">
<label class="switch mb-4">
@@ -119,42 +193,52 @@
</form>
</div>
<div id="modal-extend-expiry" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-extend-expiry" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-extend-expiry" href="">
<span class="heading"><span id="header-extend-expiry"></span> <span class="modal-close">&times;</span></span>
<div class="content mt-8">
<div class="row">
<div class="col">
<label class="label supra" for="extend-expiry-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-months">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<label class="label supra" for="extend-expiry-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-days">
<option>0</option>
</select>
</div>
<aside class="aside sm ~urge dark:~d_info mb-2 @low row unfocused" id="extend-expiry-date"></aside>
<div>
<span class="text-xl supra row py-1">{{ .strings.setExpiry }}</span>
<div class="row">
<input type="text" id="extend-expiry-text" class="input ~neutral @low mb-2 mt-4" placeholder="{{ .strings.enterExpiry }}">
</div>
</div>
<div class="row">
<div class="col">
<label class="label supra" for="extend-expiry-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-hours">
<option>0</option>
</select>
<div id="extend-expiry-field-inputs">
<span class="text-xl supra row py-1">{{ .strings.extendExpiry }}</span>
<div class="row">
<div class="col">
<label class="label supra" for="extend-expiry-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-months">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<label class="label supra" for="extend-expiry-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-days">
<option>0</option>
</select>
</div>
</div>
</div>
<div class="col">
<label class="label supra" for="extend-expiry-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-minutes">
<option>0</option>
</select>
<div class="row">
<div class="col">
<label class="label supra" for="extend-expiry-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-hours">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<label class="label supra" for="extend-expiry-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-minutes">
<option>0</option>
</select>
</div>
</div>
</div>
</div>
@@ -173,7 +257,7 @@
<div id="modal-announce" class="modal">
<form class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card" id="form-announce" href="">
<span class="heading"><span id="header-announce"></span> <span class="modal-close">&times;</span></span>
<div class="row">
<div class="flex flex-col md:flex-row">
<div class="col card ~neutral @low">
<div id="announce-details">
<span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span>
@@ -190,7 +274,7 @@
<input type="text" class="input ~neutral @low mb-2 mt-4">
<p class="support">{{ .strings.templateEnterName }}</p>
</label>
<div class="row flex-expand">
<div class="flex flex-row justify-between">
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low center supra submit">{{ .strings.send }}</span>
@@ -206,10 +290,10 @@
</form>
</div>
<div id="modal-customize" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3">
<span class="heading">{{ .strings.customizeMessages }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.customizeMessagesDescription }}</p>
<div class="table-responsive">
<div class="">
<table class="table">
<thead>
<tr>
@@ -228,6 +312,7 @@
<span class="heading"><span id="header-editor"></span> <span class="modal-close">&times;</span></span>
<div class="row">
<div class="col card ~neutral @low">
<aside class="aside sm ~urge dark:~d_info mb-2 @low" id="aside-editor"></aside>
<span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span>
<div id="editor-variables" class="mt-4"></div>
<span class="label supra" for="editor-conditionals" id="label-editor-conditionals">{{ .strings.conditionals }}</span>
@@ -250,7 +335,7 @@
</form>
</div>
<div id="modal-restart" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~critical @low">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~critical @low">
<span class="heading">{{ .strings.settingsRestartRequired }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.settingsRestartRequiredDescription }}</p>
<div class="float-right">
@@ -259,21 +344,62 @@
</div>
</div>
</div>
<div id="modal-backups" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-2/3">
<span class="heading">{{ .strings.backups }} <span class="modal-close">&times;</span></span>
<div class="content my-4">
{{ .strings.backupsDescription }}
<ul>
<li>{{ .strings.backupsCopy }}</li>
<li>{{ .strings.backupsFormatNote }}</li>
<li><a target="_blank" href="https://wiki.jfa-go.com/docs/backups/">{{ .strings.wikiPage }}</a></li>
</ul>
</div>
<div class="flex flex-row flex-wrap my-2">
<button class="button ~info @low mr-2 mb-2" id="settings-backups-backup">{{ .strings.backupNow }}</button>
<button class="button ~neutral @low mr-2 mb-2" id="settings-backups-upload">{{ .strings.backupUpload }}</button>
<input id="backups-file" name="backups-file" type="file" hidden>
<button class="button ~neutral @low mr-2 mb-2" id="settings-backups-sort-direction">{{ .strings.sortDirection }}</button>
</div>
<div class="overflow-x-auto text-xs md:text-sm">
<table class="table">
<thead>
<tr>
<th>{{ .strings.name }}</th>
<th>{{ .strings.date }}</th>
<th class="table-inline justify-center">{{ .strings.backupDownloadRestore }}</th>
</tr>
</thead>
<tbody id="backups-list"></tbody>
</table>
</div>
</div>
</div>
<div id="modal-backed-up" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.backupCreated }} <span class="modal-close">&times;</span></span>
<p class="content my-4" id="settings-backed-up-location"></p>
<p class="content my-4">{{ .strings.backupCanDownload }}</p>
<div>
<button class="button flex w-full ~info @low mb-2"><span class="flex items-center" id="settings-backed-up-download">{{ .strings.download }}</span></button>
</div>
</div>
</div>
<div id="modal-refresh" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.settingsApplied }}</span>
<p class="content">{{ .strings.settingsRefreshPage }}</p>
</div>
</div>
<div id="modal-send-pwr" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.sendPWR }}</span>
<p class="content my-2" id="send-pwr-note"></p>
<span class="button ~urge @low mt-2" id="send-pwr-link">{{ .strings.copy }}</span>
</div>
</div>
<div id="modal-ombi-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-ombi-defaults" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-ombi-defaults" href="">
<span class="heading">{{ .strings.ombiProfile }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.ombiUserDefaultsDescription }}</p>
<div class="select ~neutral @low mb-4">
@@ -285,8 +411,21 @@
</label>
</form>
</div>
<div id="modal-jellyseerr-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-jellyseerr-defaults" href="">
<span class="heading">{{ .strings.jellyseerrProfile }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.jellyseerrUserDefaultsDescription }}</p>
<div class="select ~neutral @low mb-4">
<select></select>
</div>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.submit }}</span>
</label>
</form>
</div>
<div id="modal-user-profiles" class="modal">
<div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-2/3 content card">
<span class="heading">{{ .strings.userProfiles }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.userProfilesDescription }}</p>
<div class="table-responsive">
@@ -298,6 +437,12 @@
{{ if .ombiEnabled }}
<th>Ombi</th>
{{ end }}
{{ if .jellyseerrEnabled }}
<th>Jellyseerr</th>
{{ end }}
{{ if .referralsEnabled }}
<th>{{ .strings.referrals }}</th>
{{ end }}
<th>{{ .strings.from }}</th>
<th>{{ .strings.userProfilesLibraries }}</th>
<th><span class="button ~neutral @high" id="button-profile-create">{{ .strings.create }}</span></th>
@@ -309,7 +454,7 @@
</div>
</div>
<div id="modal-add-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-add-profile" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-add-profile" href="">
<span class="heading">{{ .strings.addProfile }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.addProfileDescription }}</p>
<label>
@@ -332,7 +477,7 @@
</form>
</div>
<div id="modal-update" class="modal">
<div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 content card">
<span class="heading">{{ .strings.updates }} <span class="modal-close">&times;</span></span>
<p class="content">
<h2 class="mt-2">
@@ -348,7 +493,7 @@
</div>
{{ if .telegramEnabled }}
<div id="modal-telegram" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkTelegram }}</span>
<p class="content mb-4">{{ .strings.sendPIN }}</p>
<h1 class="ac" id="telegram-pin"></h1>
@@ -366,7 +511,7 @@
{{ end }}
{{ if .discordEnabled }}
<div id="modal-discord" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3">
<span class="heading mb-4"><span id="discord-header"></span><span class="modal-close">&times;</span></span>
<p class="content mb-4" id="discord-description"></p>
<div class="row">
@@ -377,7 +522,7 @@
</div>
{{ end }}
<div id="modal-matrix" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-matrix" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-matrix" href="">
<span class="heading">{{ .strings.linkMatrix }}</span>
<p class="content my-4">{{ .strings.linkMatrixDescription }}</p>
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.matrixHomeServer }}" id="matrix-homeserver">
@@ -390,8 +535,8 @@
</form>
</div>
<div id="notification-box"></div>
<div class="top-4 left-4 absolute">
<span class="dropdown" tabindex="0" id="lang-dropdown">
<div class="top-4 left-4 absolute flex flex-row gap-2">
<span class="dropdown z-[11]" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button">
<i class="ri-global-line"></i>
<span class="ml-2 chev"></span>
@@ -412,15 +557,18 @@
</span>
<span class="button ~warning" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
</div>
<div class="top-4 right-4 absolute">
<a class="button ~info" href="/my/account"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
</div>
{{ if .userPageEnabled }}
<div class="top-4 right-4 absolute">
<a class="button ~info" href="{{ .urlBase }}/my/account"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
</div>
{{ end }}
<div class="page-container">
<div class="mb-4">
<header class="flex flex-wrap items-center justify-between">
<div>
<header>
<div class="flex flex-row overflow-x-scroll items-center">
<span id="button-tab-invites" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.invites }}</span>
<span id="button-tab-accounts" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.accounts }}</span>
<span id="button-tab-activity" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.activity }}</span>
<span id="button-tab-settings" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.settings }}</span>
</div>
</header>
@@ -431,55 +579,55 @@
</div>
</div>
<div id="tab-invites">
<div class="card @low invites dark:~d_neutral mb-4">
<div class="card @low invites dark:~d_neutral mb-4 overflow-visible">
<span class="heading">{{ .strings.invites }}</span>
<div id="invites"></div>
<div id="invites" class="mt-2"></div>
</div>
<div class="card @low dark:~d_neutral">
<span class="heading">{{ .strings.create }}</span>
<div class="flex flex-col md:flex-row gap-3" id="create-inv">
<div class="card ~neutral @low col">
<div class="row mb-2">
<label class="col mr-2">
<div class="flex flex-col md:flex-row gap-3 mt-2" id="create-inv">
<div class="card ~neutral @low flex flex-col gap-2 grow">
<div class="flex flex-row gap-2">
<label class="w-1/2">
<input type="radio" name="duration" class="unfocused" id="radio-inv-duration" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.inviteDuration }}</span>
</label>
<label class="col ml-2">
<label class="w-1/2">
<input type="radio" name="duration" class="unfocused" id="radio-user-expiry">
<span class="button ~neutral @low supra full-width center">{{ .strings.userExpiry }}</span>
</label>
</div>
<div id="inv-duration">
<div class="row">
<div class="col">
<div id="inv-duration" class="flex flex-col gap-2">
<div class="flex flex-row gap-2">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="create-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="create-months">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="create-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="create-days">
<option>0</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="flex flex-row gap-2">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="create-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="create-hours">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="create-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="create-minutes">
<option>0</option>
</select>
@@ -487,44 +635,46 @@
</div>
</div>
</div>
<div id="user-expiry" class="unfocused">
<p class="support mb-2">{{ .strings.userExpiryDescription }}</p>
<div class="mb-2">
<label for="create-user-expiry-enabled" class="button ~neutral @low">
<input type="checkbox" id="create-user-expiry-enabled" aria-label="User duration enabled">
<span class="ml-2">{{ .strings.enabled }} </span>
</label>
<div id="user-expiry" class="unfocused flex flex-col gap-2">
<div class="flex flex-row gap-2">
<p class="support">{{ .strings.userExpiryDescription }}</p>
<div>
<label for="create-user-expiry-enabled" class="button ~neutral @low">
<input type="checkbox" id="create-user-expiry-enabled" aria-label="User duration enabled">
<span class="ml-2">{{ .strings.enabled }} </span>
</label>
</div>
</div>
<div class="row">
<div class="col">
<div class="flex flex-row gap-2">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="user-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="user-months">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="user-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="user-days">
<option>0</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="flex flex-row gap-2">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="user-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="user-hours">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="user-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="user-minutes">
<option>0</option>
</select>
@@ -532,72 +682,91 @@
</div>
</div>
</div>
<div class="col">
<div class="flex flex-col gap-4">
<label class="label supra" for="create-label"> {{ .strings.label }}</label>
<input type="text" id="create-label" class="input ~neutral @low mb-2 mt-4">
<input type="text" id="create-label" class="input ~neutral @low">
</div>
<div class="flex flex-col gap-4">
<div>
<label class="label supra" for="create-user-label"> {{ .strings.userLabel }}</label>
<p class="support">{{ .strings.userLabelDescription }}</p>
</div>
<input type="text" id="create-user-label" class="input ~neutral @low">
</div>
</div>
<div class="card ~neutral @low col">
<label class="label supra" for="create-uses">{{ .strings.inviteNumberOfUses }}</label>
<div class="flex-expand mb-2 mt-4">
<input type="number" min="0" id="create-uses" class="input ~neutral @low mr-2" value=1>
<label for="create-inf-uses" class="button ~neutral @low" title="Set uses to infinite">
<span>&infin;</span>
<input type="checkbox" class="unfocused" id="create-inf-uses" aria-label="Set uses to infinite">
</label>
</div>
<p class="support unfocused my-2" id="create-inf-uses-warning"><span class="badge ~critical">{{ .strings.warning }}</span> {{ .strings.inviteInfiniteUsesWarning }}</p>
<label class="label supra">{{ .strings.profile }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="create-profile">
</select>
</div>
<div id="create-send-to-container">
<label class="label supra">{{ .strings.inviteSendToEmail }}</label>
<div class="flex-expand mb-2 mt-4">
{{ if .discordEnabled }}
<input type="text" id="create-send-to" class="input ~neutral @low mr-2" placeholder="example@example.com | user#1234">
<span id="create-send-to-search" class="button ~neutral @low mr-2">
<i class="icon ri-search-2-line" title="{{ .strings.search }}"></i>
</span>
{{ else }}
<input type="email" id="create-send-to" class="input ~neutral @low mr-2" placeholder="example@example.com">
{{ end }}
<label for="create-send-to-enabled" class="button ~neutral @low">
<input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled">
</label>
<div class="card ~neutral @low flex flex-col justify-between gap-2 grow">
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-4">
<label class="label supra" for="create-uses">{{ .strings.inviteNumberOfUses }}</label>
<div class="flex flex-row gap-2">
<input type="number" min="0" id="create-uses" class="input ~neutral @low" value=1>
<label for="create-inf-uses" class="button ~neutral @low" title="Set uses to infinite">
<span>&infin;</span>
<input type="checkbox" class="unfocused" id="create-inf-uses" aria-label="Set uses to infinite">
</label>
</div>
</div>
<p class="support unfocused" id="create-inf-uses-warning"><span class="badge ~critical">{{ .strings.warning }}</span> {{ .strings.inviteInfiniteUsesWarning }}</p>
<div class="flex flex-col gap-4">
<label class="label supra">{{ .strings.profile }}</label>
<div class="select ~neutral @low">
<select id="create-profile">
</select>
</div>
</div>
<div id="create-send-to-container" class="flex flex-col gap-4">
<label class="label supra">{{ .strings.inviteSendToEmail }}</label>
<div class="flex flex-row gap-2">
{{ if .discordEnabled }}
<input type="text" id="create-send-to" class="input ~neutral @low" placeholder="example@example.com | user#1234">
<span id="create-send-to-search" class="button ~neutral @low">
<i class="icon ri-search-2-line" title="{{ .strings.search }}"></i>
</span>
{{ else }}
<input type="email" id="create-send-to" class="input ~neutral @low" placeholder="example@example.com">
{{ end }}
<label for="create-send-to-enabled" class="button ~neutral @low">
<input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled">
</label>
</div>
</div>
</div>
<span class="button ~urge @low supra full-width center lg" id="create-submit">{{ .strings.create }}</span>
<div>
<span class="button ~urge @low supra full-width center lg" id="create-submit">{{ .strings.create }}</span>
</div>
</div>
</div>
</div>
</div>
<div id="tab-accounts" class="unfocused">
<div class="card @low dark:~d_neutral accounts mb-4 overflow-visible">
<div class="flex-expand align-middle">
<span class="text-3xl font-bold mr-4">{{ .strings.accounts }}</span>
<div id="accounts-filter-dropdown" class="dropdown z-10" tabindex="0">
<span class="h-100 button ~neutral @low center" id="accounts-filter-button">{{ .strings.filters }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low mt-2" id="accounts-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</div>
<div id="accounts-filter-dropdown" class="dropdown manual z-10 w-full">
<div class="flex flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
<span class="text-3xl font-bold mr-4">{{ .strings.accounts }}</span>
<span class="dropdown-manual-toggle"><button class="h-full button ~neutral @low center" id="accounts-filter-button" tabindex="0">{{ .strings.filters }}</button></span>
</div>
<div class="flex flex-row align-middle w-full">
<input type="search" class="field ~neutral @low input search mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
</div>
</div>
<div class="dropdown-display max-w-full">
<div class="card ~neutral @low mt-2 overflow-x-scroll" id="accounts-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</div>
</div>
<input type="search" class="field ~neutral @low input search ml-2 mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center -ml-8" id="accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
</div>
<div class="supra py-1 sm hidden" id="accounts-search-options-header">{{ .strings.searchOptions }}</div>
<div class="row -mx-2">
<button type="button" class="button ~neutral @low center m-2 hidden"><span id="accounts-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
<div class="row -mx-2 mb-2">
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="accounts-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
<span id="accounts-filter-area"></span>
</div>
<div class="supra py-1 sm">{{ .strings.actions }}</div>
<div class="row -mx-2">
<span class="col button ~neutral @low center max-w-[20%]" id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
<div id="accounts-announce-dropdown" class="col dropdown pb-0i max-w-[20%]" tabindex="0">
<span class="w-100 button ~info @low center" id="accounts-announce">{{ .strings.announce }}</span>
<div class="supra pt-1 pb-2 sm">{{ .strings.actions }}</div>
<div class="flex flex-row flex-wrap gap-3 mb-4">
<span class="button ~neutral @low center " id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
<div id="accounts-announce-dropdown" class="dropdown pb-0i " tabindex="0">
<span class="w-full button ~info @low center items-baseline" id="accounts-announce">{{ .strings.announce }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<span class="supra sm">{{ .strings.templates }}</span>
@@ -605,18 +774,29 @@
</div>
</div>
</div>
<span class="col button ~urge @low center max-w-[20%]" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
<span class="col button ~warning @low center max-w-[20%]" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
<div id="accounts-disable-enable-dropdown" class="col dropdown manual pb-0i max-w-[20%]" tabindex="0">
<span class="w-100 button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span>
<span class="button ~urge @low center " id="accounts-modify-user">{{ .strings.modifySettings }}</span>
{{ if .referralsEnabled }}
<span class="button ~urge @low center " id="accounts-enable-referrals">{{ .strings.enableReferrals }}</span>
{{ end }}
<div id="accounts-expiry-dropdown" class="dropdown pb-0i " tabindex="0">
<span class="w-full button ~positive @low center items-baseline" id="accounts-expiry-dropdown-button">{{ .strings.expiry }} <i class="ri-arrow-down-s-line ml-2"></i></span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<span class="button ~warning full-width @low center" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
<span class="button ~critical full-width @low center mt-2" id="accounts-remove-expiry">{{ .strings.removeExpiry }}</span>
</div>
</div>
</div>
<div id="accounts-disable-enable-dropdown" class="dropdown manual pb-0i " tabindex="0">
<span class="w-full button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<span class="button ~urge full-width accounts-announce-template-button" id="accounts-enable-expiry">{{ .strings.setExpiry }}</span>
</div>
</div>
</div>
<span class="col button ~info @low center unfocused max-w-[20%]" id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
<span class="col button ~critical @low center max-w-[20%]" id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
<span class="button ~info @low center unfocused " id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
<span class="button ~critical @low center " id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
</div>
<div class="card @low accounts-header table-responsive mt-2">
<table class="table text-base leading-4">
@@ -637,38 +817,121 @@
{{ if .discordEnabled }}
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-discord">Discord</th>
{{ end }}
{{ if .referralsEnabled }}
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-referrals">{{ .strings.referrals }}</th>
{{ end }}
<th class="grid gap-4 place-items-stretch accounts-header-expiry">{{ .strings.expiry }}</th>
<th class="grid gap-4 place-items-stretch accounts-header-last-active">{{ .strings.lastActiveTime }}</th>
</tr>
</thead>
<tbody id="accounts-list"></tbody>
</table>
<div class="unfocused h-[100%] my-3" id="accounts-not-found">
<div class="flex flex-col h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span>
<button class="button ~neutral @low accounts-search-clear">
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<div id="tab-activity" class="unfocused">
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible">
<div id="activity-filter-dropdown" class="dropdown manual z-10 w-full" tabindex="0">
<div class="flex flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
<span class="text-3xl font-bold mr-4">{{ .strings.activity }}</span>
<div class="flex flex-row align-middle">
<span class="dropdown-manual-toggle"><button class="h-full button ~neutral @low center" id="activity-filter-button">{{ .strings.filters }}</button></span>
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
</div>
</div>
<div class="flex flex-row align-middle w-full">
<input type="search" class="field ~neutral @low input search mr-2" id="activity-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<button class="button ~info @low ml-2" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
</div>
</div>
<div class="dropdown-display max-w-full">
<div class="card ~neutral @low mt-2 overflow-x-scroll" id="activity-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</div>
</div>
</div>
<div class="flex flex-row justify-between pt-3 pb-2">
<div class="supra sm hidden" id="activity-search-options-header">{{ .strings.searchOptions }}</div>
<div class="supra sm flex flex-row gap-2">
<span id="activity-total-records"></span>
<span id="activity-loaded-records"></span>
<span id="activity-shown-records"></span>
</div>
</div>
<div class="row -mx-2 mb-2">
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="activity-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
<span id="activity-filter-area"></span>
</div>
<div class="my-2">
<div id="activity-card-list"></div>
<div id="activity-loader"></div>
<div class="unfocused h-[100%] my-3" id="activity-not-found">
<div class="flex flex-col h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span>
<span class="text-xl font-medium italic mb-3 unfocused" id="activity-keep-searching-description">{{ .strings.keepSearchingDescription }}</span>
<div class="flex flex-row">
<button class="button ~neutral @low activity-search-clear">
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
<button class="button ~neutral @low unfocused" id="activity-keep-searching">{{ .strings.keepSearching }}</button>
</div>
</div>
</div>
<div class="flex justify-center">
<button class="button m-2 ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button>
<button class="button m-2 ~neutral @low" id="activity-load-all">{{ .strings.loadAll }}</button>
</div>
</div>
</div>
</div>
<div id="tab-settings" class="unfocused">
<div class="card @low dark:~d_neutral settings overflow">
<div class="flex-expand">
<div class="flex-row">
<div class="flex flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
<span class="heading">{{ .strings.settings }}</span>
<label for="settings-advanced-enabled" class="button ~neutral @low ml-2 my-2">
<label for="settings-advanced-enabled" class="button ~neutral @low ml-2">
<input type="checkbox" id="settings-advanced-enabled" aria-label="Advanced settings enabled">
<span class="ml-2">{{ .strings.advancedSettings }} </span>
</label>
</div>
<div>
<span class="button ~info @low my-1" id="settings-logs">{{ .strings.logs }}</span>
<span class="button ~neutral @low my-1" id="settings-restart">{{ .strings.settingsRestart }}</span>
<span class="button ~urge @low unfocused my-1" id="settings-save">{{ .strings.settingsSave }}</span>
<div class="flex flex-row justify-start md:justify-end gap-2 w-full">
<span class="button ~neutral @low" id="settings-logs">{{ .strings.logs }}</span>
<span class="button ~info @low" id="settings-backups">{{ .strings.backups }}</span>
<span class="button ~neutral @low" id="settings-restart">{{ .strings.settingsRestart }}</span>
<span class="button ~urge @low unfocused" id="settings-save">{{ .strings.settingsSave }}</span>
</div>
</div>
<div class="flex flex-col md:flex-row gap-3">
<div class="card @low dark:~d_neutral col" id="settings-sidebar">
<div class="flex flex-row justify-between">
<input type="search" class="field ~neutral @low input settings-section-button justify-between mb-2" id="settings-search" placeholder="{{ .strings.search }}">
<button class="button ~neutral @low center -ml-10 rounded-s-none mb-2 settings-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></button>
</div>
<aside class="aside sm ~urge dark:~d_info mb-2 @low" id="settings-message">Note: <span class="badge ~critical">*</span> indicates a required field, <span class="badge ~info dark:~d_warning">R</span> indicates changes require a restart.</aside>
<span class="button ~neutral @low settings-section-button justify-between mb-2" id="setting-about"><span class="flex">{{ .strings.aboutProgram }} <i class="ri-information-line ml-2"></i></span></span>
<span class="button ~neutral @low settings-section-button justify-between mb-2" id="setting-profiles"><span class="flex">{{ .strings.userProfiles }} <i class="ri-user-line ml-2"></i></span></span>
</div>
<div class="card ~neutral @low col overflow" id="settings-panel"></div>
<div class="card ~neutral @low col overflow" id="settings-panel">
<div class="settings-section unfocused h-[100%]" id="settings-not-found">
<div class="flex flex-col h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-2">{{ .strings.noResultsFound }}</span>
<span class="mb-2 px-12 text-center">{{ .strings.settingsMaybeUnderAdvanced }}</span>
<button class="button ~neutral @low settings-search-clear">
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -18,7 +18,7 @@
<a class="button ~critical mb-4" target="_blank" href="https://github.com/hrfee/jfa-go/issues/new/choose">Create an Issue</a>
</section>
<section class="section ~neutral @low">
<div class="flex-expand">
<div class="flex flex-row justify-between">
<span class="subheading">Full Log</span>
<span class="button ~urge ml-4" id="copy-log">Copy</span>
</div>

View File

@@ -31,11 +31,20 @@
window.reCAPTCHASiteKey = "{{ .reCAPTCHASiteKey }}";
window.userPageEnabled = {{ .userPageEnabled }};
window.userPageAddress = "{{ .userPageAddress }}";
{{ if index . "customSuccessCard" }}
window.customSuccessCard = {{ .customSuccessCard }};
{{ else }}
window.customSuccessCard = false;
{{ end }}
</script>
{{ if .passwordReset }}
<script src="js/pwr.js" type="module"></script>
<script>
window.pwrPIN = "{{ .pwrPIN }}";
</script>
{{ else }}
<script src="js/form.js" type="module"></script>
{{ end }}
{{ if .reCAPTCHA }}
<script>
var reCAPTCHACallback = () => {
@@ -49,4 +58,3 @@
<script src="https://www.google.com/recaptcha/api.js?onload=reCAPTCHACallback&render=explicit" async defer></script>
{{ end }}
{{ end }}
{{ end }}

View File

@@ -14,12 +14,19 @@
</head>
<body class="max-w-full overflow-x-hidden section">
<div id="modal-success" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ if .passwordReset }}{{ .strings.passwordReset }}{{ else }}{{ .strings.successHeader }}{{ end }}</span>
<p class="content mb-4">{{ if .passwordReset }}{{ .strings.youCanLoginPassword }}{{ else }}{{ .successMessage }}{{ end }}</p>
{{ if .userPageEnabled }}<p class="content mb-4" id="modal-success-user-page-area" my-account-term="{{ .strings.myAccount }}">{{ .strings.userPageSuccessMessage }}</p>{{ end }}
<a class="button ~urge @low full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a>
</div>
{{ if .customSuccessCard }}
<div class="card @low dark:~d_neutral content break-words relative mx-auto my-[10%] w-4/5 lg:w-1/3">
{{ .customSuccessCardContent }}
<a class="button ~urge @low full-width center supra submit my-2" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a>
</div>
{{ else }}
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ if .passwordReset }}{{ .strings.passwordReset }}{{ else }}{{ .strings.successHeader }}{{ end }}</span>
<p class="content mb-4">{{ if .passwordReset }}{{ .strings.youCanLoginPassword }}{{ else }}{{ .successMessage }}{{ end }}</p>
{{ if .userPageEnabled }}<p class="content mb-4" id="modal-success-user-page-area" my-account-term="{{ .strings.myAccount }}">{{ .strings.userPageSuccessMessage }}</p>{{ end }}
<a class="button ~urge @low full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a>
</div>
{{ end }}
</div>
<div id="modal-confirmation" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
@@ -43,7 +50,7 @@
<div id="notification-box"></div>
<div class="page-container">
<div class="card dark:~d_neutral @low">
<div class="flex flex-col md:flex-row gap-3 inline align-baseline">
<div class="flex flex-col md:flex-row gap-3 items-baseline mb-2">
<span class="heading mr-5">
{{ if .passwordReset }}
{{ .strings.passwordReset }}
@@ -53,9 +60,9 @@
</span>
<span class="subheading">
{{ if .passwordReset }}
{{ .strings.enterYourPassword }}
{{ .strings.enterYourPassword }}
{{ else }}
{{ .helpMessage }}
{{ .helpMessage }}
{{ end }}
</span>
</div>
@@ -123,6 +130,9 @@
</form>
</div>
<div class="flex-initial">
{{ if .fromUser }}
<aside class="col aside sm ~positive mb-4" id="invite-from-user" data-from="{{ .fromUser }}">{{ .strings.invitedBy }}</aside>
{{ end }}
<div class="card ~neutral @low mb-4">
<span class="label supra">{{ .strings.passwordRequirementsHeader }}</span>
<ul>
@@ -143,7 +153,7 @@
</div>
{{ end }}
{{ if .contactMessage }}
<aside class="col aside sm ~info mt-4">{{ .contactMessage }}</aside>
<aside class="col aside sm ~info mt-4">{{ .contactMessage }}</aside>
{{ end }}
</div>
</div>

View File

@@ -2,6 +2,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="Description" content="jfa-go, a better way to manage Jellyfin users.">
<meta name="color-scheme" content="dark light">
<meta name="robots" content="noindex">
<link rel="apple-touch-icon" sizes="180x180" href="{{ .urlBase }}/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="{{ .urlBase }}/favicon-32x32.png">

View File

@@ -1,13 +1,25 @@
<span class="lg:w-[55%]"></span> <!-- the if statement around the 55% width below messes up tailwind, so we force include it here --!>
<div id="modal-login" class="modal">
<div class="my-[10%] row items-stretch relative mx-auto w-[40%] lg:w-[60%]">
<div class="my-[10%] row items-stretch relative mx-auto w-11/12 sm:w-4/5 lg:w-1/2">
{{ $hasTwoCards := 0 }}
{{ if index . "LoginMessageEnabled" }}
{{ if .LoginMessageEnabled }}
<div class="card mx-2 flex-initial w-[100%] xl:w-[35%] mb-4 xl:mb-0 dark:~d_neutral @low content">
{{ $hasTwoCards = 1 }}
<div class="card mx-2 flex-initial w-full lg:w-[35%] mb-4 lg:mb-0 dark:~d_neutral @low content">
{{ .LoginMessageContent }}
</div>
{{ end }}
{{ end }}
<form class="card mx-2 flex-auto form-login w-[100%] xl:w-[55%] mb-0" href="">
{{ if index . "userPageEnabled" }}
{{ if and .userPageEnabled .showUserPageLink }}
{{ $hasTwoCards = 1 }}
<div class="card mx-2 flex-initial w-full lg:w-[35%] mb-4 lg:mb-0 dark:~d_neutral @low content">
<span class="heading row">{{ .strings.loginNotAdmin }}</span>
<a class="button ~info h-12 w-full" href="{{ .urlBase }}/my/account"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
</div>
{{ end }}
{{ end }}
<form class="card mx-2 form-login w-full {{ if eq $hasTwoCards 1 }}lg:w-[55%]{{ end }} mb-0" href="">
<span class="heading">{{ .strings.login }}</span>
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.username }}" id="login-user">
<input type="password" class="field input ~neutral @high mb-4" placeholder="{{ .strings.password }}" id="login-password">

View File

@@ -35,7 +35,7 @@
<aside class="aside ~warning">
{{ .strings.changeYourPassword }}
</aside>
<span class="button ~urge @low w-100 text-center text-xl p-1 mt-4" id="pin" title="{{ .strings.copy }}">{{ .pin }}</span>
<span class="button ~urge @low w-full text-center text-xl p-1 mt-4" id="pin" title="{{ .strings.copy }}">{{ .pin }}</span>
{{ end }}
</div>
<i class="content">{{ .contactMessage }}</i>

View File

@@ -30,7 +30,7 @@
<div class="row col flex center">
<p class="content my-2">{{ .lang.StartPage.pressStart }}</p>
</div>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="support">{{ .lang.StartPage.httpsNotice }}</span>
<span class="button ~urge @low next">{{ .lang.StartPage.start }}</span>
</section>
@@ -59,7 +59,7 @@
</select>
</div>
</label>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</section>
@@ -122,9 +122,35 @@
</select>
</div>
</label>
<span class="heading">{{ .lang.Proxy.title }}</span>
<p class="content my-2" id="proxy-description">{{ .lang.Proxy.description }}</p>
<label class="row switch pb-4">
<input type="checkbox" class="mr-2" id="advanced-proxy"><span>{{ .lang.Strings.enabled }}</span>
</label>
<label class="label">
<span>{{ .lang.Proxy.protocol }}</span>
<div class="select ~neutral @low mt-4 mb-2">
<select id="advanced-proxy_protocol">
<option value="http">HTTP</option>
<option value="socks">SOCKS5</option>
</select>
</div>
</label>
<label class="label">
<span class="mt-4">{{ .lang.Proxy.address }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="advanced-proxy_address">
</label>
<label class="label">
<span class="mt-4">{{ .lang.Strings.username }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="advanced-proxy_user">
</label>
<label class="label">
<span class="mt-4">{{ .lang.Strings.password }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="advanced-proxy_password">
</label>
</div>
</div>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</section>
@@ -146,6 +172,7 @@
<label class="row switch pb-4">
<input type="radio" class="mr-2" name="ui-jellyfin_login" value="false"><span>{{ .lang.Login.authorizeManual }}</span>
</label>
<p class="support pb-4 pl-4 mt-1">{{ .lang.Login.authorizeManualUserPageNotice }}</p>
</div>
<div id="login-manual">
<label class="label">
@@ -162,7 +189,7 @@
<span class="support mb-2 mt-1">{{ .lang.Login.emailNotice }}</span>
</label>
</div>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</section>
@@ -208,7 +235,7 @@
</label>
</div>
</div>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low" id="jellyfin-test-connection">{{ .lang.JellyfinEmby.testConnection }}</span>
@@ -231,7 +258,22 @@
<input type="text" class="input ~neutral @low mt-4" id="ombi-api_key">
<p class="support mb-2 mt-1">{{ .lang.Ombi.apiKeyNotice }}</p>
</label>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</div>
</section>
</div>
<div class="card ~neutral @low mb-2 unfocused">
<span class="heading">{{ .lang.UserPage.title }}</span>
<p class="content my-2">{{ .lang.UserPage.description }}</p>
<p class="content my-2">{{ .lang.UserPage.customizeMessages }}</p>
<label class="row switch pb-4">
<input type="checkbox" class="mr-2" id="userpage-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label>
<p class="support mb-1 mt-1">{{ .lang.UserPage.requiredSettings }}</p>
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@@ -328,7 +370,7 @@
</div>
</div>
</div>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@@ -350,7 +392,7 @@
<span class="mt-4">{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="welcome_email-subject" placeholder="{{ .emailLang.WelcomeEmail.title }}">
</label>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@@ -371,7 +413,7 @@
<span class="mt-4">{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="invite_emails-subject" placeholder="{{ .emailLang.InviteEmail.title }}">
</label>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@@ -391,7 +433,7 @@
</label>
<label class="switch">
<input type="checkbox" class="mr-2" id="password_resets-link_reset"><span>{{ .lang.PasswordResets.resetLinks }}</span>
<p class="support mb-2 mt-1">{{ .lang.PasswordResets.resetLinksNotice }}</p>
<p class="support mb-2 mt-1">{{ .lang.PasswordResets.resetLinksNotice }} {{ .lang.PasswordResets.resetLinksRequiredForUserPage }}</p>
</label>
<label class="switch">
<input type="checkbox" class="mr-2" id="password_resets-set_password"><span>{{ .lang.PasswordResets.setPassword }}</span>
@@ -408,7 +450,7 @@
<span class="mt-4">{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="password_resets-subject" placeholder="{{ .emailLang.PasswordReset.title }}">
</label>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@@ -441,7 +483,7 @@
<span class="mt-4">{{ .lang.PasswordValidation.special }}</span>
<input type="number" class="input ~neutral @low mt-4 mb-2" id="password_validation-special" value="0">
</label>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@@ -471,7 +513,7 @@
<input type="text" class="input ~neutral @low mt-4" id="email-message">
<p class="support mb-2 mt-1">{{ .lang.HelpMessages.emailMessageNotice }}</p>
</label>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>

View File

@@ -24,6 +24,7 @@
window.matrixRequired = {{ .matrixRequired }};
window.matrixUserID = "{{ .matrixUser }}";
window.validationStrings = JSON.parse({{ .validationStrings }});
window.referralsEnabled = {{ .referralsEnabled }};
</script>
{{ template "header.html" . }}
<title>{{ .strings.myAccount }}</title>
@@ -47,11 +48,17 @@
</div>
{{ if .pwrEnabled }}
<div id="modal-pwr" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<div class="card content relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.resetPassword }}</span>
<p class="content my-2">
{{ if .linkResetEnabled }}
{{ .strings.resetPasswordThroughLink }}
{{ .strings.resetPasswordThroughLinkStart }}
<ul class="content">
{{ if .resetPasswordUsername }}<li>{{ .strings.resetPasswordUsername }}</li>{{ end }}
{{ if .resetPasswordEmail }}<li>{{ .strings.resetPasswordEmail }}</li>{{ end }}
{{ if .resetPasswordContactMethod }}<li>{{ .strings.resetPasswordContactMethod }}</li>{{ end }}
</ul>
{{ .strings.resetPasswordThroughLinkEnd }}
{{ else }}
{{ .strings.resetPasswordThroughJellyfin }}
{{ end }}
@@ -102,10 +109,10 @@
<div class="card @low dark:~d_neutral mb-4" id="card-user">
<span class="heading mb-2"></span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="columns-1 sm:columns-2 gap-4" id="user-cardlist">
{{ if index . "PageMessageEnabled" }}
{{ if .PageMessageEnabled }}
<div class="card @low dark:~d_neutral content" id="card-message">
<div class="card @low dark:~d_neutral content break-words" id="card-message">
{{ .PageMessageContent }}
</div>
{{ end }}
@@ -150,6 +157,20 @@
<div class="user-expiry-countdown"></div>
</div>
</div>
{{ if .referralsEnabled }}
<div>
<div class="card @low dark:~d_neutral unfocused" id="card-referrals">
<span class="heading mb-2">{{ .strings.referrals }}</span>
<aside class="aside ~neutral my-4 col user-referrals-description"></aside>
<div class="flex flex-row justify-between gap-2">
<div class="user-referrals-info"></div>
<div class="grid my-2">
<button type="button" class="user-referrals-button button ~info dark:~d_info @low" title="Copy">{{ .strings.copyReferral }}<i class="ri-file-copy-line ml-2"></i></button>
</div>
</div>
</div>
</div>
{{ end }}
</div>
</div>
<script src="{{ .urlBase }}/js/user.js" type="module"></script>

View File

@@ -1,6 +1,9 @@
# Images
This holds any images on the main README, and the base files for the icons and banner. The font used, like Jellyfin, is [Quicksand](https://fonts.google.com/specimen/Quicksand) by Andrew Paglinawan.
This holds any images on the main README, and the base files for the icons and banner. The font used pre-0.5.0, like Jellyfin, is [Quicksand](https://fonts.google.com/specimen/Quicksand) by Andrew Paglinawan. These old versions are prefixed with `-quicksand` in `src/`.
Post-0.5.0, the font used is Hanken Grotesk, available under SIL OFL 1.1 License.
https://scripts.sil.org/cms/scripts/page.php?item_id=OFL_web
"Go" text logo and Gopher image: Copyright 2018 The Go Authors. All rights reserved.
https://creativecommons.org/licenses/by/3.0/legalcode

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 523 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 38 KiB

BIN
images/myaccount.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 58 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 91 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 59 KiB

7
jellyseerr/go.mod Normal file
View File

@@ -0,0 +1,7 @@
module github.com/hrfee/jfa-go/jellyseerr
replace github.com/hrfee/jfa-go/common => ../common
go 1.18
require github.com/hrfee/jfa-go/common v0.0.0-20240728190513-dabef831d769 // indirect

460
jellyseerr/jellyseerr.go Normal file
View File

@@ -0,0 +1,460 @@
package jellyseerr
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/hrfee/jfa-go/common"
)
const (
API_SUFFIX = "/api/v1"
BogusIdentifier = "123412341234123456"
)
// Jellyseerr represents a running Jellyseerr instance.
type Jellyseerr struct {
server, key string
header map[string]string
httpClient *http.Client
userCache map[string]User // Map of jellyfin IDs to users
cacheExpiry time.Time
cacheLength time.Duration
timeoutHandler common.TimeoutHandler
LogRequestBodies bool
AutoImportUsers bool
}
// NewJellyseerr returns an Ombi object.
func NewJellyseerr(server, key string, timeoutHandler common.TimeoutHandler) *Jellyseerr {
if !strings.HasSuffix(server, API_SUFFIX) {
server = server + API_SUFFIX
}
return &Jellyseerr{
server: server,
key: key,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
header: map[string]string{
"X-Api-Key": key,
},
cacheLength: time.Duration(30) * time.Minute,
cacheExpiry: time.Now(),
timeoutHandler: timeoutHandler,
userCache: map[string]User{},
LogRequestBodies: false,
}
}
func (js *Jellyseerr) req(mode string, uri string, data any, queryParams url.Values, headers map[string]string, response bool) (string, int, error) {
var params []byte
if data != nil {
params, _ = json.Marshal(data)
}
if js.LogRequestBodies {
fmt.Printf("Jellyseerr API Client: Sending Data \"%s\" to \"%s\"\n", string(params), uri)
}
if qp := queryParams.Encode(); qp != "" {
uri += "?" + qp
}
var req *http.Request
if data != nil {
req, _ = http.NewRequest(mode, uri, bytes.NewBuffer(params))
} else {
req, _ = http.NewRequest(mode, uri, nil)
}
req.Header.Add("Content-Type", "application/json")
for name, value := range js.header {
req.Header.Add(name, value)
}
if headers != nil {
for name, value := range headers {
req.Header.Add(name, value)
}
}
resp, err := js.httpClient.Do(req)
reqFailed := err != nil || !(resp.StatusCode == 200 || resp.StatusCode == 201)
defer js.timeoutHandler()
var responseText string
defer resp.Body.Close()
if response || reqFailed {
responseText, err = js.decodeResp(resp)
if err != nil {
return responseText, resp.StatusCode, err
}
}
if reqFailed {
var msg ErrorDTO
err = json.Unmarshal([]byte(responseText), &msg)
if err != nil {
return responseText, resp.StatusCode, err
}
if msg.Message == "" {
err = fmt.Errorf("failed (error %d)", resp.StatusCode)
} else {
err = fmt.Errorf("got %d: %s", resp.StatusCode, msg.Message)
}
return responseText, resp.StatusCode, err
}
return responseText, resp.StatusCode, err
}
func (js *Jellyseerr) decodeResp(resp *http.Response) (string, error) {
var out io.Reader
switch resp.Header.Get("Content-Encoding") {
case "gzip":
out, _ = gzip.NewReader(resp.Body)
default:
out = resp.Body
}
buf := new(strings.Builder)
_, err := io.Copy(buf, out)
if err != nil {
return "", err
}
return buf.String(), nil
}
func (js *Jellyseerr) get(uri string, data any, params url.Values) (string, int, error) {
return js.req(http.MethodGet, uri, data, params, nil, true)
}
func (js *Jellyseerr) post(uri string, data any, response bool) (string, int, error) {
return js.req(http.MethodPost, uri, data, url.Values{}, nil, response)
}
func (js *Jellyseerr) put(uri string, data any, response bool) (string, int, error) {
return js.req(http.MethodPut, uri, data, url.Values{}, nil, response)
}
func (js *Jellyseerr) delete(uri string, data any) (int, error) {
_, status, err := js.req(http.MethodDelete, uri, data, url.Values{}, nil, false)
return status, err
}
func (js *Jellyseerr) ImportFromJellyfin(jfIDs ...string) ([]User, error) {
params := map[string]interface{}{
"jellyfinUserIds": jfIDs,
}
resp, status, err := js.post(js.server+"/user/import-from-jellyfin", params, true)
var data []User
if err != nil {
return data, err
}
if status != 200 && status != 201 {
return data, fmt.Errorf("failed (error %d)", status)
}
err = json.Unmarshal([]byte(resp), &data)
for _, u := range data {
if u.JellyfinUserID != "" {
js.userCache[u.JellyfinUserID] = u
}
}
return data, err
}
func (js *Jellyseerr) getUsers() error {
if js.cacheExpiry.After(time.Now()) {
return nil
}
js.cacheExpiry = time.Now().Add(js.cacheLength)
pageCount := 1
pageIndex := 0
for {
res, err := js.getUserPage(pageIndex)
if err != nil {
return err
}
for _, u := range res.Results {
if u.JellyfinUserID == "" {
continue
}
js.userCache[u.JellyfinUserID] = u
}
pageCount = res.Page.Pages
pageIndex++
if pageIndex >= pageCount {
break
}
}
return nil
}
func (js *Jellyseerr) getUserPage(page int) (GetUsersDTO, error) {
params := url.Values{}
params.Add("take", "30")
params.Add("skip", strconv.Itoa(page*30))
params.Add("sort", "created")
if js.LogRequestBodies {
fmt.Printf("Jellyseerr API Client: Sending with URL params \"%+v\"\n", params)
}
resp, status, err := js.get(js.server+"/user", nil, params)
var data GetUsersDTO
if status != 200 {
return data, fmt.Errorf("failed (error %d)", status)
}
if err != nil {
return data, err
}
err = json.Unmarshal([]byte(resp), &data)
return data, err
}
func (js *Jellyseerr) MustGetUser(jfID string) (User, error) {
u, _, err := js.GetOrImportUser(jfID)
return u, err
}
// GetImportedUser provides the same function as ImportFromJellyfin, but will always return the user,
// even if they already existed. Also returns whether the user was imported or not,
func (js *Jellyseerr) GetOrImportUser(jfID string) (u User, imported bool, err error) {
imported = false
u, err = js.GetExistingUser(jfID)
if err == nil {
return
}
var users []User
users, err = js.ImportFromJellyfin(jfID)
if err != nil {
return
}
if len(users) != 0 {
u = users[0]
err = nil
return
}
err = fmt.Errorf("user not found or imported")
return
}
func (js *Jellyseerr) GetExistingUser(jfID string) (u User, err error) {
js.getUsers()
ok := false
err = nil
if u, ok = js.userCache[jfID]; ok {
return
}
js.cacheExpiry = time.Now()
js.getUsers()
if u, ok = js.userCache[jfID]; ok {
err = nil
return
}
err = fmt.Errorf("user not found")
return
}
func (js *Jellyseerr) getUser(jfID string) (User, error) {
if js.AutoImportUsers {
return js.MustGetUser(jfID)
}
return js.GetExistingUser(jfID)
}
func (js *Jellyseerr) Me() (User, error) {
resp, status, err := js.get(js.server+"/auth/me", nil, url.Values{})
var data User
data.ID = -1
if status != 200 {
return data, fmt.Errorf("failed (error %d)", status)
}
if err != nil {
return data, err
}
err = json.Unmarshal([]byte(resp), &data)
return data, err
}
func (js *Jellyseerr) GetPermissions(jfID string) (Permissions, error) {
data := permissionsDTO{Permissions: -1}
u, err := js.getUser(jfID)
if err != nil {
return data.Permissions, err
}
resp, status, err := js.get(fmt.Sprintf(js.server+"/user/%d/settings/permissions", u.ID), nil, url.Values{})
if err != nil {
return data.Permissions, err
}
if status != 200 {
return data.Permissions, fmt.Errorf("failed (error %d)", status)
}
err = json.Unmarshal([]byte(resp), &data)
return data.Permissions, err
}
func (js *Jellyseerr) SetPermissions(jfID string, perm Permissions) error {
u, err := js.getUser(jfID)
if err != nil {
return err
}
_, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/permissions", u.ID), permissionsDTO{Permissions: perm}, false)
if err != nil {
return err
}
if status != 200 && status != 201 {
return fmt.Errorf("failed (error %d)", status)
}
u.Permissions = perm
js.userCache[jfID] = u
return nil
}
func (js *Jellyseerr) ApplyTemplateToUser(jfID string, tmpl UserTemplate) error {
u, err := js.getUser(jfID)
if err != nil {
return err
}
_, status, err := js.put(fmt.Sprintf(js.server+"/user/%d", u.ID), tmpl, false)
if err != nil {
return err
}
if status != 200 && status != 201 {
return fmt.Errorf("failed (error %d)", status)
}
u.UserTemplate = tmpl
js.userCache[jfID] = u
return nil
}
func (js *Jellyseerr) ModifyUser(jfID string, conf map[UserField]any) error {
if _, ok := conf[FieldEmail]; ok {
return fmt.Errorf("email is read only, set with ModifyMainUserSettings instead")
}
u, err := js.getUser(jfID)
if err != nil {
return err
}
_, status, err := js.put(fmt.Sprintf(js.server+"/user/%d", u.ID), conf, false)
if err != nil {
return err
}
if status != 200 && status != 201 {
return fmt.Errorf("failed (error %d)", status)
}
// Lazily just invalidate the cache.
js.cacheExpiry = time.Now()
return nil
}
func (js *Jellyseerr) DeleteUser(jfID string) error {
u, err := js.getUser(jfID)
if err != nil {
return err
}
status, err := js.delete(fmt.Sprintf(js.server+"/user/%d", u.ID), nil)
if status != 200 && status != 201 {
return fmt.Errorf("failed (error %d)", status)
}
if err != nil {
return err
}
delete(js.userCache, jfID)
return err
}
func (js *Jellyseerr) GetNotificationPreferences(jfID string) (Notifications, error) {
u, err := js.getUser(jfID)
if err != nil {
return Notifications{}, err
}
return js.GetNotificationPreferencesByID(u.ID)
}
func (js *Jellyseerr) GetNotificationPreferencesByID(jellyseerrID int64) (Notifications, error) {
var data Notifications
resp, status, err := js.get(fmt.Sprintf(js.server+"/user/%d/settings/notifications", jellyseerrID), nil, url.Values{})
if err != nil {
return data, err
}
if status != 200 {
return data, fmt.Errorf("failed (error %d)", status)
}
err = json.Unmarshal([]byte(resp), &data)
return data, err
}
func (js *Jellyseerr) ApplyNotificationsTemplateToUser(jfID string, tmpl NotificationsTemplate) error {
// This behaviour is not desired, this being all-zero means no notifications, which is a settings state we'd want to store!
/* if tmpl.NotifTypes.Empty() {
tmpl.NotifTypes = nil
}*/
u, err := js.getUser(jfID)
if err != nil {
return err
}
_, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/notifications", u.ID), tmpl, false)
if err != nil {
return err
}
if status != 200 && status != 201 {
return fmt.Errorf("failed (error %d)", status)
}
return nil
}
func (js *Jellyseerr) ModifyNotifications(jfID string, conf map[NotificationsField]any) error {
u, err := js.getUser(jfID)
if err != nil {
return err
}
_, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/notifications", u.ID), conf, false)
if err != nil {
return err
}
if status != 200 && status != 201 {
return fmt.Errorf("failed (error %d)", status)
}
return nil
}
func (js *Jellyseerr) GetUsers() (map[string]User, error) {
err := js.getUsers()
return js.userCache, err
}
func (js *Jellyseerr) UserByID(jellyseerrID int64) (User, error) {
resp, status, err := js.get(js.server+fmt.Sprintf("/user/%d", jellyseerrID), nil, url.Values{})
var data User
if status != 200 {
return data, fmt.Errorf("failed (error %d)", status)
}
if err != nil {
return data, err
}
err = json.Unmarshal([]byte(resp), &data)
return data, err
}
func (js *Jellyseerr) ModifyMainUserSettings(jfID string, conf MainUserSettings) error {
u, err := js.getUser(jfID)
if err != nil {
return err
}
_, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/main", u.ID), conf, false)
if err != nil {
return err
}
if status != 200 && status != 201 {
return fmt.Errorf("failed (error %d)", status)
}
// Lazily just invalidate the cache.
js.cacheExpiry = time.Now()
return nil
}

View File

@@ -0,0 +1,69 @@
package jellyseerr
import (
"testing"
"github.com/hrfee/jfa-go/common"
)
const (
API_KEY = "MTcyMjI2MDM2MTYyMzMxNDZkZmYyLTE4MzMtNDUyNy1hODJlLTI0MTZkZGUyMDg2Ng=="
URI = "http://localhost:5055"
PERM = 2097184
)
func client() *Jellyseerr {
return NewJellyseerr(URI, API_KEY, common.NewTimeoutHandler("Jellyseerr", URI, false))
}
func TestMe(t *testing.T) {
js := client()
u, err := js.Me()
if err != nil {
t.Fatalf("returned error %+v", err)
}
if u.ID < 0 {
t.Fatalf("returned no user %+v\n", u)
}
}
/* func TestImportFromJellyfin(t *testing.T) {
js := client()
list, err := js.ImportFromJellyfin("6b75e189efb744f583aa2e8e9cee41d3")
if err != nil {
t.Fatalf("returned error %+v", err)
}
if len(list) == 0 {
t.Fatalf("returned no users")
}
} */
func TestMustGetUser(t *testing.T) {
js := client()
u, err := js.MustGetUser("8c9d25c070d641cd8ad9cf825f622a16")
if err != nil {
t.Fatalf("returned error %+v", err)
}
if u.ID < 0 {
t.Fatalf("returned no users")
}
}
func TestSetPermissions(t *testing.T) {
js := client()
err := js.SetPermissions("6b75e189efb744f583aa2e8e9cee41d3", PERM)
if err != nil {
t.Fatalf("returned error %+v", err)
}
}
func TestGetPermissions(t *testing.T) {
js := client()
perm, err := js.GetPermissions("6b75e189efb744f583aa2e8e9cee41d3")
if err != nil {
t.Fatalf("returned error %+v", err)
}
if perm != PERM {
t.Fatalf("got unexpected perm code %d", perm)
}
}

136
jellyseerr/models.go Normal file
View File

@@ -0,0 +1,136 @@
package jellyseerr
import "time"
type UserField string
const (
FieldDisplayName UserField = "displayName"
FieldEmail UserField = "email"
)
type User struct {
UserTemplate // Note: You can set this with User.UserTemplate = value.
UserType int64 `json:"userType,omitempty"`
Warnings []any `json:"warnings,omitempty"`
ID int64 `json:"id,omitempty"`
Email string `json:"email,omitempty"`
PlexUsername string `json:"plexUsername,omitempty"`
JellyfinUsername string `json:"jellyfinUsername,omitempty"`
Username string `json:"username,omitempty"`
RecoveryLinkExpirationDate any `json:"recoveryLinkExpirationDate,omitempty"`
PlexID string `json:"plexId,omitempty"`
JellyfinUserID string `json:"jellyfinUserId,omitempty"`
JellyfinDeviceID string `json:"jellyfinDeviceId,omitempty"`
JellyfinAuthToken string `json:"jellyfinAuthToken,omitempty"`
PlexToken string `json:"plexToken,omitempty"`
Avatar string `json:"avatar,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
UpdatedAt time.Time `json:"updatedAt,omitempty"`
RequestCount int64 `json:"requestCount,omitempty"`
DisplayName string `json:"displayName,omitempty"`
}
func (u User) Name() string {
var n string
if u.Username != "" {
n = u.Username
} else if u.JellyfinUsername != "" {
n = u.JellyfinUsername
}
if u.DisplayName != "" {
n += " (" + u.DisplayName + ")"
}
return n
}
type UserTemplate struct {
Permissions Permissions `json:"permissions,omitempty"`
MovieQuotaLimit any `json:"movieQuotaLimit,omitempty"`
MovieQuotaDays any `json:"movieQuotaDays,omitempty"`
TvQuotaLimit any `json:"tvQuotaLimit,omitempty"`
TvQuotaDays any `json:"tvQuotaDays,omitempty"`
}
type PageInfo struct {
Pages int `json:"pages,omitempty"`
PageSize int `json:"pageSize,omitempty"`
Results int `json:"results,omitempty"`
Page int `json:"page,omitempty"`
}
type GetUsersDTO struct {
Page PageInfo `json:"pageInfo,omitempty"`
Results []User `json:"results,omitempty"`
}
type permissionsDTO struct {
Permissions Permissions `json:"permissions,omitempty"`
}
type Permissions int
type NotificationTypes struct {
Discord int64 `json:"discord"`
Email int64 `json:"email"`
Pushbullet int64 `json:"pushbullet"`
Pushover int64 `json:"pushover"`
Slack int64 `json:"slack"`
Telegram int64 `json:"telegram"`
Webhook int64 `json:"webhook"`
Webpush int64 `json:"webpush"`
}
/* func (nt *NotificationTypes) Empty() bool {
return nt.Discord == 0 && nt.Email == 0 && nt.Pushbullet == 0 && nt.Pushover == 0 && nt.Slack == 0 && nt.Telegram == 0 && nt.Webhook == 0 && nt.Webpush == 0
} */
type NotificationsField string
const (
FieldDiscord NotificationsField = "discordId"
FieldTelegram NotificationsField = "telegramChatId"
FieldEmailEnabled NotificationsField = "emailEnabled"
FieldDiscordEnabled NotificationsField = "discordEnabled"
FieldTelegramEnabled NotificationsField = "telegramEnabled"
)
type Notifications struct {
NotificationsTemplate
PgpKey any `json:"pgpKey,omitempty"`
DiscordID string `json:"discordId,omitempty"`
PushbulletAccessToken any `json:"pushbulletAccessToken,omitempty"`
PushoverApplicationToken any `json:"pushoverApplicationToken,omitempty"`
PushoverUserKey any `json:"pushoverUserKey,omitempty"`
TelegramChatID string `json:"telegramChatId,omitempty"`
}
type NotificationsTemplate struct {
EmailEnabled bool `json:"emailEnabled,omitempty"`
DiscordEnabled bool `json:"discordEnabled,omitempty"`
DiscordEnabledTypes int64 `json:"discordEnabledTypes,omitempty"`
PushoverSound any `json:"pushoverSound,omitempty"`
TelegramEnabled bool `json:"telegramEnabled,omitempty"`
TelegramSendSilently any `json:"telegramSendSilently,omitempty"`
WebPushEnabled bool `json:"webPushEnabled,omitempty"`
NotifTypes NotificationTypes `json:"notificationTypes"`
}
type MainUserSettings struct {
Username string `json:"username,omitempty"`
Email string `json:"email,omitempty"`
DiscordID string `json:"discordId,omitempty"`
Locale string `json:"locale,omitempty"`
Region string `json:"region,omitempty"`
OriginalLanguage any `json:"originalLanguage,omitempty"`
MovieQuotaLimit any `json:"movieQuotaLimit,omitempty"`
MovieQuotaDays any `json:"movieQuotaDays,omitempty"`
TvQuotaLimit any `json:"tvQuotaLimit,omitempty"`
TvQuotaDays any `json:"tvQuotaDays,omitempty"`
WatchlistSyncMovies any `json:"watchlistSyncMovies,omitempty"`
WatchlistSyncTv any `json:"watchlistSyncTv,omitempty"`
}
type ErrorDTO struct {
Message string `json:"message,omitempty"`
}

81
jellyseerrdaemon.go Normal file
View File

@@ -0,0 +1,81 @@
package main
import (
"strconv"
"time"
"github.com/hrfee/jfa-go/jellyseerr"
)
func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
user, imported, err := app.js.GetOrImportUser(jfID)
if err != nil {
app.debug.Printf("Failed to get or trigger import for Jellyseerr (user \"%s\"): %v", jfID, err)
return
}
if imported {
app.debug.Printf("Jellyseerr: Triggered import for Jellyfin user \"%s\" (ID %d)", jfID, user.ID)
}
notif, err := app.js.GetNotificationPreferencesByID(user.ID)
if err != nil {
app.debug.Printf("Failed to get notification prefs for Jellyseerr (user \"%s\"): %v", jfID, err)
return
}
contactMethods := map[jellyseerr.NotificationsField]any{}
email, ok := app.storage.GetEmailsKey(jfID)
if ok && email.Addr != "" && user.Email != email.Addr {
err = app.js.ModifyMainUserSettings(jfID, jellyseerr.MainUserSettings{Email: email.Addr})
if err != nil {
app.err.Printf("Failed to set Jellyseerr email address: %v\n", err)
} else {
contactMethods[jellyseerr.FieldEmailEnabled] = email.Contact
}
}
if discordEnabled {
dcUser, ok := app.storage.GetDiscordKey(jfID)
if ok && dcUser.ID != "" && notif.DiscordID != dcUser.ID {
contactMethods[jellyseerr.FieldDiscord] = dcUser.ID
contactMethods[jellyseerr.FieldDiscordEnabled] = dcUser.Contact
}
}
if telegramEnabled {
tgUser, ok := app.storage.GetTelegramKey(jfID)
chatID, _ := strconv.ParseInt(notif.TelegramChatID, 10, 64)
if ok && tgUser.ChatID != 0 && chatID != tgUser.ChatID {
u, _ := app.storage.GetTelegramKey(jfID)
contactMethods[jellyseerr.FieldTelegram] = u.ChatID
contactMethods[jellyseerr.FieldTelegramEnabled] = tgUser.Contact
}
}
if len(contactMethods) != 0 {
err := app.js.ModifyNotifications(jfID, contactMethods)
if err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
}
}
}
func (app *appContext) SynchronizeJellyseerrUsers() {
users, status, err := app.jf.GetUsers(false)
if err != nil || status != 200 {
app.err.Printf("Failed to get users (%d): %s", status, err)
return
}
// I'm sure Jellyseerr can handle it,
// but past issues with the Jellyfin db scare me from
// running these concurrently. W/e, its a bg task anyway.
for _, user := range users {
app.SynchronizeJellyseerrUser(user.ID)
}
}
func newJellyseerrDaemon(interval time.Duration, app *appContext) *GenericDaemon {
d := NewGenericDaemon(interval, app,
func(app *appContext) {
app.SynchronizeJellyseerrUsers()
},
)
d.Name("Jellyseerr import daemon")
return d
}

27
lang.go
View File

@@ -93,18 +93,19 @@ func (ls *emailLangs) getOptions() [][2]string {
}
type emailLang struct {
Meta langMeta `json:"meta"`
Strings langSection `json:"strings"`
UserCreated langSection `json:"userCreated"`
InviteExpiry langSection `json:"inviteExpiry"`
PasswordReset langSection `json:"passwordReset"`
UserDeleted langSection `json:"userDeleted"`
UserDisabled langSection `json:"userDisabled"`
UserEnabled langSection `json:"userEnabled"`
InviteEmail langSection `json:"inviteEmail"`
WelcomeEmail langSection `json:"welcomeEmail"`
EmailConfirmation langSection `json:"emailConfirmation"`
UserExpired langSection `json:"userExpired"`
Meta langMeta `json:"meta"`
Strings langSection `json:"strings"`
UserCreated langSection `json:"userCreated"`
InviteExpiry langSection `json:"inviteExpiry"`
PasswordReset langSection `json:"passwordReset"`
UserDeleted langSection `json:"userDeleted"`
UserDisabled langSection `json:"userDisabled"`
UserEnabled langSection `json:"userEnabled"`
UserExpiryAdjusted langSection `json:"userExpiryAdjusted"`
InviteEmail langSection `json:"inviteEmail"`
WelcomeEmail langSection `json:"welcomeEmail"`
EmailConfirmation langSection `json:"emailConfirmation"`
UserExpired langSection `json:"userExpired"`
}
type setupLangs map[string]setupLang
@@ -116,6 +117,7 @@ type setupLang struct {
EndPage langSection `json:"endPage"`
General langSection `json:"general"`
Updates langSection `json:"updates"`
Proxy langSection `json:"proxy"`
Language langSection `json:"language"`
Login langSection `json:"login"`
JellyfinEmby langSection `json:"jellyfinEmby"`
@@ -123,6 +125,7 @@ type setupLang struct {
Email langSection `json:"email"`
Messages langSection `json:"messages"`
Notifications langSection `json:"notifications"`
UserPage langSection `json:"userPage"`
WelcomeEmails langSection `json:"welcomeEmails"`
PasswordResets langSection `json:"passwordResets"`
InviteEmails langSection `json:"inviteEmails"`

224
lang/admin/ar-aa.json Normal file
View File

@@ -0,0 +1,224 @@
{
"meta": {
"name": "العربية (AR)"
},
"strings": {
"invites": "الدعوات",
"accounts": "الحسابات",
"settings": "الإعدادات",
"inviteMonths": "شهور",
"inviteDays": "أيام",
"inviteHours": "ساعات",
"inviteMinutes": "دقائق",
"inviteNumberOfUses": "عدد الاستخدامات",
"inviteDuration": "مدة الدعوة",
"warning": "تحذير",
"inviteInfiniteUsesWarning": "الدعوات ذات الاستخدامات اللانهائية يمكن ان تستخدم بشكل مسيئ",
"inviteSendToEmail": "إرسال إلى",
"create": "إنشاء",
"apply": "تطبيق",
"select": "تحديد",
"name": "الاسم",
"date": "التاريخ",
"setExpiry": "تعيين انتهاء الصلاحية",
"updates": "التحديثات",
"update": "تحديث",
"download": "تنزيل",
"search": "بحث",
"advancedSettings": "إعدادات متقدمة",
"lastActiveTime": "آخر نشاط",
"from": "من",
"after": "بعد",
"before": "قبل",
"user": "مستخدم",
"userExpiry": "انتهاء صلاحية المستخدم",
"userExpiryDescription": "بعد وقت محدد من تسجيل مستخدم جديد, jfa-go سوف يمسح\\يلغي تفعيل الحساب. بامكانك تغيير هذا السلوك في الاعدادات.",
"aboutProgram": "حول",
"version": "إصدار",
"commitNoun": "فرض",
"newUser": "مستخدم جديد",
"profile": "حساب تعريفي",
"unknown": "غير معروف",
"label": "وسم",
"logs": "السجلات",
"announce": "إعلان",
"templates": "قوالب",
"subject": "الموضوع",
"message": "الرسالة",
"variables": "المتغيرات",
"conditionals": "",
"preview": "معاينة",
"reset": "إعادة ضبط",
"donate": "تبرع",
"unlink": "إلغاء ربط الحساب",
"sendPWR": "إرسال إعادة تعيين كلمة المرور",
"contactThrough": "تواصل عن طريق:",
"extendExpiry": "تمديد إنتهاء الصلاحية",
"sendPWRManual": "",
"sendPWRSuccess": "تم إرسال رابط إعادة تعيين كلمة المرور.",
"sendPWRSuccessManual": "",
"sendPWRValidFor": "",
"customizeMessages": "",
"customizeMessagesDescription": "",
"markdownSupported": "",
"modifySettings": "",
"modifySettingsDescription": "",
"applyHomescreenLayout": "تطبيق ترتيب الصفحه الرئيسيه",
"sendDeleteNotificationEmail": "ارسال رساله اشعار",
"sendDeleteNotifiationExample": "تم حذف حسابك.",
"settingsRestart": "اعاده تشغيل",
"settingsRestarting": "اعاده التشغيل…",
"settingsRestartRequired": "يجب اعاده التشغيل",
"settingsRestartRequiredDescription": "يجب اعاده التشغيل لتطبيق بعض الاعدادات التي تم تغييرها. اعاده التشغيل الان ام لاحقا؟",
"settingsApplyRestartLater": "تطبيق الاعدادات, اعاده التشغيل لاحقا",
"settingsApplyRestartNow": "تطبيق الاعدادات و اعاده التشغيل",
"settingsApplied": "تم تطبيق الاعدادات.",
"settingsRefreshPage": "اعد انعاش الصفحه بعد بضع ثوان.",
"settingsRequiredOrRestartMessage": "ملاحظه: {n} تشير الى حقل اجباري, {n} تشير ان التغييرات تحتاج لاعاده التشغيل.",
"settingsSave": "حفظ",
"ombiProfile": "",
"ombiUserDefaultsDescription": "",
"userProfiles": "",
"userProfilesDescription": "",
"userProfilesIsDefault": "",
"userProfilesLibraries": "",
"addProfile": "",
"addProfileDescription": "",
"addProfileNameOf": "",
"addProfileStoreHomescreenLayout": "",
"inviteNoUsersCreated": "",
"inviteUsersCreated": "",
"inviteNoProfile": "",
"inviteDateCreated": "",
"inviteRemainingUses": "",
"inviteNoInvites": "",
"inviteExpiresInTime": "",
"notifyEvent": "",
"notifyInviteExpiry": "",
"notifyUserCreation": "",
"sendPIN": "",
"searchDiscordUser": "",
"findDiscordUser": "",
"linkMatrixDescription": "",
"matrixHomeServer": "",
"saveAsTemplate": "",
"deleteTemplate": "",
"templateEnterName": "",
"accessJFA": "",
"accessJFASettings": "",
"sortingBy": "",
"filters": "",
"clickToRemoveFilter": "",
"clearSearch": "",
"actions": "",
"searchOptions": "",
"matchText": "",
"jellyfinID": "",
"userPageLogin": "",
"userPagePage": "",
"buildTime": "",
"builtBy": "",
"activity": "الانشطه",
"userLabel": "وسم المستخدم",
"userLabelDescription": "الوسام للمستخدمين المفعلين من هذه الدعوه.",
"enableReferrals": "تفعيل الاحالات",
"disableReferrals": "ابطال الاحالات",
"invite": "دعوه",
"enableReferralsProfileDescription": "تمكين المستخدمين من هذا الحساب التعريفي للاحالات الخاصه, لارسالها للعائله\\الاصدقاء. انشاء دعوه بالاعدادات المطلوبه, ثم اختارها هنا. كل احاله سوف تكون مبنيه على اعدادات هذه الدعوه. بامكانك مسح الدعوه عند لانتهاء.",
"enableReferralsDescription": "تمكين المستخدمين لاستعمال احالات خاصه مثل الدعوه, لارسالها للعائله\\للاصدقاء. ممكن اصدارها من قوالب الاحالات في الحساب التعريفي, او من دعوه مفعله."
},
"notifications": {
"changedEmailAddress": "",
"userCreated": "",
"createProfile": "",
"saveSettings": "",
"saveEmail": "",
"sentAnnouncement": "",
"savedAnnouncement": "",
"setOmbiProfile": "",
"updateApplied": "",
"updateAppliedRefresh": "",
"telegramVerified": "",
"accountConnected": "",
"errorSettingsAppliedNoHomescreenLayout": "",
"errorHomescreenAppliedNoSettings": "",
"errorSettingsFailed": "",
"errorSaveEmail": "",
"errorBlankFields": "",
"errorDeleteProfile": "",
"errorLoadProfiles": "",
"errorCreateProfile": "",
"errorSetDefaultProfile": "",
"errorLoadUsers": "",
"errorLoadSettings": "",
"errorSetOmbiProfile": "",
"errorLoadOmbiUsers": "",
"errorChangedEmailAddress": "",
"errorFailureCheckLogs": "",
"errorPartialFailureCheckLogs": "",
"errorUserCreated": "",
"errorSendWelcomeEmail": "",
"errorApplyUpdate": "",
"errorCheckUpdate": "",
"updateAvailable": "",
"noUpdatesAvailable": ""
},
"quantityStrings": {
"modifySettingsFor": {
"singular": "",
"plural": ""
},
"deleteNUsers": {
"singular": "",
"plural": ""
},
"disableUsers": {
"singular": "",
"plural": ""
},
"reEnableUsers": {
"singular": "",
"plural": ""
},
"addUser": {
"singular": "",
"plural": ""
},
"deleteUser": {
"singular": "",
"plural": ""
},
"deletedUser": {
"singular": "",
"plural": ""
},
"disabledUser": {
"singular": "",
"plural": ""
},
"enabledUser": {
"singular": "",
"plural": ""
},
"announceTo": {
"singular": "",
"plural": ""
},
"appliedSettings": {
"singular": "",
"plural": ""
},
"extendExpiry": {
"singular": "",
"plural": ""
},
"setExpiry": {
"singular": "",
"plural": ""
},
"extendedExpiry": {
"singular": "",
"plural": ""
}
}
}

229
lang/admin/cs-cz.json Normal file
View File

@@ -0,0 +1,229 @@
{
"meta": {
"name": "Čeština (CZ)"
},
"strings": {
"invites": "Pozvánky",
"invite": "Pozvat",
"accounts": "Účty",
"settings": "Nastavení",
"inviteMonths": "Měsíce",
"inviteDays": "Dny",
"inviteHours": "Hodiny",
"inviteMinutes": "Minut",
"inviteNumberOfUses": "Počet použití",
"inviteDuration": "Doba trvání pozvánky",
"warning": "Varování",
"inviteInfiniteUsesWarning": "pozvánky s nekonečným využitím mohou být zneužity",
"inviteSendToEmail": "Poslat komu",
"create": "Vytvořit",
"apply": "Aplikovat",
"select": "Vybrat",
"name": "Název",
"date": "Datum",
"setExpiry": "Nastavit expiraci",
"updates": "Aktualizace",
"update": "Aktualizace",
"download": "Stažení",
"search": "Vyhledávání",
"advancedSettings": "Pokročilé nastavení",
"lastActiveTime": "Naposled aktivní",
"from": "Z",
"after": "Po",
"before": "Před",
"user": "Uživatel",
"userExpiry": "Vypršení platnosti",
"userExpiryDescription": "Zadanou dobu po každé registraci jfa-go smaže/zakáže účet. Toto chování můžete změnit v nastavení.",
"aboutProgram": "O",
"version": "Verze",
"commitNoun": "Zavázat se",
"newUser": "Nový uživatel",
"profile": "Profil",
"unknown": "Neznámý",
"label": "Štítek",
"userLabel": "Uživatelský štítek",
"userLabelDescription": "Štítek, který se použije pro uživatele vytvořené pomocí této pozvánky.",
"logs": "Protokoly",
"announce": "Oznámit",
"templates": "Šablony",
"subject": "Předmět",
"message": "Zpráva",
"variables": "Proměnné",
"conditionals": "Podmínky",
"preview": "Náhled",
"reset": "Resetovat",
"donate": "Darovat",
"unlink": "Odpojit účet",
"sendPWR": "Odeslat resetování hesla",
"contactThrough": "Kontakt přes:",
"extendExpiry": "Prodloužit platnost",
"sendPWRManual": "Uživatel {n} nemá žádný způsob kontaktu, stisknutím tlačítka Kopírovat získáte odkaz, který mu chcete poslat.",
"sendPWRSuccess": "Odkaz pro resetování hesla byl odeslán.",
"sendPWRSuccessManual": "Pokud jej uživatel neobdržel, stisknutím tlačítka Kopírovat získáte odkaz, který mu můžete ručně odeslat.",
"sendPWRValidFor": "Odkaz je platný 30m.",
"customizeMessages": "Přizpůsobit zprávy",
"customizeMessagesDescription": "Pokud nechcete používat šablony zpráv jfa-go, můžete si vytvořit vlastní pomocí Markdown.",
"markdownSupported": "Markdown je podporován.",
"modifySettings": "Upravit nastavení",
"modifySettingsDescription": "Použít nastavení ze stávajícího profilu nebo je získat přímo od uživatele.",
"enableReferrals": "Povolit doporučení",
"disableReferrals": "Zakázat doporučení",
"enableReferralsDescription": "Poskytněte uživatelům osobní doporučující odkaz podobný pozvánce, kterou můžete poslat přátelům/rodině. Lze je získat ze šablony doporučení v profilu nebo z existující pozvánky.",
"enableReferralsProfileDescription": "Poskytněte uživatelům vytvořeným pomocí tohoto profilu osobní doporučující odkaz podobný pozvánce, aby jej poslali přátelům/rodině. Vytvořte pozvánku s požadovaným nastavením a poté ji vyberte zde. Každé doporučení pak bude založeno na této pozvánce. Po dokončení můžete pozvánku smazat.",
"applyHomescreenLayout": "Použít rozložení domovské obrazovky",
"sendDeleteNotificationEmail": "Odeslat zprávu s upozorněním",
"sendDeleteNotifiationExample": "Váš účet byl smazán.",
"settingsRestart": "Restartovat",
"settingsRestarting": "Restartování…",
"settingsRestartRequired": "Je potřeba restart",
"settingsRestartRequiredDescription": "K použití některých změn, které jste změnili, je nutný restart. Restartovat hned nebo později?",
"settingsApplyRestartLater": "Použít, restartovat později",
"settingsApplyRestartNow": "Použít a restartovat",
"settingsApplied": "Nastavení byla použita.",
"settingsRefreshPage": "Obnovte stránku během několika sekund.",
"settingsRequiredOrRestartMessage": "Poznámka: {n} označuje povinné pole, {n} označuje, že změny vyžadují restart.",
"settingsSave": "Uložit",
"ombiProfile": "Ombi uživatelský profil",
"ombiUserDefaultsDescription": "Vytvořte uživatele Ombi a nakonfigurujte jej, poté jej vyberte níže. Když je tento profil vybrán, jeho nastavení/oprávnění budou uložena a použita pro nové uživatele Ombi vytvořené jfa-go.",
"userProfiles": "Uživatelské profily",
"userProfilesDescription": "Profily se použijí pro uživatele, když si vytvoří účet. Profil zahrnuje přístupová práva ke knihovně a rozvržení domovské obrazovky.",
"userProfilesIsDefault": "Výchozí",
"userProfilesLibraries": "Knihovny",
"addProfile": "Přidat profil",
"addProfileDescription": "Vytvořte uživatele Jellyfin a nakonfigurujte jej, poté jej vyberte níže. Když se tento profil použije na pozvánku, vytvoří se noví uživatelé s nastavením.",
"addProfileNameOf": "Jméno profilu",
"addProfileStoreHomescreenLayout": "Uložit rozložení domovské obrazovky",
"inviteNoUsersCreated": "Ještě žádný!",
"inviteUsersCreated": "Vytvoření uživatelé",
"inviteNoProfile": "Žádný profil",
"inviteDateCreated": "Vytvořeno",
"inviteNoInvites": "Žádný",
"inviteExpiresInTime": "Platnost vyprší za {n}",
"notifyEvent": "Upozornit na:",
"notifyInviteExpiry": "Při vypršení platnosti",
"notifyUserCreation": "Při vytvoření uživatele",
"sendPIN": "Požádejte uživatele, aby robotovi zaslal níže uvedený PIN.",
"searchDiscordUser": "Začněte psát uživatelské jméno Discord a vyhledejte uživatele.",
"findDiscordUser": "Najít uživatele Discordu",
"linkMatrixDescription": "Zadejte uživatelské jméno a heslo uživatele, který chcete použít jako robot. Po odeslání se aplikace restartuje.",
"matrixHomeServer": "Adresa domovského serveru",
"saveAsTemplate": "Uložit jako šablonu",
"deleteTemplate": "Smazat šablonu",
"templateEnterName": "Zadejte název pro uložení této šablony.",
"accessJFA": "Přístup k jfa-go",
"accessJFASettings": "Nelze změnit, protože v Nastavení > Obecné bylo nastaveno \"Pouze správce\" nebo \"Povolit vše\".",
"sortingBy": "Řazení podle",
"filters": "Filtry",
"clickToRemoveFilter": "Kliknutím tento filtr odstraníte.",
"clearSearch": "Vymazat vyhledávání",
"actions": "Akce",
"searchOptions": "Možnosti hledání",
"matchText": "Shoda textu",
"jellyfinID": "Jellyfin ID",
"userPageLogin": "Uživatelská stránka: Přihlášení",
"userPagePage": "Uživatelská stránka: Stránka",
"buildTime": "Čas sestavení",
"builtBy": "Postaven",
"loginNotAdmin": "Nejste správce?"
},
"notifications": {
"changedEmailAddress": "Změněna e-mailová adresa uživatele {n}.",
"userCreated": "Uživatel {n} byl vytvořen.",
"createProfile": "Vytvořen profil {n}.",
"saveSettings": "Nastavení byla uložena",
"saveEmail": "Email byl uložen.",
"sentAnnouncement": "Oznámení odesláno.",
"savedAnnouncement": "Oznámení uloženo.",
"setOmbiProfile": "Uložený ombi profil.",
"updateApplied": "Aktualizace byla použita, restartujte prosím.",
"updateAppliedRefresh": "Aktualizace byla použita, obnovte ji.",
"telegramVerified": "Účet telegramu ověřen.",
"accountConnected": "Účet připojen.",
"referralsEnabled": "Doporučení povolena.",
"errorSettingsAppliedNoHomescreenLayout": "Nastavení byla použita, ale použití rozvržení domovské obrazovky mohlo selhat.",
"errorHomescreenAppliedNoSettings": "Bylo použito rozvržení domovské obrazovky, ale použití nastavení mohlo selhat.",
"errorSettingsFailed": "Aplikace se nezdařila.",
"errorSaveEmail": "Uložení e-mailu se nezdařilo.",
"errorBlankFields": "Pole zůstala prázdná",
"errorDeleteProfile": "Smazání profilu {n} se nezdařilo",
"errorLoadProfiles": "Načtení profilů se nezdařilo.",
"errorCreateProfile": "Nepodařilo se vytvořit profil {n}",
"errorSetDefaultProfile": "Nepodařilo se nastavit výchozí profil.",
"errorLoadUsers": "Uživatele se nepodařilo načíst.",
"errorLoadSettings": "Nastavení se nepodařilo načíst.",
"errorSetOmbiProfile": "Uložení profilu ombi se nezdařilo.",
"errorLoadOmbiUsers": "Uživatele ombi se nepodařilo načíst.",
"errorChangedEmailAddress": "E-mailovou adresu uživatele {n} se nepodařilo změnit.",
"errorFailureCheckLogs": "Selhalo (zkontrolujte konzolu/protokoly)",
"errorPartialFailureCheckLogs": "Částečná chyba (zkontrolujte konzolu/protokoly)",
"errorUserCreated": "Nepodařilo se vytvořit uživatele {n}.",
"errorSendWelcomeEmail": "Nepodařilo se odeslat uvítací zprávu (zkontrolujte konzolu/protokoly)",
"errorApplyUpdate": "Aktualizaci se nepodařilo použít, zkuste to ručně.",
"errorCheckUpdate": "Kontrola aktualizace se nezdařila.",
"errorNoReferralTemplate": "Profil neobsahuje šablonu doporučení, přidejte si ji v nastavení.",
"updateAvailable": "Je k dispozici nová aktualizace, zkontrolujte nastavení.",
"noUpdatesAvailable": "Nejsou k dispozici žádné nové aktualizace."
},
"quantityStrings": {
"modifySettingsFor": {
"singular": "Upravit nastavení pro {n} uživatele",
"plural": "Upravit nastavení pro {n} uživatelů"
},
"enableReferralsFor": {
"singular": "Povolit doporučení pro {n} uživatele",
"plural": "Povolit doporučení pro {n} uživatelů"
},
"deleteNUsers": {
"singular": "Smazat {n} uživatele",
"plural": "Smazat {n} uživatelů"
},
"disableUsers": {
"singular": "Zakázat {n} uživatele",
"plural": "Zakázat {n} uživatelů"
},
"reEnableUsers": {
"singular": "Znovu povolte {n} uživatele",
"plural": "Znovu povolit {n} uživatelů"
},
"addUser": {
"singular": "Přidat uživatele",
"plural": "Přidat uživatele"
},
"deleteUser": {
"singular": "Smazat uživatele",
"plural": "Smazat uživatele"
},
"deletedUser": {
"singular": "Smazán {n} uživatel.",
"plural": "Smazaní {n} uživatelé."
},
"disabledUser": {
"singular": "Deaktivován {n} uživatel.",
"plural": "Zakázaných {n} uživatelů."
},
"enabledUser": {
"singular": "Povoleno {n} uživatele.",
"plural": "Povolených {n} uživatelů."
},
"announceTo": {
"singular": "Oznámeno {n} uživateli",
"plural": "Oznámit {n} uživatelům"
},
"appliedSettings": {
"singular": "Nastavení byla použita na {n} uživatele.",
"plural": "Nastavení byla použita na {n} uživatelů."
},
"extendExpiry": {
"singular": "Prodloužit platnost pro {n} uživatele",
"plural": "Prodloužit platnost pro {n} uživatelů"
},
"setExpiry": {
"singular": "Nastavit vypršení platnosti pro {n} uživatele",
"plural": "Nastavit vypršení platnosti pro {n} uživatelů"
},
"extendedExpiry": {
"singular": "Prodloužená platnost pro {n} uživatele.",
"plural": "Prodloužená platnost pro {n} uživatelů."
}
}
}

View File

@@ -37,9 +37,9 @@
"profile": "Profil",
"unknown": "Ukendt",
"label": "Etiket",
"announce": "Annoncere",
"announce": "Meddelelse",
"subject": "Emne",
"message": "Meddelelse",
"message": "Besked",
"variables": "Variabler",
"conditionals": "Betingelser",
"preview": "Eksempel",
@@ -47,13 +47,13 @@
"donate": "Doner",
"contactThrough": "Kontakt gennem:",
"extendExpiry": "Forlæng udløb",
"customizeMessages": "Tilpas Meddelelser",
"customizeMessagesDescription": "Hvis du ikke vil bruge jfa-go's meddelelses skabeloner, kan du oprette din egen ved hjælp af Markdown.",
"customizeMessages": "Tilpas Beskeder",
"customizeMessagesDescription": "Hvis du ikke vil bruge jfa-go's besked skabeloner, kan du oprette din egen ved hjælp af Markdown.",
"markdownSupported": "Markdown understøttes.",
"modifySettings": "Rediger indstillinger",
"modifySettingsDescription": "Anvend indstillinger fra en eksisterende profil, eller hent dem direkte fra en bruger.",
"applyHomescreenLayout": "Anvend startskærmens layout",
"sendDeleteNotificationEmail": "Send notifikations meddelelse",
"sendDeleteNotificationEmail": "Send notifikations besked",
"sendDeleteNotifiationExample": "Din konto er blevet slettet.",
"settingsRestart": "Genstart",
"settingsRestarting": "Genstarter…",
@@ -79,7 +79,6 @@
"inviteUsersCreated": "Oprettet brugere",
"inviteNoProfile": "Ingen Profil",
"inviteDateCreated": "Oprettet",
"inviteRemainingUses": "Resterende anvendelser",
"inviteNoInvites": "Ingen",
"inviteExpiresInTime": "Udløber om {n}",
"notifyEvent": "Meddel den:",
@@ -103,7 +102,35 @@
"sendPWRSuccessManual": "Hvis brugeren ikke er modtaget den, så tryk på kopier for manuelt at sende et link til dem.",
"sendPWRValidFor": "Dette link er gyldigt i 30m.",
"accessJFA": "Få adgang til jfa-go",
"accessJFASettings": "Kan ikke ændres, da enten \"Kun administrator\" eller \"Tillad alle\" er blevet indstillet i Indstillinger > Generelt."
"accessJFASettings": "Kan ikke ændres, da enten \"Kun administrator\" eller \"Tillad alle\" er blevet indstillet i Indstillinger > Generelt.",
"after": "Efter",
"settingsHiddenDependency": "Matchende indstillinger er skjult, fordi de afhænger af værdien af en anden indstilling:",
"userPageLogin": "Brugerside: Login",
"buildTime": "Bygnings Tid",
"invite": "inviter",
"loginNotAdmin": "Ikke en Admin?",
"userLabel": "Brugeretiket",
"userLabelDescription": "Etiket, der skal anvendes på brugere, der er oprettet med denne invitation.",
"sortingBy": "Sortering Efter",
"clickToRemoveFilter": "Klik for at fjerne dette filter.",
"clearSearch": "Ryd søgning",
"actions": "Handlinger",
"unlink": "Fjern linket til konto",
"enableReferrals": "Aktiver henvisninger",
"disableReferrals": "Deaktiver henvisninger",
"enableReferralsDescription": "Giv brugerne et personligt henvisningslink, der ligner en invitation, til at sende til venner/familie. Kan hentes fra en henvisningsskabelon i en profil eller fra en eksisterende invitation.",
"enableReferralsProfileDescription": "Giv brugere oprettet med denne profil et personligt henvisningslink, der ligner en invitation, til at sende til venner/familie. Opret en invitation med de ønskede indstillinger, og vælg den her. Hver henvisning vil så være baseret på denne invitation. Du kan slette invitationen, når den er fuldført.",
"before": "Før",
"noResultsFound": "Ingen Resultater Fundet",
"settingsDependsOn": "{setting}: afhænger af {dependency}",
"settingsMaybeUnderAdvanced": "Tip: Du finder muligvis det du leder efter, ved at aktivere Avancerede indstillinger.",
"settingsAdvancedMode": "{setting}: Avanceret Indstillinger skal være aktiveret",
"filters": "Filtre",
"searchOptions": "Søge Indstillinger",
"matchText": "Match Tekst",
"jellyfinID": "Jellyfin ID",
"userPagePage": "Brugerside: Side",
"builtBy": "Bygget Af"
},
"notifications": {
"changedEmailAddress": "Ændret e-mail adresse på {n}.",
@@ -134,14 +161,16 @@
"errorFailureCheckLogs": "Mislykkedes (tjek konsol/logfiler)",
"errorPartialFailureCheckLogs": "Delvis fejl (tjek konsol/logfiler)",
"errorUserCreated": "Kunne ikke oprette bruger {n}.",
"errorSendWelcomeEmail": "Kunne ikke sende velkomst meddelelse (tjek konsol/logfiler",
"errorSendWelcomeEmail": "Kunne ikke sende velkomst besked (tjek konsol/logfiler",
"errorApplyUpdate": "Kunne ikke anvende opdateringen, prøv manuelt.",
"errorCheckUpdate": "Kunne ikke kontrollere for opdatering.",
"updateAvailable": "En ny opdatering er tilgængelig, tjek indstillingerne.",
"noUpdatesAvailable": "Ingen nye opdateringer tilgængelige.",
"savedAnnouncement": "Meddelelse gemt.",
"setOmbiProfile": "Gemt i ombi profilen.",
"errorSetOmbiProfile": "Ombi profilen kunne ikke gemmes."
"errorSetOmbiProfile": "Ombi profilen kunne ikke gemmes.",
"referralsEnabled": "Henvisninger aktiveret.",
"errorNoReferralTemplate": "Profilen indeholder ikke en henvisningsskabelon. Tilføj en i indstillingerne."
},
"quantityStrings": {
"modifySettingsFor": {
@@ -181,8 +210,8 @@
"plural": "Aktiveret {n} brugere."
},
"announceTo": {
"singular": "Annoncer til {n} bruger",
"plural": "Annoncer til {n} brugere"
"singular": "Send Meddelelse til {n} bruger",
"plural": "Send Meddelelse til {n} brugere"
},
"appliedSettings": {
"singular": "Anvendte indstillinger til {n} bruger.",
@@ -199,6 +228,10 @@
"setExpiry": {
"singular": "Indstil udløb for {n} bruger",
"plural": "Indstil udløb for {n} brugere"
},
"enableReferralsFor": {
"singular": "Aktiver Henvisninger for {n} bruger",
"plural": "Aktiver Henvisninger for {n} brugere"
}
}
}
}

View File

@@ -3,7 +3,7 @@
"name": "Deutsch (DE)"
},
"strings": {
"invites": "Invites",
"invites": "Einladungen",
"accounts": "Konten",
"settings": "Einstellungen",
"inviteDays": "Tage",
@@ -53,7 +53,6 @@
"inviteUsersCreated": "Erstellte Benutzer",
"inviteNoProfile": "Kein Profil",
"inviteDateCreated": "Erstellt",
"inviteRemainingUses": "Verbleibende Verwendungen",
"inviteNoInvites": "Keine",
"inviteExpiresInTime": "Läuft in {n} ab",
"notifyEvent": "Benachrichtigen bei:",
@@ -95,7 +94,7 @@
"accessJFA": "jfa-go Zugriff",
"sendPWRValidFor": "Der Link ist 30m gültig.",
"logs": "Logdaten",
"setExpiry": "Ablauf setzen",
"setExpiry": "Ablaufdatum setzen",
"sendPWRSuccess": "Link zur Passwortrücksetzung versandt.",
"sendPWRSuccessManual": "Falls der Benutzer ihn nicht erhalten hat, klicke \"Kopieren\" und sende ihm den Link manuell.",
"sendPWR": "Sende Passwortrücksetzung",
@@ -103,7 +102,41 @@
"accessJFASettings": "Kann nicht geändert werden, da entweder \"Nur Admin-Benutzer\" oder \"Erlaube allen Jellyfin-Nutzern sich anzumelden\" in Einstellungen > Allgemein aktiviert ist.",
"saveAsTemplate": "Als Vorlage speichern",
"deleteTemplate": "Vorlage löschen",
"templateEnterName": "Gebe einen Namen ein, um diese Vorlage zu speichern."
"templateEnterName": "Gebe einen Namen ein, um diese Vorlage zu speichern.",
"filters": "Filter",
"clickToRemoveFilter": "zum Entfernen des Filters klicken.",
"clearSearch": "Suche löschen",
"actions": "Aktionen",
"searchOptions": "Suchoptionen",
"matchText": "Textübereinstummung",
"jellyfinID": "Jellyfin ID",
"userPageLogin": "Benutzer Seite: Login",
"userPagePage": "Benutzer Seite: Seite",
"after": "nach",
"before": "vor",
"unlink": "Account trennen",
"sortingBy": "Sortieren nach",
"activity": "Aktivität",
"settingsMaybeUnderAdvanced": "Tipp: Du könntest finden, wonach Du suchst, indem Du die erweiterten Einstellungen aktivierst.",
"enableReferralsProfileDescription": "Gib Benutzern, die mit diesem Profil erstellt wurden, einen persönlichen Empfehlungslink, ähnlich einer Einladung, den sie an Freunde und Familie senden können. Erstelle eine Einladung mit den gewünschten Einstellungen und wähle sie dann hier aus. Jede Empfehlung basiert dann auf dieser Einladung. Du kannst die Einladung nach Abschluss löschen.",
"removeExpiry": "Ablaufdatum entfernen",
"enterExpiry": "Ablaufdatum eingeben",
"keepSearchingDescription": "Die Suche umfasst nur bereits geladene Aktivitäten. Klicke unten um alle Aktivitäten zu durchsuchen.",
"useInviteExpiry": "Ablaufdatum des Profils/der Einladung setzen",
"useInviteExpiryNote": "Standardmässig laufen Einladungen nach 90 Tagen ab, können jedoch vom Benutzer erneuert werden. Aktiviere diese Option, damit die Empfehlung nach der festgelegten Zeit deaktiviert wird.",
"settingsHiddenDependency": "Zutreffende Einstellungen sind ausgeblendet, da sie vom Wert einer anderen Einstellung abhängen:",
"deleted": "Gelöscht",
"disabled": "Deaktiviert",
"keepSearching": "Weiter suchen",
"enableReferralsDescription": "Gib Benutzern einen persönlichen Empfehlungslink, ähnlich einer Einladung, den sie an Freunde und Familie senden können. Dieser kann aus einer Empfehlungsvorlage im Profil oder aus einer bestehenden Einladung stammen.",
"settingsDependsOn": "{setting}: abhängig von {dependency}",
"settingsAdvancedMode": "{setting}: Erweiterte Einstellungen müssen aktiviert sein",
"invite": "Einladung",
"userLabelDescription": "Label welches auf Benutzer angewendet wird, die mit dieser Einladung erstellt wurden.",
"enableReferrals": "Empfehlungen aktivieren",
"disableReferrals": "Empfehlungen deaktivieren",
"userLabel": "Benutzer Label",
"noResultsFound": "Keine Resultate gefunden"
},
"notifications": {
"changedEmailAddress": "E-Mail-Adresse von {n} geändert.",
@@ -201,4 +234,4 @@
"plural": "Ablauf für {n} Benutzer setzen"
}
}
}
}

View File

@@ -56,7 +56,6 @@
"inviteUsersCreated": "Δημιουργηθέντες χρήστες",
"inviteNoProfile": "Κανένα Προφίλ",
"inviteDateCreated": "Δημιουργηθέντα",
"inviteRemainingUses": "Εναπομείναντες χρήσεις",
"inviteNoInvites": "Καμία",
"inviteExpiresInTime": "Λήγει σε {n}",
"notifyEvent": "Ενημέρωση όταν:",

View File

@@ -124,7 +124,6 @@
"addProfileStoreHomescreenLayout": "Store homescreen layout",
"inviteNoUsersCreated": "None yet!",
"inviteUsersCreated": "Created users",
"inviteRemainingUses": "Remaining uses",
"inviteNoInvites": "None",
"inviteExpiresInTime": "Expires in {n}",
"notifyEvent": "Notify on:",

View File

@@ -4,7 +4,9 @@
},
"strings": {
"invites": "Invites",
"invite": "Invite",
"accounts": "Accounts",
"activity": "Activity",
"settings": "Settings",
"inviteMonths": "Months",
"inviteDays": "Days",
@@ -20,7 +22,6 @@
"select": "Select",
"name": "Name",
"date": "Date",
"setExpiry": "Set expiry",
"updates": "Updates",
"update": "Update",
"download": "Download",
@@ -40,6 +41,8 @@
"profile": "Profile",
"unknown": "Unknown",
"label": "Label",
"userLabel": "User Label",
"userLabelDescription": "Label to apply to users created with this invite.",
"logs": "Logs",
"announce": "Announce",
"templates": "Templates",
@@ -51,9 +54,17 @@
"reset": "Reset",
"donate": "Donate",
"unlink": "Unlink Account",
"deleted": "Deleted",
"disabled": "Disabled",
"sendPWR": "Send Password Reset",
"noResultsFound": "No Results Found",
"keepSearching": "Keep Searching",
"keepSearchingDescription": "Only the current loaded activities were searched. Click below if you wish to search all activities.",
"contactThrough": "Contact through:",
"extendExpiry": "Extend expiry",
"setExpiry": "Set expiry",
"removeExpiry": "Remove expiry",
"enterExpiry": "Enter an expiry",
"sendPWRManual": "User {n} has no method of contact, press copy to get a link to send to them.",
"sendPWRSuccess": "Password reset link sent.",
"sendPWRSuccessManual": "If the user hasn't received it, press copy to get a link to manually send to them.",
@@ -63,7 +74,16 @@
"markdownSupported": "Markdown is supported.",
"modifySettings": "Modify Settings",
"modifySettingsDescription": "Apply settings from an existing profile, or source them directly from a user.",
"enableReferrals": "Enable Referrals",
"disableReferrals": "Disable Referrals",
"enableReferralsDescription": "Give users a personal referral link similiar to an invite, to send to friends/family. Can be sourced from a referral template in a profile, or from an existing invite.",
"enableReferralsProfileDescription": "Give users created with this profile a personal referral link similiar to an invite, to send to friends/family. Create an invite with the desired settings, then select it here. Each referral will then be based on this invite. You can delete the invite once complete.",
"useInviteExpiry": "Set expiry from profile/invite",
"useInviteExpiryNote": "By default, invites expire after 90 days but can be renewed by the user. Enable for the referral to be disabled after the time set.",
"applyHomescreenLayout": "Apply homescreen layout",
"applyConfigurationAndPolicy": "Apply Jellyfin configuration/policy",
"applyOmbi": "Apply Ombi profile (if available)",
"applyJellyseerr": "Apply Jellyseerr profile (if available)",
"sendDeleteNotificationEmail": "Send notification message",
"sendDeleteNotifiationExample": "Your account has been deleted.",
"settingsRestart": "Restart",
@@ -76,8 +96,14 @@
"settingsRefreshPage": "Refresh the page in a few seconds.",
"settingsRequiredOrRestartMessage": "Note: {n} indicates a required field, {n} indicates changes require a restart.",
"settingsSave": "Save",
"settingsHiddenDependency": "Matching settings are hidden because they depend on the value of another setting:",
"settingsDependsOn": "{setting}: Depends on {dependency}",
"settingsAdvancedMode": "{setting}: Advanced Settings must be enabled",
"settingsMaybeUnderAdvanced": "Tip: You might find what you're looking for by enabling Advanced Settings.",
"ombiProfile": "Ombi user profile",
"ombiUserDefaultsDescription": "Create an Ombi user and configure it, then select it below. It's settings/permissions will be stored and applied to new Ombi users created by jfa-go when this profile is selected.",
"jellyseerrProfile": "Jellyseerr user profile",
"jellyseerrUserDefaultsDescription": "Create a Jellyseerr user and configure it, then select it below. It's settings/permissions will be stored and applied to new Jellyseerr users created by jfa-go when this profile is selected.",
"userProfiles": "User Profiles",
"userProfilesDescription": "Profiles are applied to users when they create an account. A profile includes library access rights and homescreen layout.",
"userProfilesIsDefault": "Default",
@@ -90,7 +116,6 @@
"inviteUsersCreated": "Created users",
"inviteNoProfile": "No Profile",
"inviteDateCreated": "Created",
"inviteRemainingUses": "Remaining uses",
"inviteNoInvites": "None",
"inviteExpiresInTime": "Expires in {n}",
"notifyEvent": "Notify on:",
@@ -107,6 +132,7 @@
"accessJFA": "Access jfa-go",
"accessJFASettings": "Cannot be changed as either \"Admin Only\" or \"Allow All\" has been set in Settings > General.",
"sortingBy": "Sorting By",
"sortDirection": "Sort Direction",
"filters": "Filters",
"clickToRemoveFilter": "Click to remove this filter.",
"clearSearch": "Clear search",
@@ -116,10 +142,69 @@
"jellyfinID": "Jellyfin ID",
"userPageLogin": "User Page: Login",
"userPagePage": "User Page: Page",
"buildTime": "Build Time",
"builtBy": "Built By"
"postSignupCard": "Post-signup help card",
"postSignupCardDescription": "Card shown to user after signing up. Overrides \"Success Message\". Overriden by \"Auto redirect on success\" setting.",
"buildTime": "Build Time",
"builtBy": "Built By",
"loginNotAdmin": "Not an Admin?",
"referrer": "Referrer",
"accountLinked": "{contactMethod} linked: {user}",
"accountUnlinked": "{contactMethod} removed: {user}",
"accountResetPassword": "{user} reset their password",
"accountChangedPassword": "{user} changed their password",
"accountCreated": "Account created: {user}",
"accountDeleted": "Account deleted: {user}",
"accountDisabled": "Account disabled: {user}",
"accountReEnabled": "Account re-enabled: {user}",
"accountExpired": "Account expired: {user}",
"accountWillExpire": "Account will expire on {date}.",
"expirationBasedOn": "Given date based on 1st user.",
"userDeleted": "User was deleted.",
"userDisabled": "User was disabled",
"inviteCreated": "Invite created: {invite}",
"inviteDeleted": "Invite deleted: {invite}",
"inviteExpired": "Invite expired: {invite}",
"fromInvite": "From Invite",
"byAdmin": "By Admin",
"byUser": "By User",
"byJfaGo": "By jfa-go",
"activityID": "Activity ID",
"title": "Title",
"usersMentioned": "User mentioned",
"actor": "Actor",
"actorDescription": "The thing that caused this action. \"user\"/\"admin\"/\"daemon\" or a username.",
"accountCreationFilter": "Account Creation",
"accountDeletionFilter": "Account Deletion",
"accountDisabledFilter": "Account Disabled",
"accountEnabledFilter": "Account Enabled",
"contactLinkedFilter": "Contact Linked",
"contactUnlinkedFilter": "Contact Unlinked",
"passwordChangeFilter": "Password Changed",
"passwordResetFilter": "Password Reset",
"inviteCreatedFilter": "Invite Created",
"inviteDeletedFilter": "Invite Deleted/Expired",
"loadMore": "Load More",
"loadAll": "Load All",
"noMoreResults": "No more results.",
"totalRecords": "{n} Total Records",
"loadedRecords": "{n} Loaded",
"shownRecords": "{n} Shown",
"backups": "Backups",
"backupsDescription": "Backups of the database can be made, restored, or downloaded from here.",
"backupsFormatNote": "Only backup files with the standard name format will be shown here. To use any other, upload the backup manually.",
"backupsCopy": "When applying a backup, a copy of the original \"db\" folder will be made next to it, in case anything goes wrong.",
"backupDownloadRestore": "Download / Restore",
"backupUpload": "Upload & Restore Backup",
"backupDownload": "Download Backup",
"backupRestore": "Restore Backup",
"backupNow": "Backup Now",
"backupCreated": "Backup created",
"backupCanBeFound": "The backup can be found on the server at {filepath}.",
"backupCanDownload": "Alternatively, click below to download the backup.",
"wikiPage": "Wiki Page"
},
"notifications": {
"pathCopied": "Full path copied to clipboard.",
"changedEmailAddress": "Changed email address of {n}.",
"userCreated": "User {n} created.",
"createProfile": "Created profile {n}.",
@@ -128,10 +213,15 @@
"sentAnnouncement": "Announcement sent.",
"savedAnnouncement": "Announcement saved.",
"setOmbiProfile": "Stored ombi profile.",
"savedProfile": "Stored profile changes.",
"updateApplied": "Update applied, please restart.",
"updateAppliedRefresh": "Update applied, please refresh.",
"telegramVerified": "Telegram account verified.",
"accountConnected": "Account connected.",
"referralsEnabled": "Referrals enabled.",
"activityDeleted": "Activity Deleted.",
"errorInviteNoLongerExists": "Invite no longer exists.",
"errorInviteNotFound": "Invite not found.",
"errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.",
"errorHomescreenAppliedNoSettings": "Homescreen layout was applied, but applying settings may have failed.",
"errorSettingsFailed": "Application failed.",
@@ -140,6 +230,7 @@
"errorDeleteProfile": "Failed to delete profile {n}",
"errorLoadProfiles": "Failed to load profiles.",
"errorCreateProfile": "Failed to create profile {n}",
"errorSavedProfile": "Failed to save profile {n}",
"errorSetDefaultProfile": "Failed to set default profile.",
"errorLoadUsers": "Failed to load users.",
"errorLoadSettings": "Failed to load settings.",
@@ -152,6 +243,9 @@
"errorSendWelcomeEmail": "Failed to send welcome message (check console/logs)",
"errorApplyUpdate": "Failed to apply update, try manually.",
"errorCheckUpdate": "Failed to check for update.",
"errorNoReferralTemplate": "Profile doesn't contain referral template, add one in settings.",
"errorLoadActivities": "Failed to load activities.",
"errorInvalidDate": "Date is invalid.",
"updateAvailable": "A new update is available, check settings.",
"noUpdatesAvailable": "No new updates available."
},
@@ -160,6 +254,10 @@
"singular": "Modify Settings for {n} user",
"plural": "Modify Settings for {n} users"
},
"enableReferralsFor": {
"singular": "Enable Referrals for {n} user",
"plural": "Enable Referrals for {n} users"
},
"deleteNUsers": {
"singular": "Delete {n} user",
"plural": "Delete {n} users"

View File

@@ -75,7 +75,6 @@
"inviteUsersCreated": "Usuarios creados",
"inviteNoProfile": "Sin perfil",
"inviteDateCreated": "Creado",
"inviteRemainingUses": "Usos restantes",
"inviteNoInvites": "Ninguno",
"inviteExpiresInTime": "Caduca en {n}",
"notifyEvent": "Notificar en:",
@@ -103,7 +102,41 @@
"ombiProfile": "Perfil de usuario de Ombi",
"logs": "Registros",
"accessJFA": "Acceso",
"accessJFASettings": "No se puede cambia, ya que se ha establecido \"Solo administradores\" o \"Permitir a todos\" en Configuración > General."
"accessJFASettings": "No se puede cambia, ya que se ha establecido \"Solo administradores\" o \"Permitir a todos\" en Configuración > General.",
"buildTime": "Tiempo de construcción",
"builtBy": "Construido por",
"sortingBy": "Ordenar por",
"filters": "Filtros",
"clearSearch": "Borrar búsqueda",
"searchOptions": "Opciones de búsqueda",
"matchText": "Coincidir texto",
"jellyfinID": "Jellyfin ID",
"userPageLogin": "Página de usuario: Iniciar sesión",
"userPagePage": "Página de usuario: Página",
"after": "Después",
"before": "Antes",
"unlink": "Desvincular cuenta",
"clickToRemoveFilter": "Haga clic para eliminar el filtro.",
"removeExpiry": "Eliminar caducidad",
"enterExpiry": "Introduzca una caducidad",
"useInviteExpiry": "Establecer caducidad desde el perfil/invitación",
"noResultsFound": "Ningún resultado encontrado",
"settingsDependsOn": "{setting}: Depende de {dependency}",
"activity": "Actividad",
"disabled": "Desactivado",
"deleted": "Eliminado",
"keepSearching": "Seguir buscando",
"keepSearchingDescription": "Solo se ha buscado en las actividades cargadas actualmente. Clique a continuación si quiere buscar en todas las actividades.",
"settingsAdvancedMode": "{setting}: Los ajustes avanzados deben estar habilitados",
"settingsMaybeUnderAdvanced": "Consejo: Puede que encuentre lo que busca si habilita los Ajustes avanzados.",
"invite": "Invitar",
"userLabel": "Etiqueta de usuario",
"userLabelDescription": "Etiqueta que aplicar a usuarios creados con esta invitación.",
"enableReferrals": "Habilitar referencias",
"disableReferrals": "Deshabilitar referencias",
"enableReferralsDescription": "Proporciona a los usuarios un enlace personal de referencia, parecido a una invitación, para que lo compartan con amigos y familiares. Puede conseguirse a través de una plantilla de referencia en un perfil, o a través de una invitación existente.",
"enableReferralsProfileDescription": "Proporciona a los usuarios creados con este perfil un enlace personal de referencia, parecido a una invitación, para que lo compartan con amigos y familiares. Cree una invitación con los ajustes deseados y selecciónela aquí. Cada referencia se basará en esta invitación. Puede eliminar la invitación una vez completado.",
"useInviteExpiryNote": "Por defecto las invitaciones caducan a los 90 días, pero pueden ser renovadas por el usuario. Habilite que la referencia sea desactivada cuando pase el tiempo establecido."
},
"notifications": {
"changedEmailAddress": "Se cambió la dirección de correo electrónico de {n}.",
@@ -201,4 +234,4 @@
"plural": "Establecer la caducidad para {n} usuarios"
}
}
}
}

View File

@@ -55,7 +55,6 @@
"inviteUsersCreated": "Utilisateurs créés",
"inviteNoProfile": "Aucun profil",
"inviteDateCreated": "Créer",
"inviteRemainingUses": "Utilisations restantes",
"inviteNoInvites": "Aucune",
"inviteExpiresInTime": "Expires dans {n}",
"notifyEvent": "Notifier sur :",
@@ -104,7 +103,100 @@
"ombiProfile": "Profil d'utilisateur Ombi",
"logs": "Logs",
"accessJFA": "Accès à jfa-go",
"accessJFASettings": "Ne peut pas être changé car \"Admin Only\" ou \"Allow All\" a été défini dans Paramètres > Général."
"accessJFASettings": "Ne peut pas être changé car \"Admin Only\" ou \"Allow All\" a été défini dans Paramètres > Général.",
"buildTime": "Heure de la version",
"builtBy": "Version créée par",
"sortingBy": "Trier par",
"filters": "Filtres",
"clickToRemoveFilter": "Cliquer pour supprimer ce filtre.",
"clearSearch": "Réinitialiser la recherche",
"actions": "Actions",
"searchOptions": "Recherche avancée",
"matchText": "Texte correspondant",
"jellyfinID": "ID Jellyfin",
"userPageLogin": "Page utilisateur : Connexion",
"userPagePage": "Page utilisateur : Page",
"after": "Après",
"before": "Avant",
"unlink": "Délier le compte",
"enableReferrals": "Activer Parrainage",
"enableReferralsDescription": "Offrez aux utilisateurs un lien de parrainage personnel semblable à une invitation, à envoyer à vos amis/famille. Peut provenir modèle de profil ou dune invitation existante.",
"invite": "Inviter",
"userLabel": "Étiquette",
"userLabelDescription": "Étiquette à appliquer aux utilisateurs créés avec cette invitation.",
"disableReferrals": "Désactiver Parrainage",
"enableReferralsProfileDescription": "Donnez aux utilisateurs créés avec ce profil un lien de parrainage personnel semblable à une invitation, à envoyer à vos amis/famille. Créez une invitation avec les paramètres souhaités, puis sélectionnez-la ici. Chaque référence sera alors basée sur cette invitation. Vous pouvez supprimer l'invitation une fois terminée.",
"loginNotAdmin": "Vous n'êtes pas administrateur ?",
"removeExpiry": "Supprimer l'expiration",
"enterExpiry": "Entrez une date d'expiration",
"useInviteExpiry": "Définir l'expiration du profil/invitation",
"sortDirection": "Ordre de trie",
"referrer": "Référence",
"accountLinked": "{contactMethod} lié : {user}",
"accountUnlinked": "{contactMethod} supprimé : {user}",
"accountResetPassword": "{user} réinitialise son mot de passe",
"expirationBasedOn": "Date donnée basée sur le 1er utilisateur.",
"accountDeleted": "Compte supprimé : {user}",
"accountChangedPassword": "{user} a changé son mot de passe",
"accountCreated": "Compte créé : {user}",
"accountDisabled": "Compte désactivé : {user}",
"accountReEnabled": "Compte réactivé : {user}",
"accountExpired": "Compte expiré : {user}",
"accountWillExpire": "Le compte expirera le {date}.",
"backups": "Sauvegardes",
"backupsDescription": "Des sauvegardes de la base de données peuvent être effectuées, restaurées ou téléchargées à partir d'ici.",
"backupsCopy": "Lors de l'application d'une sauvegarde, une copie du dossier \"db\" d'origine sera créée à côté, en cas de problème.",
"backupDownloadRestore": "Télécharger/Restaurer",
"backupUpload": "Télécharger et restaurer la sauvegarde",
"backupDownload": "Télécharger la sauvegarde",
"backupRestore": "Restaurer la sauvegarde",
"backupNow": "Sauvegarder maintenant",
"backupCreated": "Sauvegarde créée",
"backupCanDownload": "Vous pouvez également cliquer ci-dessous pour télécharger la sauvegarde.",
"wikiPage": "Wiki page",
"activity": "Activité",
"deleted": "Supprimé",
"disabled": "Désactivé",
"keepSearching": "Continuer la recherche",
"keepSearchingDescription": "Seules les activités actuellement chargées ont été recherchées. Cliquez ci-dessous si vous souhaitez rechercher toutes les activités.",
"settingsHiddenDependency": "Les paramètres correspondants sont masqués car ils dépendent de la valeur d'un autre paramètre :",
"settingsDependsOn": "{setting} : dépend de {dependency}",
"settingsMaybeUnderAdvanced": "Astuce : Vous trouverez peut-être ce que vous cherchez en activant les paramètres avancés.",
"settingsAdvancedMode": "{setting} : les paramètres avancés doivent être activés",
"actorDescription": "La chose qui a provoqué cette action. \"user\"/\"admin\"/\"service\" ou un nom d'utilisateur.",
"activityID": "ID d'activité",
"byUser": "Par Utilisateur",
"inviteExpired": "Invitation expirée : {invite}",
"byJfaGo": "Par jfa-go",
"accountDisabledFilter": "Compte désactivé",
"inviteCreated": "Invitation créée : {invite}",
"inviteDeleted": "Invitation supprimée : {invite}",
"fromInvite": "À partir de l'invitation",
"accountDeletionFilter": "Suppression de compte",
"userDeleted": "L'utilisateur a été supprimé.",
"userDisabled": "L'utilisateur a été désactivé",
"accountCreationFilter": "Création de compte",
"title": "Titre",
"usersMentioned": "Utilisateur mentionné",
"actor": "Acteur",
"byAdmin": "Par Administrateur",
"passwordResetFilter": "Réinitialisation du mot de passe",
"loadMore": "Charger plus",
"accountEnabledFilter": "Compte activé",
"inviteCreatedFilter": "Invitation crée",
"inviteDeletedFilter": "Invitation supprimée/expirée",
"noMoreResults": "Plus de résultats.",
"totalRecords": "{n} Nombre total d'enregistrements",
"passwordChangeFilter": "Mot de passe changé",
"loadedRecords": "{n} Chargé",
"shownRecords": "{n} affiché",
"contactUnlinkedFilter": "Contact sans lien",
"contactLinkedFilter": "Contact lié",
"loadAll": "Tout charger",
"noResultsFound": "Aucun résultat trouvé",
"useInviteExpiryNote": "Par défaut, les invitations expirent après 90 jours mais peuvent être renouvelées par l'utilisateur. Activez la désactivation de la référence après le délai défini.",
"backupsFormatNote": "Seuls les fichiers de sauvegarde au format standard seront affichés ici. Pour en utiliser un autre, veuillez charger la sauvegarde manuellement.",
"backupCanBeFound": "La sauvegarde peut être trouvée sur le serveur à {filepath}."
},
"notifications": {
"changedEmailAddress": "Adresse e-mail modifiée de {n}.",
@@ -142,7 +234,15 @@
"accountConnected": "Compte connecté.",
"savedAnnouncement": "Annonce enregistrée.",
"setOmbiProfile": "Profil ombi enregistré.",
"errorSetOmbiProfile": "Echec de la sauvegarde du profil ombi."
"errorSetOmbiProfile": "Echec de la sauvegarde du profil ombi.",
"errorNoReferralTemplate": "Le profil ne contient pas de modèle de référence, ajoutez-en un dans les paramètres.",
"referralsEnabled": "Parrainage activer.",
"errorLoadActivities": "Échec du chargement des activités.",
"pathCopied": "Chemin complet copié dans le presse-papiers.",
"activityDeleted": "Activité supprimée.",
"errorInviteNoLongerExists": "L'invitation n'existe plus.",
"errorInviteNotFound": "Invitation introuvable.",
"errorInvalidDate": "La date n'est pas valide."
},
"quantityStrings": {
"modifySettingsFor": {
@@ -200,6 +300,10 @@
"setExpiry": {
"singular": "Définir l'expiration pour {n} utilisateur",
"plural": "Définir l'expiration pour {n} utilisateurs"
},
"enableReferralsFor": {
"singular": "Activer les parrainages pour {n} utilisateur",
"plural": "Activer les parrainages pour {n} utilisateur"
}
}
}
}

View File

@@ -29,8 +29,8 @@
"lastActiveTime": "Utoljára aktív",
"from": "Feladó",
"user": "Felhasználó",
"userExpiry": "Felhasználói lejárat",
"userExpiryDescription": "Egy meghatározott idő után minden regisztrációt töröl, vagy felfüggeszt a jfa-go. Ezt a működést megváltoztathatod a beállításokban.",
"userExpiry": "Felhasználó megszünése",
"userExpiryDescription": "Egy meghatározott idő után minden létrehozott felhasználó, vagy felfüggesztésre vagy törlésre kerül rendszer által. A viselkedés a beállításokban módosítható.",
"aboutProgram": "Névjegy",
"version": "Verzió",
"commitNoun": "Elkövet",
@@ -49,50 +49,50 @@
"reset": "Visszaállítás",
"donate": "Támogatás",
"sendPWR": "Jelszó visszaállítás küldése",
"contactThrough": "",
"extendExpiry": "",
"sendPWRManual": "",
"sendPWRSuccess": "",
"sendPWRSuccessManual": "",
"sendPWRValidFor": "",
"customizeMessages": "",
"customizeMessagesDescription": "",
"markdownSupported": "",
"modifySettings": "",
"modifySettingsDescription": "",
"applyHomescreenLayout": "",
"sendDeleteNotificationEmail": "",
"sendDeleteNotifiationExample": "",
"settingsRestart": "",
"settingsRestarting": "",
"settingsRestartRequired": "",
"settingsRestartRequiredDescription": "",
"settingsApplyRestartLater": "",
"settingsApplyRestartNow": "",
"settingsApplied": "",
"settingsRefreshPage": "",
"settingsRequiredOrRestartMessage": "",
"settingsSave": "",
"ombiProfile": "",
"ombiUserDefaultsDescription": "",
"userProfiles": "",
"userProfilesDescription": "",
"userProfilesIsDefault": "",
"userProfilesLibraries": "",
"addProfile": "",
"addProfileDescription": "",
"addProfileNameOf": "",
"addProfileStoreHomescreenLayout": "",
"inviteNoUsersCreated": "",
"inviteUsersCreated": "",
"inviteNoProfile": "",
"inviteDateCreated": "",
"inviteRemainingUses": "",
"inviteNoInvites": "",
"inviteExpiresInTime": "",
"notifyEvent": "",
"notifyInviteExpiry": "",
"notifyUserCreation": "",
"contactThrough": "Kapcsolatfelvétel vele:",
"extendExpiry": "Lejárat kiterjesztése",
"sendPWRManual": "{n} felhasználónak nincs beállítva egy kapcsolati lehetőség sem, kattints a másolásra a linkhez és küld tovább neki.",
"sendPWRSuccess": "Jelszó visszaállítási link elküldve.",
"sendPWRSuccessManual": "Ha a felhasználó nem kapta meg, nyomj a másolásra és küld el neki manuálisan.",
"sendPWRValidFor": "A link érvényessége 30p.",
"customizeMessages": "Üzenetek testreszabása",
"customizeMessagesDescription": "Hogyha nem akarod a jfa-go üzenet sablonjait használni, létre hozhatsz egy sajátot, akár Markdown segítségével.",
"markdownSupported": "Markdown támogatott.",
"modifySettings": "Beállítások módosítása",
"modifySettingsDescription": "Beállítások másolása egy meglévő profilról, vagy egy konkrét felhasználóról.",
"applyHomescreenLayout": "Főképernyő elrendezés alkalmazása",
"sendDeleteNotificationEmail": "Értesítések küldése",
"sendDeleteNotifiationExample": "A fiókod törlésre került.",
"settingsRestart": "Újraindítás",
"settingsRestarting": "Újraindítás…",
"settingsRestartRequired": "Újraindítás szükséges",
"settingsRestartRequiredDescription": "A változtatott beállítások érvénybe léptetéséhez újraindítás szükséges. Most szeretnéd újraindítani vagy később?",
"settingsApplyRestartLater": "Alkalmazás, újraindítás később",
"settingsApplyRestartNow": "Alkalmazás és újraindítás",
"settingsApplied": "Beállítások alkalmazva.",
"settingsRefreshPage": "Frissítsd az oldalt egy pár másodperc múlva.",
"settingsRequiredOrRestartMessage": "Megjegyzés: {n} jelöli a kötelező mezőket, {n} jelöli hogyha újraindítás szükséges.",
"settingsSave": "Mentés",
"ombiProfile": "Ombi felhasználói profil",
"ombiUserDefaultsDescription": "Hozz létre egy Ombi felhasználót, állítsd be, majd válaszd ki lentebb. A beállításai/jogosultságai el lesznek mentve és alkalmazva lesznek az új Ombi felhasználókra amik ezzel a profillal lesznek létrehozva.",
"userProfiles": "Felhasználói profilok",
"userProfilesDescription": "A profilok alkalmazva lesznek a felhasználó létrehozáskor. A profil tartalmazza a könyvtár hozzáférést, és a kezdőképernyő elrendezést.",
"userProfilesIsDefault": "Alapértelmezett",
"userProfilesLibraries": "Könyvtárak",
"addProfile": "Profil hozzáadása",
"addProfileDescription": "Hozz létre egy Jellyfin felhasználót, állítsd be, majd válaszd ki lentebb. Amikor egy felhasználó létrejön egy meghívásból aminél ez a profil volt alkalmazva, megkapja az összes beállítását.",
"addProfileNameOf": "Profil neve",
"addProfileStoreHomescreenLayout": "Kezdőképernyő elrendezés elmentése",
"inviteNoUsersCreated": "Még nincs!",
"inviteUsersCreated": "Létrehozott felhasználók",
"inviteNoProfile": "Profil nélkül",
"inviteDateCreated": "Létrehozva",
"inviteRemainingUses": "Hátralévő felhasználások",
"inviteNoInvites": "Nincs meghívó",
"inviteExpiresInTime": "Lejárat {n} múlva",
"notifyEvent": "Értesítés ekkor:",
"notifyInviteExpiry": "Lejáratkor",
"notifyUserCreation": "Használatkor",
"sendPIN": "",
"searchDiscordUser": "",
"findDiscordUser": "",
@@ -100,7 +100,19 @@
"matrixHomeServer": "",
"saveAsTemplate": "",
"deleteTemplate": "",
"templateEnterName": ""
"templateEnterName": "",
"unlink": "Fiók leválasztása",
"after": "Utánna",
"before": "Elötte",
"sortingBy": "Rendezés",
"filters": "Szűrők",
"clearSearch": "Keresés törlése",
"actions": "Műveletek",
"searchOptions": "Kereső paraméterek",
"matchText": "Eggyező szöveg",
"jellyfinID": "Jellyfin azonosító",
"userPageLogin": "Felhasználói oldal: Bejelentkezés",
"clickToRemoveFilter": "Szűrő eltávolítása."
},
"notifications": {
"changedEmailAddress": "",
@@ -192,4 +204,4 @@
"plural": ""
}
}
}
}

View File

@@ -30,7 +30,7 @@
"modifySettings": "Ganti Pengaturan",
"modifySettingsDescription": "Terapkan pengaturan dari profil yang ada, atau dapatkan langsung dari pengguna.",
"applyHomescreenLayout": "Terapkan tata letak layar beranda",
"sendDeleteNotificationEmail": "Kirim email notifikasi",
"sendDeleteNotificationEmail": "Kirim pesan notifikasi",
"sendDeleteNotifiationExample": "Akun anda telah dihapus.",
"settingsRestart": "Mulai ulang",
"settingsRestarting": "Mengulang kembali…",
@@ -56,7 +56,6 @@
"inviteUsersCreated": "Pengguna yang telah dibuat",
"inviteNoProfile": "Tidak ada profil",
"inviteDateCreated": "Dibuat",
"inviteRemainingUses": "Penggunaan yang tersisa",
"inviteNoInvites": "Tidak ada",
"inviteExpiresInTime": "Kadaluarsa dalam {n}",
"notifyEvent": "Beritahu pada:",
@@ -68,9 +67,16 @@
"customizeMessages": "Sesuaikan Email",
"customizeMessagesDescription": "Jika Anda tidak ingin menggunakan templat email jfa-go, Anda dapat membuatnya sendiri menggunakan Markdown.",
"announce": "Mengumumkan",
"subject": "Subjek Email",
"subject": "Subjek",
"message": "Pesan",
"markdownSupported": "Markdown didukung."
"markdownSupported": "Markdown didukung.",
"donate": "Donasi",
"select": "Pilih",
"search": "Cari",
"download": "Unduh",
"inviteMonths": "Bulan",
"inviteDuration": "Durasi undangan",
"activity": "Aktivitas"
},
"notifications": {
"changedEmailAddress": "Alamat email {n} diubah.",
@@ -129,4 +135,4 @@
"plural": "Umumkan kepada {n} pengguna"
}
}
}
}

216
lang/admin/it-it.json Normal file
View File

@@ -0,0 +1,216 @@
{
"meta": {
"name": "Italiano (IT)"
},
"strings": {
"invites": "",
"accounts": "",
"settings": "",
"inviteMonths": "",
"inviteDays": "",
"inviteHours": "",
"inviteMinutes": "",
"inviteNumberOfUses": "",
"inviteDuration": "",
"warning": "",
"inviteInfiniteUsesWarning": "",
"inviteSendToEmail": "",
"create": "",
"apply": "",
"select": "",
"name": "",
"date": "",
"setExpiry": "",
"updates": "",
"update": "",
"download": "",
"search": "",
"advancedSettings": "",
"lastActiveTime": "",
"from": "",
"after": "",
"before": "",
"user": "",
"userExpiry": "",
"userExpiryDescription": "",
"aboutProgram": "",
"version": "",
"commitNoun": "",
"newUser": "",
"profile": "",
"unknown": "",
"label": "",
"logs": "",
"announce": "",
"templates": "",
"subject": "",
"message": "",
"variables": "",
"conditionals": "",
"preview": "",
"reset": "Ripristino",
"donate": "",
"unlink": "",
"sendPWR": "",
"contactThrough": "",
"extendExpiry": "",
"sendPWRManual": "",
"sendPWRSuccess": "",
"sendPWRSuccessManual": "",
"sendPWRValidFor": "",
"customizeMessages": "",
"customizeMessagesDescription": "",
"markdownSupported": "",
"modifySettings": "",
"modifySettingsDescription": "",
"applyHomescreenLayout": "",
"sendDeleteNotificationEmail": "",
"sendDeleteNotifiationExample": "",
"settingsRestart": "",
"settingsRestarting": "",
"settingsRestartRequired": "",
"settingsRestartRequiredDescription": "",
"settingsApplyRestartLater": "",
"settingsApplyRestartNow": "",
"settingsApplied": "",
"settingsRefreshPage": "",
"settingsRequiredOrRestartMessage": "",
"settingsSave": "",
"ombiProfile": "",
"ombiUserDefaultsDescription": "",
"userProfiles": "",
"userProfilesDescription": "",
"userProfilesIsDefault": "",
"userProfilesLibraries": "",
"addProfile": "",
"addProfileDescription": "",
"addProfileNameOf": "",
"addProfileStoreHomescreenLayout": "",
"inviteNoUsersCreated": "",
"inviteUsersCreated": "",
"inviteNoProfile": "",
"inviteDateCreated": "",
"inviteRemainingUses": "",
"inviteNoInvites": "",
"inviteExpiresInTime": "",
"notifyEvent": "",
"notifyInviteExpiry": "",
"notifyUserCreation": "",
"sendPIN": "",
"searchDiscordUser": "",
"findDiscordUser": "",
"linkMatrixDescription": "",
"matrixHomeServer": "",
"saveAsTemplate": "",
"deleteTemplate": "",
"templateEnterName": "",
"accessJFA": "",
"accessJFASettings": "",
"sortingBy": "",
"filters": "",
"clickToRemoveFilter": "",
"clearSearch": "",
"actions": "",
"searchOptions": "",
"matchText": "",
"jellyfinID": "",
"userPageLogin": "",
"userPagePage": "",
"buildTime": "",
"builtBy": ""
},
"notifications": {
"changedEmailAddress": "",
"userCreated": "",
"createProfile": "",
"saveSettings": "",
"saveEmail": "",
"sentAnnouncement": "",
"savedAnnouncement": "",
"setOmbiProfile": "",
"updateApplied": "",
"updateAppliedRefresh": "",
"telegramVerified": "",
"accountConnected": "",
"errorSettingsAppliedNoHomescreenLayout": "",
"errorHomescreenAppliedNoSettings": "",
"errorSettingsFailed": "",
"errorSaveEmail": "",
"errorBlankFields": "",
"errorDeleteProfile": "",
"errorLoadProfiles": "",
"errorCreateProfile": "",
"errorSetDefaultProfile": "",
"errorLoadUsers": "",
"errorLoadSettings": "",
"errorSetOmbiProfile": "",
"errorLoadOmbiUsers": "",
"errorChangedEmailAddress": "",
"errorFailureCheckLogs": "",
"errorPartialFailureCheckLogs": "",
"errorUserCreated": "",
"errorSendWelcomeEmail": "",
"errorApplyUpdate": "",
"errorCheckUpdate": "",
"updateAvailable": "",
"noUpdatesAvailable": ""
},
"quantityStrings": {
"modifySettingsFor": {
"singular": "",
"plural": ""
},
"deleteNUsers": {
"singular": "",
"plural": ""
},
"disableUsers": {
"singular": "",
"plural": ""
},
"reEnableUsers": {
"singular": "",
"plural": ""
},
"addUser": {
"singular": "",
"plural": ""
},
"deleteUser": {
"singular": "",
"plural": ""
},
"deletedUser": {
"singular": "",
"plural": ""
},
"disabledUser": {
"singular": "",
"plural": ""
},
"enabledUser": {
"singular": "",
"plural": ""
},
"announceTo": {
"singular": "",
"plural": ""
},
"appliedSettings": {
"singular": "",
"plural": ""
},
"extendExpiry": {
"singular": "",
"plural": ""
},
"setExpiry": {
"singular": "",
"plural": ""
},
"extendedExpiry": {
"singular": "",
"plural": ""
}
}
}

View File

@@ -53,7 +53,6 @@
"inviteUsersCreated": "Aangemaakte gebruikers",
"inviteNoProfile": "Geen profiel",
"inviteDateCreated": "Aangemaakt",
"inviteRemainingUses": "Resterend aantal keer te gebruiken",
"inviteNoInvites": "Geen",
"inviteExpiresInTime": "Verloopt over {n}",
"notifyEvent": "Meldingen:",
@@ -103,7 +102,100 @@
"ombiProfile": "Ombi gebruikersprofiel",
"logs": "Logs",
"accessJFA": "Toegang tot jfa-go",
"accessJFASettings": "Kan niet worden aangepast, omdat \"Alleen beheerders\" of \"Laat alle Jellyfin-gebruikers inloggen\" is aangevinkt in Instellingen > Algemeen."
"accessJFASettings": "Kan niet worden aangepast, omdat \"Alleen beheerders\" of \"Laat alle Jellyfin-gebruikers inloggen\" is aangevinkt in Instellingen > Algemeen.",
"noResultsFound": "Geen resultaten gevonden",
"settingsHiddenDependency": "Overeenkomende instellingen zijn verborgen, omdat ze afhangen van een andere instelling:",
"settingsAdvancedMode": "{setting}: Geavanceerde instellingen moet ingeschakeld zijn",
"builtBy": "Build door",
"buildTime": "Build moment",
"userPageLogin": "Gebruikerspagina: Inloggen",
"loginNotAdmin": "Geen beheerder?",
"before": "Voor",
"unlink": "Ontkoppel account",
"after": "Na",
"invite": "Uitnodiging",
"userLabel": "Gebruikerslabel",
"userLabelDescription": "Label om toe te wijzen aan gebruikers aangemaakt met deze uitnodiging.",
"enableReferrals": "Verwijzingen inschakelen",
"disableReferrals": "Verwijzingen uitschakelen",
"enableReferralsDescription": "Geef gebruikers een persoonlijke verwijslink gelijkend op een uitnodiging, om naar vrienden/familie te sturen. Kan opgebouwd worden aan de hand van een verwijssjabloon in een profiel, of een bestaande uitnodiging.",
"enableReferralsProfileDescription": "Geef gebruikers aangemaakt met dit profiel een persoonlijke verwijslink gelijkend op een uitnodiging, om naar vrienden/familie te sturen. Maak een uitnodiging aan met de gewenste instellingen, en selecteer die hier. Elke verwijzing wordt gebaseerd op die uitnodiging. Je kunt de uitnodiging daarna verwijderen.",
"settingsDependsOn": "{setting}: hangt af van {dependency}",
"settingsMaybeUnderAdvanced": "Tip: je vindt misschien wat je zoekt door Geavanceerde instellingen in te schakelen.",
"sortingBy": "Sorteren naar",
"filters": "Filters",
"clickToRemoveFilter": "Klik om dit filter te verwijderen.",
"clearSearch": "Zoekopdracht verwijderen",
"actions": "Acties",
"searchOptions": "Zoekopties",
"matchText": "Tekstovereenkomst",
"jellyfinID": "Jellyfin ID",
"userPagePage": "Gebruikerspagina: Pagina",
"activity": "Activiteit",
"deleted": "Verwijderd",
"disabled": "Uitgeschakeld",
"keepSearching": "Blijf zoeken",
"keepSearchingDescription": "Alleen momenteel ingeladen activiteiten zijn doorzocht. Klik hieronder om alle activiteiten te doorzoeken.",
"sortDirection": "Sorteerrichting",
"referrer": "Verwijzer",
"accountLinked": "{contactMethod} gekoppeld: {user}",
"accountUnlinked": "{contactMethod} verwijderd: {user}",
"accountResetPassword": "{user} heeft hun wachtwoord gereset",
"accountChangedPassword": "{user} heeft hun wachtwoord gewijzigd",
"accountDisabled": "Account uitgeschakeld: {user}",
"accountDeleted": "Account verwijderd: {user}",
"accountCreated": "Account aangemaakt: {user}",
"accountReEnabled": "Account opnieuw ingeschakeld: {user}",
"accountExpired": "Account verlopen: {user}",
"userDeleted": "Gebruiker is verwijderd.",
"userDisabled": "Gebruiker is uitgeschakeld",
"inviteCreated": "Uitnodiging aangemaakt: {invite}",
"inviteDeleted": "Uitnodiging verwijderd: {invite}",
"inviteExpired": "Uitnodiging verlopen: {invite}",
"fromInvite": "Via uitnodiging",
"byAdmin": "Door beheerder",
"byUser": "Door gebruiker",
"byJfaGo": "Door jfa-go",
"activityID": "Activiteit ID",
"title": "Titel",
"usersMentioned": "Genoemde gebruiker",
"actor": "Uitvoerder",
"actorDescription": "Wat deze actie veroorzaakt heeft. \"gebruiker\"/\"beheerder\"/\"daemon\" of een gebruikersnaam.",
"accountCreationFilter": "Aanmaken van account",
"accountDeletionFilter": "Verwijderen van account",
"accountDisabledFilter": "Account uitgeschakeld",
"accountEnabledFilter": "Account ingeschakeld",
"contactLinkedFilter": "Contact gekoppeld",
"contactUnlinkedFilter": "Contact ontkoppeld",
"passwordChangeFilter": "Wachtwoord gewijzigd",
"passwordResetFilter": "Wachtwoord gereset",
"inviteCreatedFilter": "Uitnodiging aangemaakt",
"inviteDeletedFilter": "Uitnodiging verwijderd/verlopen",
"loadMore": "Laad meer",
"loadAll": "Laad alles",
"noMoreResults": "Niet meer resultaten.",
"totalRecords": "{n} documenten totaal",
"loadedRecords": "{n} geladen",
"shownRecords": "{n} getoond",
"useInviteExpiry": "Neem verloop over van profiel/uitnodiging",
"backups": "Backups",
"removeExpiry": "Verwijder verloop",
"enterExpiry": "Voer verloop in",
"accountWillExpire": "Account verloopt op {date}.",
"expirationBasedOn": "Datum gebaseerd op 1e gebruiker.",
"backupsFormatNote": "Alleen backupbestanden met het standaard naamformaat worden hier getoond. Upload handmatig om een ander bestand te gebruiken.",
"backupDownloadRestore": "Downloaden / Terugzetten",
"backupUpload": "Upload backup & zet terug",
"backupDownload": "Download backup",
"backupRestore": "Backup terugzetten",
"backupNow": "Nu backup maken",
"backupCreated": "Backup gemaakt",
"backupCanBeFound": "De backup kan op de server gevonden worden onder {filepath}.",
"backupCanDownload": "Of klik hieronder om de backup te downloaden.",
"wikiPage": "Wiki pagina",
"useInviteExpiryNote": "Standaard verlopen uitnodigingen na 90 dagen, maar kunnen ze vernieuwd worden door de gebruiker. Schakel in om de verwijzing uit te schakelen na de ingestelde tijd.",
"backupsDescription": "Hier kunnen backups van de database gemaakt, teruggezet, of gedownload worden.",
"backupsCopy": "Bij het toepassen van een backup wordt er een kopie van de originele \"db\" folder naast gemaakt, voor het geval er iets misgaat."
},
"notifications": {
"changedEmailAddress": "E-mailadres van {n} gewijzigd.",
@@ -141,7 +233,15 @@
"accountConnected": "Account gekoppeld.",
"savedAnnouncement": "Aankondiging opgeslagen.",
"setOmbiProfile": "Opgeslagen ombi-profiel.",
"errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt."
"errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt.",
"errorNoReferralTemplate": "Profiel bevat geen verwijzingssjabloon, voeg er een toe bij instellingen.",
"referralsEnabled": "Verwijzingen actief.",
"activityDeleted": "Activiteit verwijderd.",
"errorInviteNoLongerExists": "Uitnodiging bestaat niet meer.",
"errorInviteNotFound": "Uitnodiging niet gevonden.",
"errorLoadActivities": "Laden van activiteiten mislukt.",
"pathCopied": "Volledig pad gekopieerd naar klembord.",
"errorInvalidDate": "Ongeldige datum."
},
"quantityStrings": {
"modifySettingsFor": {
@@ -199,6 +299,10 @@
"setExpiry": {
"singular": "Stel verloop in voor {n} gebruiker",
"plural": "Stel verloop in voor {n} gebruikers"
},
"enableReferralsFor": {
"plural": "Verwijzingen activeren voor {1} gebruikers",
"singular": "Verwijzingen activeren voor {1} gebruiker"
}
}
}
}

View File

@@ -87,7 +87,6 @@
"inviteUsersCreated": "",
"inviteNoProfile": "",
"inviteDateCreated": "Utworzone",
"inviteRemainingUses": "",
"inviteNoInvites": "",
"inviteExpiresInTime": "",
"notifyEvent": "",
@@ -102,7 +101,8 @@
"deleteTemplate": "Usuń szablon",
"templateEnterName": "Wprowadź nazwę aby zapisać szablon.",
"accessJFA": "",
"accessJFASettings": ""
"accessJFASettings": "",
"invite": "Zaproś"
},
"notifications": {
"changedEmailAddress": "Zmieniono adres email {n}.",
@@ -198,4 +198,4 @@
"plural": ""
}
}
}
}

View File

@@ -54,7 +54,6 @@
"inviteUsersCreated": "Usuários criado",
"inviteNoProfile": "Sem Perfil",
"inviteDateCreated": "Criado",
"inviteRemainingUses": "Uso restantes",
"inviteNoInvites": "Nenhum",
"inviteExpiresInTime": "Expira em {n}",
"notifyEvent": "Notificar em:",
@@ -84,7 +83,7 @@
"conditionals": "Condicionais",
"donate": "Doar",
"contactThrough": "Contato através:",
"sendPIN": "Peça que o usuário envie o PIN abaixo para o bot.",
"sendPIN": "Peça ao usuário para enviar o PIN abaixo para o bot.",
"searchDiscordUser": "Digite o nome de usuário do Discord.",
"findDiscordUser": "Encontrar usuário Discord",
"linkMatrixDescription": "Digite o nome de usuário e a senha para usar como bot. Depois de enviado, o aplicativo será reiniciado.",
@@ -103,7 +102,100 @@
"sendPWRSuccess": "Link de redefinição de senha enviado.",
"sendPWRSuccessManual": "Se o usuário não o recebeu, pressione copiar para obter um link para enviar manualmente a ele.",
"sendPWRValidFor": "O link é válido por 30m.",
"accessJFASettings": "Não pode ser alterado porque \"Só Administrador\" ou \"Permitir todos\" foi definido em Configurações> Geral."
"accessJFASettings": "Não pode ser alterado porque \"Só Administrador\" ou \"Permitir todos\" foi definido em Configurações> Geral.",
"after": "Depois",
"removeExpiry": "Remover expiração",
"enableReferrals": "Habilitar referências",
"disableReferrals": "Desativar referências",
"invite": "Convite",
"before": "Antes",
"unlink": "Desvincular conta",
"enterExpiry": "Insira um vencimento",
"useInviteExpiry": "Definir expiração do perfil/convite",
"useInviteExpiryNote": "Por padrão, os convites expiram após 90 dias, mas podem ser renovados pelo usuário. Habilite para que o encaminhamento seja desabilitado após o tempo definido.",
"noResultsFound": "Nenhum resultado encontrado",
"activity": "Atividade",
"userLabel": "Rótulo de usuário",
"userLabelDescription": "Rótulo a ser aplicado aos usuários criados com este convite.",
"deleted": "Excluído",
"disabled": "Desabilitado",
"keepSearching": "Continue procurando",
"keepSearchingDescription": "Apenas as atividades atualmente carregadas foram pesquisadas. Clique abaixo se desejar pesquisar todas as atividades.",
"enableReferralsDescription": "Forneça aos usuários um link de indicação pessoal semelhante a um convite, para enviar a amigos/familiares. Pode ser proveniente de um modelo de indicação em um perfil ou de um convite existente.",
"enableReferralsProfileDescription": "Forneça aos usuários criados com este perfil um link de indicação pessoal semelhante a um convite, para enviar a amigos/familiares. Crie um convite com as configurações desejadas e selecione-o aqui. Cada indicação será então baseada neste convite. Você pode excluir o convite depois de concluído.",
"fromInvite": "Do convite",
"inviteDeletedFilter": "Convite excluído/expirado",
"accountDisabled": "Conta desativada: {user}",
"backupsDescription": "Cópias de segurança do banco de dados podem ser feitos, restaurados ou baixados aqui.",
"backupsFormatNote": "Somente arquivos de Cópias de segurança com formato de nome padrão serão mostrados aqui. Para usar qualquer outro, carregue o backup manualmente.",
"backupCanDownload": "Como alternativa, clique abaixo para baixar Cópia de segurança.",
"inviteCreated": "Convite criado: {invite}",
"buildTime": "Hora de construir",
"inviteDeleted": "Convite excluído: {invite}",
"inviteExpired": "O convite expirou: {invite}",
"byAdmin": "Por administrador",
"byUser": "Por usuário",
"byJfaGo": "Por jfa-go",
"actor": "Ator",
"actorDescription": "O que causou essa ação. \"user\"/\"admin\"/\"daemon\" ou um nome de usuário.",
"accountCreationFilter": "Criação de conta",
"accountDeletionFilter": "Exclusão de conta",
"accountDisabledFilter": "conta desativada",
"accountEnabledFilter": "Conta ativada",
"contactLinkedFilter": "Contato Linkedin",
"contactUnlinkedFilter": "Contato não vinculado",
"passwordResetFilter": "Redefinição de senha",
"inviteCreatedFilter": "Convite criado",
"loginNotAdmin": "Você não é administrador?",
"loadedRecords": "{n} Carregado",
"shownRecords": "{n} Exibido",
"searchOptions": "Opções de busca",
"matchText": "Corresponder Texto",
"jellyfinID": "ID do Jellyfin",
"sortingBy": "Classificando por",
"sortDirection": "Classificar direção",
"settingsHiddenDependency": "As configurações correspondentes ficam ocultas porque dependem do valor de outra configuração:",
"settingsDependsOn": "{setting}: depende de {dependency}",
"settingsAdvancedMode": "{setting}: as configurações avançadas devem estar habilitadas",
"settingsMaybeUnderAdvanced": "Dica: você pode encontrar o que procura ativando Configurações avançadas.",
"accountResetPassword": "{user} redefiniu a senha",
"accountCreated": "Conta criada: {user}",
"accountDeleted": "Conta excluída: {user}",
"accountExpired": "A conta expirou: {user}",
"accountWillExpire": "A conta expirará em {data}.",
"expirationBasedOn": "Data fornecida com base no primeiro usuário.",
"userDeleted": "O usuário foi excluído.",
"userDisabled": "O usuário foi desativado",
"activityID": "ID da atividade",
"accountChangedPassword": "{user} alterou a senha",
"title": "Título",
"accountReEnabled": "Conta reativada: {user}",
"referrer": "Indicador",
"usersMentioned": "Usuário mencionado",
"passwordChangeFilter": "Senha alterada",
"loadAll": "Carregar tudo",
"loadMore": "Carregar mais",
"noMoreResults": "Não há mais resultados.",
"totalRecords": "{n} Total de registros",
"filters": "Filtros",
"clickToRemoveFilter": "Clique para remover este filtro.",
"clearSearch": "Limpar pesquisa",
"actions": "Ações",
"userPageLogin": "Página do usuário: Entrar",
"userPagePage": "Página do usuário: página",
"builtBy": "Criado por",
"backups": "Cópias de segurança",
"backupDownloadRestore": "Baixar / Restaurar",
"backupsCopy": "Ao aplicar uma Cópias de segurança, será feita uma cópia da pasta \"db\" original ao lado dele, caso algo dê errado.",
"backupUpload": "Carregar e restaurar Cópias de segurança",
"backupDownload": "Baixar cópia de segurança",
"backupRestore": "Restaurar cópia de segurança",
"backupNow": "Faça cópia de segurança agora",
"backupCreated": "Cópia de segurança criada",
"backupCanBeFound": "A Cópia de segurança pode ser encontrado no servidor em {filepath}.",
"wikiPage": "Página Wiki",
"accountLinked": "{contactMethod} vinculado: {user}",
"accountUnlinked": "{contactMethod} removido: {user}"
},
"notifications": {
"changedEmailAddress": "Endereço de e-mail alterado de {n}.",
@@ -141,7 +233,15 @@
"accountConnected": "Conta conectada.",
"savedAnnouncement": "Anúncio salvo.",
"setOmbiProfile": "Perfil ombi armazenado.",
"errorSetOmbiProfile": "Falha ao armazenar o perfil ombi."
"errorSetOmbiProfile": "Falha ao armazenar o perfil ombi.",
"errorNoReferralTemplate": "O perfil não contém modelo de referência. Adicione um nas configurações.",
"pathCopied": "Caminho completo copiado para a área de transferência.",
"referralsEnabled": "Referências habilitadas.",
"activityDeleted": "Atividade excluída.",
"errorInviteNoLongerExists": "O convite não existe mais.",
"errorInviteNotFound": "Convite não encontrado.",
"errorLoadActivities": "Falha ao carregar atividades.",
"errorInvalidDate": "A data é inválida."
},
"quantityStrings": {
"modifySettingsFor": {
@@ -199,6 +299,10 @@
"setExpiry": {
"singular": "Definir expiração para {a} usuário",
"plural": "Definir expiração para {a} usuários"
},
"enableReferralsFor": {
"plural": "Ativar referências para {n} usuários",
"singular": "Ativar referências para {n} usuário"
}
}
}
}

View File

@@ -65,7 +65,6 @@
"inviteUsersCreated": "Skapade användare",
"inviteNoProfile": "Ingen profil",
"inviteDateCreated": "Skapad",
"inviteRemainingUses": "Återstående användningar",
"inviteNoInvites": "Ingen",
"inviteExpiresInTime": "Går ut om {n}",
"notifyEvent": "Meddela den:",

View File

@@ -86,7 +86,6 @@
"inviteUsersCreated": "Người dùng đã tạo",
"inviteNoProfile": "Không có Tài khoản mẫu",
"inviteDateCreated": "Tạo",
"inviteRemainingUses": "Số lần sử dụng còn lại",
"inviteNoInvites": "Không có",
"inviteExpiresInTime": "Hết hạn trong {n}",
"notifyEvent": "Thông báo khi:",

View File

@@ -69,7 +69,7 @@
"ombiUserDefaults": "Ombi 用户默认值",
"ombiUserDefaultsDescription": "创建并配置 Ombi 用户,然后在下面选择它。它的设置/权限将被存储并应用于由 jfa-go 创建的新 Ombi 用户。",
"userProfiles": "用户档案",
"userProfilesDescription": "配置文件在用户创建帐户时应用于用户。配置文件包括库访问权限和主屏幕布局。",
"userProfilesDescription": "个人资料在用户创建帐户时应用于他们。个人资料包括库访问权限和主屏幕布局。",
"userProfilesIsDefault": "默认",
"userProfilesLibraries": "库",
"addProfile": "添加档案",
@@ -80,7 +80,6 @@
"inviteUsersCreated": "已创建的用户",
"inviteNoProfile": "没有个人资料",
"inviteDateCreated": "已创建",
"inviteRemainingUses": "剩余使用次数",
"inviteNoInvites": "无",
"inviteExpiresInTime": "在 {n} 到期",
"notifyEvent": "通知:",
@@ -103,7 +102,100 @@
"sendPWRValidFor": "此链接有效30分钟。",
"ombiProfile": "Ombi 用户配置文件",
"accessJFASettings": "无法更改,因为“仅限管理员”或“允许所有”已在“设置”>“常规”中设置。",
"accessJFA": "访问jfa-go"
"accessJFA": "访问 jfa-go",
"buildTime": "构建时间",
"builtBy": "由",
"clickToRemoveFilter": "单击此处以取消此筛选器。",
"filters": "筛选器",
"jellyfinID": "Jellyfin ID",
"clearSearch": "清除搜索",
"searchOptions": "搜索选项",
"matchText": "匹配文本",
"userPagePage": "用户页面",
"actions": "操作",
"after": "之后",
"before": "之前",
"unlink": "取消关联帐户",
"sortingBy": "排序方式",
"userPageLogin": "用户页面:登录",
"activity": "活动",
"userLabelDescription": "标签应用于使用此邀请创建的用户。",
"disabled": "禁用",
"keepSearchingDescription": "只有当前加载的活动被搜索了。如果您想搜索所有活动,请点击下方。",
"enableReferralsDescription": "为用户提供一个个人的推荐链接,类似于邀请,以便他们发送给朋友和家人。可以从个人资料中的推荐模板获取,或从现有的邀请中获取。",
"userDeleted": "用户已被删除。",
"inviteCreated": "邀请已创建:{invite}",
"usersMentioned": "用户提到的",
"actorDescription": "引起这个操作的事物。可以是“用户”、“管理员”、“守护程序”或用户名。",
"loginNotAdmin": "不是管理员?",
"invite": "邀请",
"noResultsFound": "没有发现任何结果",
"settingsHiddenDependency": "匹配设置被隐藏,因为它们取决于另一个设置的值:",
"settingsDependsOn": "{setting}:依赖于 {dependency}",
"settingsAdvancedMode": "{setting}:必须启用高级设置",
"settingsMaybeUnderAdvanced": "提示:通过启用高级设置,您可能会找到您正在寻找的内容。",
"userLabel": "用户标签",
"deleted": "删除",
"keepSearching": "继续搜索",
"enableReferrals": "启用推荐",
"disableReferrals": "禁用推荐",
"enableReferralsProfileDescription": "为使用该个人资料创建的用户提供一个类似邀请的个人推荐链接,以便他们发送给朋友和家人。创建一个具有所需设置的邀请,然后在此处进行选择。然后,每个推荐都将基于这个邀请。完成后,您可以删除邀请。",
"sortDirection": "排序方向",
"referrer": "推荐人",
"accountLinked": "{contactMethod} 已关联:{user}",
"accountUnlinked": "{contactMethod} 已移除:{user}",
"accountResetPassword": "{user} 重置了他们的密码",
"accountChangedPassword": "{user} 更改了他们的密码",
"accountCreated": "账户已创建:{user}",
"accountDeleted": "账户已删除:{user}",
"accountDisabled": "账户已禁用:{user}",
"accountReEnabled": "账户已重新启用:{user}",
"accountExpired": "账户已过期:{user}",
"userDisabled": "用户已被禁用",
"inviteDeleted": "邀请已删除:{invite}",
"inviteExpired": "邀请已过期:{invite}",
"fromInvite": "来自邀请",
"byAdmin": "由管理员发起的",
"byUser": "由用户发起的",
"byJfaGo": "由jfa-go发起的",
"activityID": "活动ID",
"title": "标题",
"actor": "角色",
"accountCreationFilter": "账户创建",
"accountDeletionFilter": "账户删除",
"accountDisabledFilter": "账户禁用",
"accountEnabledFilter": "账户启用",
"contactLinkedFilter": "联系方式已关联",
"contactUnlinkedFilter": "联系方式未关联",
"passwordChangeFilter": "密码已更改",
"passwordResetFilter": "密码重置",
"inviteCreatedFilter": "邀请已创建",
"inviteDeletedFilter": "邀请已删除/过期",
"loadMore": "加载更多",
"loadAll": "加载全部",
"noMoreResults": "没有更多结果了。",
"totalRecords": "{n} 总记录数",
"loadedRecords": "已加载{n}",
"shownRecords": "已显示{n}",
"removeExpiry": "用户过期删除时间",
"useInviteExpiryNote": "在默认情况下邀请会在90天之后过期但是用户可以手动续期该邀请。启动该设置手动设置有效期后会关闭推荐设置。",
"accountWillExpire": "账户将在{date}后过期。",
"expirationBasedOn": "根据第一个用户给出的日期。",
"backupsFormatNote": "此处仅显示具有标准名称格式的备份文件。如要使用其他名称的备份,请手动上传。",
"backupCanDownload": "或者,单击下面的按钮下载备份。",
"enterExpiry": "输入自定义过期时间",
"useInviteExpiry": "设置个人资料/邀请的有效期",
"backups": "备份设置",
"backupsDescription": "可以从这里制作、恢复或下载数据库的备份。",
"backupsCopy": "当在使用备份文件恢复时程序将创建原始“db”文件夹的副本以防出现问题。",
"backupDownloadRestore": "下载/恢复",
"backupUpload": "上传和恢复备份",
"backupRestore": "恢复备份",
"backupDownload": "下载备份",
"backupNow": "立即备份",
"backupCreated": "备份已创建",
"backupCanBeFound": "该备份可以在服务器上的 {filepath} 处找到。",
"wikiPage": "帮助文档"
},
"notifications": {
"changedEmailAddress": "更改了 {n} 的电子邮件地址。",
@@ -141,7 +233,15 @@
"updateAvailable": "有新更新可用,请检查设置。",
"noUpdatesAvailable": "没有可用的更新。",
"setOmbiProfile": "保存ombi配置文件。",
"errorSetOmbiProfile": "无法保存ombi配置文件。"
"errorSetOmbiProfile": "无法保存ombi配置文件。",
"activityDeleted": "活动已删除。",
"errorNoReferralTemplate": "个人资料不包含推荐模板,请在设置中添加一个。",
"referralsEnabled": "已启用推荐。",
"errorInviteNoLongerExists": "邀请已不存在。",
"errorInviteNotFound": "未找到邀请。",
"errorLoadActivities": "无法加载活动。",
"pathCopied": "完整路径已复制到剪贴板。",
"errorInvalidDate": "日期无效。"
},
"quantityStrings": {
"modifySettingsFor": {
@@ -199,6 +299,10 @@
"setExpiry": {
"plural": "为{n}用户设置到期时间",
"singular": "为{n}用户设置到期时间"
},
"enableReferralsFor": {
"singular": "为{n}用户启用推荐功能",
"plural": "为{n}个用户启用推荐功能"
}
}
}
}

View File

@@ -87,7 +87,6 @@
"inviteUsersCreated": "創建的帳戶",
"inviteNoProfile": "無資料",
"inviteDateCreated": "已創建",
"inviteRemainingUses": "剩餘使用次數",
"inviteNoInvites": "無",
"inviteExpiresInTime": "在 {n} 到期",
"notifyEvent": "通知:",

View File

@@ -2,7 +2,64 @@
"meta": {
"name": "العربية (AR)"
},
"strings": {},
"notifications": {},
"quantityStrings": {}
}
"strings": {
"username": "اسم المستخدم",
"password": "كلمة المرور",
"emailAddress": "البريد الالكتروني",
"name": "الاسم",
"submit": "ادخال",
"success": "نجاح",
"continue": "اكمل",
"error": "خطأ",
"copy": "نسخ",
"time24h": "توقيت 24 ساعة",
"time12h": "توقيت 12 ساعة",
"linkTelegram": "رابط تلغرام",
"contactTelegram": "التواصل عبر التلغرام",
"linkDiscord": "رابط الدسكورد",
"linkMatrix": "ربط Matrix",
"contactDiscord": "التواصل عبر الدسكورد",
"theme": "القالب",
"refresh": "تحديث",
"required": "مطلوب",
"login": "تسجيل الدخول",
"admin": "المسؤول",
"reEnable": "اعادة تفعيل",
"disable": "تجميد",
"accountStatus": "حالة الحساب",
"notSet": "لم تحدد",
"expiry": "انتهاء الصلاحية",
"add": "اضافة",
"edit": "تعديل",
"delete": "حذف",
"myAccount": "حسابي",
"disabled": "معطل",
"enabled": "مفعل",
"send": "ارسال",
"copied": "تم النسخ",
"contactEmail": "التواصل عبر البريد الالكتروني",
"logout": "تسجيل الخروج",
"contactMethods": "وسيلة التواصل"
},
"notifications": {
"errorUnknown": "خطأ غير معروف.",
"error401Unauthorized": "غير مخول. حاول تحديث الصفحة.",
"errorSaveSettings": "لا يمكن حفظ الاعدادات.",
"errorLoginBlank": "اسم المستخدم و/أو كلمة المرور لم يتم ادخالها.",
"errorConnection": "لا يمكن الاتصال بـالبرنامج."
},
"quantityStrings": {
"year": {
"singular": "{n} سنة",
"plural": "{n} سنوات"
},
"month": {
"singular": "{n} شهر",
"plural": "{n} أشهر"
},
"day": {
"singular": "{n} يوم",
"plural": "{n} أيام"
}
}
}

67
lang/common/cs-cz.json Normal file
View File

@@ -0,0 +1,67 @@
{
"meta": {
"name": "Čeština (CZ)"
},
"strings": {
"username": "Uživatelské jméno",
"password": "Heslo",
"emailAddress": "Emailová adresa",
"name": "Název",
"submit": "Odeslat",
"send": "Poslat",
"success": "Hotovo",
"continue": "Pokračovat",
"error": "Chyba",
"copy": "Kopírovat",
"copied": "Zkopírováno",
"time24h": "Čas 24 hodin",
"time12h": "Čas 12 hodin",
"linkTelegram": "Link Telegram",
"contactEmail": "Kontakt přes Email",
"contactTelegram": "Kontakt přes Telegram",
"linkDiscord": "Link Discord",
"linkMatrix": "Link Matrix",
"contactDiscord": "Kontakt přes Discord",
"theme": "Téma",
"refresh": "Obnovit",
"required": "Požadované",
"login": "Přihlásit se",
"logout": "Odhlásit se",
"admin": "Admin",
"enabled": "Povoleno",
"disabled": "Zakázáno",
"reEnable": "Znovu povolit",
"disable": "Zakázat",
"contactMethods": "Kontaktní metody",
"accountStatus": "Stav účtu",
"notSet": "Nenastaveno",
"expiry": "Uplynutí",
"add": "Přidat",
"edit": "Upravit",
"delete": "Vymazat",
"myAccount": "Můj účet",
"referrals": "Doporučení",
"inviteRemainingUses": "Zbývající použití"
},
"notifications": {
"errorLoginBlank": "Uživatelské jméno a/nebo heslo zůstalo prázdné.",
"errorConnection": "Nelze se připojit k jfa-go.",
"errorUnknown": "Neznámá chyba.",
"error401Unauthorized": "Neoprávněný. Zkuste stránku obnovit.",
"errorSaveSettings": "Nastavení se nepodařilo uložit."
},
"quantityStrings": {
"year": {
"singular": "{n} rok",
"plural": "{n} let"
},
"month": {
"singular": "{n} měsíc",
"plural": "{n} měsíců"
},
"day": {
"singular": "{n} den",
"plural": "{n} dní"
}
}
}

View File

@@ -5,7 +5,7 @@
"strings": {
"username": "Brugernavn",
"password": "Adgangskode",
"emailAddress": "E-mail Adresse",
"emailAddress": "Email adresse",
"name": "Navn",
"submit": "Indsend",
"send": "Send",
@@ -35,7 +35,13 @@
"expiry": "Udløb",
"add": "Tilføj",
"edit": "Rediger",
"delete": "Slet"
"delete": "Slet",
"inviteRemainingUses": "Resterende anvendelser",
"referrals": "Henvisninger",
"contactMethods": "Kontakt Metoder",
"accountStatus": "Kontostatus",
"notSet": "Ikke sat",
"myAccount": "Min Konto"
},
"notifications": {
"errorLoginBlank": "Brugernavnet og/eller adgangskoden blev efterladt tomme.",
@@ -44,5 +50,18 @@
"error401Unauthorized": "Adgang nægtet. Prøv at genindlæse siden.",
"errorSaveSettings": "Kunne ikke gemme indstillingerne."
},
"quantityStrings": {}
}
"quantityStrings": {
"year": {
"singular": "{n} År",
"plural": "{n} År"
},
"month": {
"singular": "{n} Månede",
"plural": "{n} Måneder"
},
"day": {
"singular": "{n} Dag",
"plural": "{n} Dage"
}
}
}

View File

@@ -35,7 +35,8 @@
"expiry": "Ablaufdatum",
"add": "Hinzufügen",
"edit": "Bearbeiten",
"delete": "Löschen"
"delete": "Löschen",
"inviteRemainingUses": "Verbleibende Verwendungen"
},
"notifications": {
"errorLoginBlank": "Der Benutzername und/oder das Passwort wurden nicht ausgefüllt.",

View File

@@ -25,7 +25,8 @@
"disable": "Απενεργοποίηση",
"expiry": "Λήξη",
"edit": "Επεξεργασία",
"delete": "Διαγραφή"
"delete": "Διαγραφή",
"inviteRemainingUses": "Εναπομείναντες χρήσεις"
},
"notifications": {
"errorLoginBlank": "Το όνομα χρήστη και/ή ο κωδικός ήταν κενά.",

View File

@@ -35,7 +35,8 @@
"expiry": "Expiry",
"add": "Add",
"edit": "Edit",
"delete": "Delete"
"delete": "Delete",
"inviteRemainingUses": "Remaining uses"
},
"notifications": {
"errorLoginBlank": "The username and/or password was left blank.",

View File

@@ -39,7 +39,9 @@
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"myAccount": "My Account"
"myAccount": "My Account",
"referrals": "Referrals",
"inviteRemainingUses": "Remaining uses"
},
"notifications": {
"errorLoginBlank": "The username and/or password were left blank.",
@@ -62,4 +64,4 @@
"plural": "{n} Days"
}
}
}
}

View File

@@ -35,7 +35,13 @@
"expiry": "Expiración",
"add": "Agregar",
"edit": "Editar",
"delete": "Eliminar"
"delete": "Eliminar",
"inviteRemainingUses": "Usos restantes",
"contactMethods": "Métodos de contacto",
"accountStatus": "Estado de la cuenta",
"notSet": "No establecido",
"myAccount": "Mi cuenta",
"referrals": "Referencias"
},
"notifications": {
"errorLoginBlank": "El nombre de usuario y/o la contraseña se dejaron en blanco.",
@@ -44,5 +50,18 @@
"error401Unauthorized": "No autorizado. Intente actualizar la página.",
"errorSaveSettings": "No se pudo guardar la configuración."
},
"quantityStrings": {}
}
"quantityStrings": {
"year": {
"plural": "{n} años",
"singular": "{n} año"
},
"month": {
"singular": "{n} mes",
"plural": "{n} meses"
},
"day": {
"singular": "{n} día",
"plural": "{n} días"
}
}
}

View File

@@ -5,7 +5,7 @@
"strings": {
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"emailAddress": "Adresse e-mail",
"emailAddress": "Adresse mail",
"name": "Nom",
"submit": "Soumettre",
"send": "Envoyer",
@@ -31,11 +31,17 @@
"enabled": "Activé",
"disabled": "Désactivé",
"reEnable": "Ré-activé",
"disable": "Désactivé",
"disable": "Désactiver",
"expiry": "Expiration",
"add": "Ajouter",
"edit": "Éditer",
"delete": "Effacer"
"delete": "Effacer",
"inviteRemainingUses": "Utilisations restantes",
"accountStatus": "Statut du compte",
"notSet": "Non défini",
"myAccount": "Mon compte",
"contactMethods": "Moyens de contact",
"referrals": "Programme de parrainage"
},
"notifications": {
"errorLoginBlank": "Le nom d'utilisateur et/ou le mot de passe sont vides.",
@@ -44,5 +50,18 @@
"error401Unauthorized": "Non autorisé. Essayez d'actualiser la page.",
"errorSaveSettings": "Impossible d'enregistrer les paramètres."
},
"quantityStrings": {}
}
"quantityStrings": {
"year": {
"plural": "{n} années",
"singular": "{n} année"
},
"day": {
"singular": "{n} jour",
"plural": "{n} jours"
},
"month": {
"singular": "{n} mois",
"plural": "{n} mois"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"meta": {
"name": "English (US)"
"name": "Magyar (HU)"
},
"strings": {
"login": "Belépés",
@@ -13,8 +13,53 @@
"expiry": "Lejárat",
"add": "Hozzáadás",
"edit": "Szerkesztés",
"delete": "Törlés"
"delete": "Törlés",
"password": "Jelszó",
"username": "Felhasználónév",
"emailAddress": "E-mail cím",
"name": "Név",
"submit": "Mentés",
"send": "Küldés",
"success": "Siker",
"continue": "Folytatás",
"error": "Hiba",
"copy": "Másolás",
"copied": "Másolva",
"time24h": "24 órás idő",
"time12h": "12 órás idő",
"linkTelegram": "Telegram összekötése",
"contactEmail": "Kapcsolat e-mailen keresztül",
"contactTelegram": "Kapcsolat telegramon keresztül",
"linkDiscord": "Discord összekötése",
"linkMatrix": "Matrix összekötése",
"contactDiscord": "Kapcsolat discordon keresztül",
"theme": "Téma",
"refresh": "Frissítés",
"required": "Kötelező",
"contactMethods": "Kapcsolati lehetőségek",
"accountStatus": "Fiók státusz",
"notSet": "Nincs beállítva",
"myAccount": "Saját fiókom"
},
"notifications": {},
"quantityStrings": {}
}
"notifications": {
"errorLoginBlank": "A felhasználónév és/vagy a jelszó üresen lett hagyva.",
"errorConnection": "Nem lehet csatlakozni a jfa-go-hoz.",
"errorUnknown": "Ismeretlen hiba.",
"error401Unauthorized": "Nincs jogosultság. Próbáld frissíteni az oldalt.",
"errorSaveSettings": "Nem lehet menteni a beállításokat."
},
"quantityStrings": {
"year": {
"singular": "{n} Év",
"plural": "{n} Évek"
},
"month": {
"singular": "{n} Hónap",
"plural": "{n} Hónapok"
},
"day": {
"singular": "{n} Nap",
"plural": "{n} Napok"
}
}
}

View File

@@ -19,14 +19,14 @@
"login": "Masuk",
"logout": "Keluar",
"edit": "Edit",
"delete": "Hapus"
"delete": "Hapus",
"inviteRemainingUses": "Penggunaan yang tersisa"
},
"notifications": {
"errorLoginBlank": "Nama pengguna dan / atau sandi kosong.",
"errorConnection": "Tidak dapat terhubung ke jfa-go.",
"errorUnknown": "Kesalahan yang tidak diketahui.",
"errorUnknown": "Terjadi kesalahan yang tidak diketahui.",
"error401Unauthorized": "Tidak ter-otorisasi. Coba segarkan halaman.",
"errorSaveSettings": "Tidak dapat menyimpan pengaturan."
},
"quantityStrings": {}
}
}
}

View File

@@ -19,13 +19,47 @@
"linkTelegram": "Link Telegram",
"contactEmail": "Contatta tramite Email",
"contactTelegram": "Contatta tramite Telegram",
"linkDiscord": "Link Discord",
"linkDiscord": "Collega Discord",
"linkMatrix": "Link Matrix",
"contactDiscord": "Contatta tramite Discord",
"theme": "Tema",
"refresh": "Aggiorna",
"required": "Richiesto"
"required": "Richiesto",
"accountStatus": "Stato Account",
"login": "Login",
"admin": "Admin",
"enabled": "Abilitato",
"disabled": "Disabilitato",
"reEnable": "Riabilita",
"disable": "Disattiva",
"contactMethods": "Metodo di contatto",
"notSet": "Non impostato",
"expiry": "Scadenza",
"add": "Aggiungi",
"edit": "Modifica",
"delete": "Elimina",
"myAccount": "Il mio Account",
"logout": "Esci"
},
"notifications": {},
"quantityStrings": {}
"notifications": {
"errorUnknown": "Errore sconosciuto.",
"errorLoginBlank": "L'username o password sono stati lasciati vuoti.",
"errorConnection": "Non riesco a connettermi a jfa-go.",
"error401Unauthorized": "Non autorizzato. Prova a ricaricare la pagina.",
"errorSaveSettings": "Impossibile salvare impostazione."
},
"quantityStrings": {
"year": {
"singular": "{n} Anno",
"plural": "{n} Anni"
},
"month": {
"singular": "{n} Mese",
"plural": "{n} Mesi"
},
"day": {
"singular": "{n} Giorno",
"plural": "{n} Giorni"
}
}
}

8
lang/common/nds.json Normal file
View File

@@ -0,0 +1,8 @@
{
"meta": {
"name": "Nedderdütsch (NDS)"
},
"strings": {},
"notifications": {},
"quantityStrings": {}
}

View File

@@ -35,7 +35,13 @@
"expiry": "Verloop",
"add": "Voeg toe",
"edit": "Bewerken",
"delete": "Verwijderen"
"delete": "Verwijderen",
"inviteRemainingUses": "Resterend aantal keer te gebruiken",
"referrals": "Verwijzingen",
"contactMethods": "Contactmethodes",
"accountStatus": "Account status",
"notSet": "Niet ingesteld",
"myAccount": "Mijn account"
},
"notifications": {
"errorLoginBlank": "De gebruikersnaam en/of wachtwoord is leeg.",
@@ -44,5 +50,18 @@
"error401Unauthorized": "Geen toegang. Probeer de pagina te vernieuwen.",
"errorSaveSettings": "Opslaan van instellingen mislukt."
},
"quantityStrings": {}
}
"quantityStrings": {
"year": {
"singular": "{n} jaar",
"plural": "{n} jaar"
},
"month": {
"singular": "{n} maand",
"plural": "{n} maanden"
},
"day": {
"singular": "{n} dag",
"plural": "{n} dagen"
}
}
}

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