Compare commits

..

109 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
102 changed files with 3728 additions and 1109 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"'
@@ -128,9 +125,6 @@ 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
@@ -141,11 +135,10 @@ steps:
from_secret: ssh2_username
port:
from_secret: ssh2_port
volumes:
- /root/.ssh/docker-build:/root/drone_rsa
envs:
- buildrone_key
key_path: /root/drone_rsa
key:
from_secret: ssh2_key
command_timeout: 50m
script:
- /mnt/buildx/jfa-go/build.sh
@@ -160,10 +153,6 @@ trigger:
exclude:
- pull_request
volumes:
- name: ssh_key
host:
path: /root/.ssh/docker-build
---
name: jfa-go-pr
kind: pipeline
@@ -175,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:

View File

@@ -1,3 +1,5 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
version: 2
project_name: jfa-go
release:
github:
@@ -78,6 +80,9 @@ 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:

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

@@ -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,32 +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.
* 🔗 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.
* 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. Custom messages can be added, with markdown.
* Referrals: Users can be given special invites to send to their friends and families.
* 📨 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
* "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.
* 📣 Announcements: Bulk message your users with announcements about your server.
* Authentication via Jellyfin: Instead of using separate credentials for jfa-go and Jellyfin, jfa-go can use it as the authentication provider.
* Enables the usage of jfa-go by multiple people
* 🌓 Customizations
* Customize emails with variables and markdown
* Specify contact and help messages to appear in emails and pages
@@ -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/)
@@ -175,4 +161,3 @@ For translations, use the weblate instance [here](https://weblate.jfa-go.com/eng
Big thanks to those who sponsor me. You can see them below:
[<img src="https://sponsors-endpoint.hrfee.pw/sponsor/avatar/0" width="35">](https://sponsors-endpoint.hrfee.pw/sponsor/profile/0)
[<img src="https://sponsors-endpoint.hrfee.pw/sponsor/avatar/1" width="35">](https://sponsors-endpoint.hrfee.pw/sponsor/profile/0)

View File

@@ -138,6 +138,7 @@ func (app *appContext) GetActivities(gc *gin.Context) {
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

View File

@@ -102,7 +102,7 @@ func (app *appContext) checkInvites() {
InviteCode: data.Code,
Value: data.Label,
Time: time.Now(),
})
}, nil, false)
}
}
@@ -161,7 +161,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
InviteCode: code,
Value: inv.Label,
Time: time.Now(),
})
}, nil, false)
} else if used {
del := false
newInv := inv
@@ -174,7 +174,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
InviteCode: code,
Value: inv.Label,
Time: time.Now(),
})
}, nil, false)
} else if newInv.RemainingUses != 0 {
// 0 means infinite i guess?
newInv.RemainingUses--
@@ -285,7 +285,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
InviteCode: invite.Code,
Value: invite.Label,
Time: time.Now(),
})
}, gc, false)
respondBool(200, true, gc)
}
@@ -305,7 +305,8 @@ func (app *appContext) GetInvites(gc *gin.Context) {
if inv.IsReferral {
continue
}
_, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
years, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
months += years * 12
invite := inviteDTO{
Code: inv.Code,
Months: months,
@@ -492,7 +493,7 @@ func (app *appContext) DeleteInvite(gc *gin.Context) {
InviteCode: req.Code,
Value: inv.Label,
Time: time.Now(),
})
}, gc, false)
app.info.Printf("%s: Invite deleted", req.Code)
respondBool(200, true, gc)

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,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/jellyseerr"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
)
@@ -26,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("UserLogin").Enabled},
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("UserPage").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")
@@ -51,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)."
@@ -177,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 {
@@ -217,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)
@@ -237,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
@@ -270,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})
}
@@ -329,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)
}
@@ -353,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
@@ -363,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 {
@@ -375,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 {
@@ -399,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)
@@ -573,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
@@ -608,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
@@ -639,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")
@@ -665,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
@@ -681,6 +697,13 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
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,
@@ -688,7 +711,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
})
}, gc, false)
linkExistingOmbiDiscordTelegram(app)
respondBool(200, true, gc)
@@ -699,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
@@ -710,6 +734,14 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
} */
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,
@@ -717,7 +749,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
})
}, gc, false)
respondBool(200, true, gc)
}
@@ -727,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
@@ -738,6 +771,13 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
} */
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,
@@ -745,7 +785,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
})
}, gc, false)
respondBool(200, true, gc)
}
@@ -755,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
@@ -773,7 +814,7 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
})
}, gc, false)
respondBool(200, true, gc)
}

View File

@@ -2,6 +2,7 @@ package main
import (
"fmt"
"net/url"
"github.com/gin-gonic/gin"
"github.com/hrfee/mediabrowser"
@@ -95,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)
@@ -122,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)

View File

@@ -27,6 +27,7 @@ func (app *appContext) GetProfiles(gc *gin.Context) {
LibraryAccess: p.LibraryAccess,
FromUser: p.FromUser,
Ombi: p.Ombi != nil,
Jellyseerr: p.Jellyseerr.Enabled,
ReferralsEnabled: false,
}
if referralsEnabled {

View File

@@ -8,6 +8,7 @@ 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"
)
@@ -200,14 +201,7 @@ 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)
app.modifyEmail(id, claims["email"].(string))
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
@@ -216,18 +210,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
Source: gc.GetString("jfId"),
Value: "email",
Time: time.Now(),
})
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)
}
}
}
}, gc, true)
app.info.Println("Email list modified")
gc.Redirect(http.StatusSeeOther, "/my/account")
@@ -371,6 +354,13 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
}
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"),
@@ -378,7 +368,7 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
})
}, gc, true)
respondBool(200, true, gc)
}
@@ -419,6 +409,13 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
}
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"),
@@ -426,7 +423,7 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
})
}, gc, true)
respondBool(200, true, gc)
}
@@ -507,7 +504,7 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
})
}, gc, true)
delete(app.matrix.tokens, pin)
respondBool(200, true, gc)
@@ -522,6 +519,13 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
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"),
@@ -529,7 +533,7 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
})
}, gc, true)
respondBool(200, true, gc)
}
@@ -543,6 +547,13 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
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"),
@@ -550,7 +561,7 @@ func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
})
}, gc, true)
respondBool(200, true, gc)
}
@@ -571,7 +582,7 @@ func (app *appContext) UnlinkMyMatrix(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
})
}, gc, true)
respondBool(200, true, gc)
}
@@ -701,7 +712,7 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
SourceType: ActivityUser,
Source: user.ID,
Time: time.Now(),
})
}, gc, true)
if app.config.Section("ombi").Key("enabled").MustBool(false) {
func() {

View File

@@ -2,12 +2,14 @@ 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"
@@ -55,7 +57,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: user.Name,
Time: time.Now(),
})
}, gc, false)
profile := app.storage.GetDefaultProfile()
if req.Profile != "" && req.Profile != "none" {
@@ -93,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)
@@ -114,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) {
@@ -292,24 +317,24 @@ 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)
}
}
}
@@ -331,12 +356,16 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
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
@@ -467,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)
@@ -503,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
@@ -539,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
@@ -609,7 +683,7 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Time: time.Now(),
})
}, gc, false)
if sendMail && req.Notify {
if err := app.sendByID(msg, userID); err != nil {
@@ -687,7 +761,7 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: username,
Time: time.Now(),
})
}, gc, false)
if sendMail && req.Notify {
if err := app.sendByID(msg, userID); err != nil {
@@ -738,6 +812,22 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
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)
}
@@ -927,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
@@ -1162,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"
@@ -1180,22 +1313,10 @@ 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
}
emailStore.Addr = address
app.storage.SetEmailsKey(id, emailStore)
app.modifyEmail(id, address)
activityType := ActivityContactLinked
if address == "" {
@@ -1208,18 +1329,7 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "email",
Time: time.Now(),
})
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)
}
}
}
}, gc, false)
}
}
app.info.Println("Email list modified")
@@ -1243,6 +1353,8 @@ 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)
@@ -1260,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"
@@ -1277,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 {
@@ -1293,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
@@ -1303,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)
@@ -1345,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)
}

12
api.go
View File

@@ -114,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 {
@@ -121,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
@@ -138,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)
@@ -179,7 +187,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
SourceType: ActivityUser,
Source: user.ID,
Time: time.Now(),
})
}, gc, true)
prevPassword := req.PIN
if isInternal {

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})
}

View File

@@ -161,20 +161,13 @@ func (app *appContext) loadPendingBackup() {
LOADBAK = ""
}
func newBackupDaemon(app *appContext) *housekeepingDaemon {
func newBackupDaemon(app *appContext) *GenericDaemon {
interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute
daemon := housekeepingDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
}
daemon.jobs = []func(app *appContext){
d := NewGenericDaemon(interval, app,
func(app *appContext) {
app.debug.Println("Backups: Creating backup")
app.makeBackup()
},
}
return &daemon
)
return d
}

View File

@@ -105,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")
@@ -120,6 +123,9 @@ func (app *appContext) loadConfig() error {
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()

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,7 @@
"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",
@@ -297,6 +297,29 @@
"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,
@@ -597,7 +620,7 @@
"order": [],
"meta": {
"name": "User Page/\"My Account\"",
"description": "The User Page (My Account) allows users to access and modify info directly, such as changing/adding contact methods, seeing their expiry date, sending referrals or changing their password. Password resets can also be initiated from here, given a contact method or username. ",
"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": {
@@ -679,7 +702,7 @@
"description": "Allow users to start a Password Reset by inputting their Discord/Telegram/Matrix username/id."
},
"pwr_note": {
"name": "PWR Methods",
"name": "PWR Methods:",
"type": "note",
"depends_true": "enabled",
"value": "",
@@ -1557,6 +1580,66 @@
}
}
},
"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": {
@@ -1690,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": {
@@ -1742,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

@@ -106,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;
@@ -172,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;
@@ -219,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 */
}
@@ -300,10 +179,6 @@ sup.\~critical, .text-critical {
width: 100%;
}
.flex-auto {
flex: auto;
}
.center {
justify-content: center;
}
@@ -312,14 +187,6 @@ sup.\~critical, .text-critical {
align-items: center;
}
.no-lp {
padding-left: 0px;
}
.block {
display: block;
}
.focused {
display: block;
}
@@ -416,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;
@@ -575,7 +451,6 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
cursor: pointer;
}
.g-recaptcha {
overflow: hidden;
width: 296px;
@@ -587,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

@@ -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;
}

View File

@@ -74,6 +74,17 @@ func (app *appContext) clearTelegram() {
}
}
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 (app *appContext) clearActivities() {
app.debug.Println("Housekeeping: Cleaning up Activity log...")
keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000)
@@ -105,83 +116,42 @@ func (app *appContext) clearActivities() {
}
}
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
type housekeepingDaemon struct {
Stopped bool
ShutdownChannel chan string
Interval time.Duration
period time.Duration
jobs []func(app *appContext)
app *appContext
}
func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemon {
daemon := housekeepingDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
}
daemon.jobs = []func(app *appContext){
func 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

@@ -741,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"),

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)
}

11
go.mod
View File

@@ -16,8 +16,11 @@ 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
@@ -28,12 +31,13 @@ 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-20230626224816-f72960635dc3
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.12
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.1
@@ -54,7 +58,6 @@ require (
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dgraph-io/badger/v3 v3.2103.5 // 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
@@ -87,7 +90,7 @@ require (
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/easyproxy v0.0.0-00010101000000-000000000000 // indirect
github.com/hrfee/jfa-go/jellyseerr v0.0.0-00010101000000-000000000000 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.16.6 // indirect

13
go.sum
View File

@@ -224,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.12 h1:fqDxt1be3e+ZNjAtlKc8MTqg7peo6fuGCrk2wOXo20k=
github.com/hrfee/mediabrowser v0.3.12/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=
@@ -394,8 +394,6 @@ 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=
@@ -437,8 +435,6 @@ 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.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
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=
@@ -478,8 +474,6 @@ 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.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU=
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
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=
@@ -523,8 +517,6 @@ golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBc
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.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.9.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=
@@ -536,7 +528,6 @@ 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.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
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=

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 }};
@@ -26,7 +27,7 @@
<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 }}">
@@ -43,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>
@@ -80,46 +81,60 @@
</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 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>
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
<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="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="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>
<div class="select ~neutral @low mb-4">
<select id="modify-user-profiles"></select>
</div>
<div class="select ~neutral @low mb-4 unfocused">
<select id="modify-user-users"></select>
</div>
<label class="switch mb-4">
<input type="checkbox" id="modify-user-homescreen" checked>
<span>{{ .strings.applyHomescreenLayout }}</span>
</label>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
</label>
</form>
</div>
{{ if .referralsEnabled }}
<div id="modal-enable-referrals-user" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-enable-referrals-user" href="">
<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="flex-row-group mr-2">
<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">
<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>
@@ -142,7 +157,7 @@
</form>
</div>
<div id="modal-enable-referrals-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-enable-referrals-profile" href="">
<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>
@@ -162,7 +177,7 @@
</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">
@@ -178,7 +193,7 @@
</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">
<aside class="aside sm ~urge dark:~d_info mb-2 @low row unfocused" id="extend-expiry-date"></aside>
@@ -242,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>
@@ -259,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>
@@ -275,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>
@@ -297,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>
@@ -319,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">
@@ -329,7 +345,7 @@
</div>
</div>
<div id="modal-backups" 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-2/3">
<span class="heading">{{ .strings.backups }} <span class="modal-close">&times;</span></span>
<div class="content my-4">
{{ .strings.backupsDescription }}
@@ -345,7 +361,7 @@
<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">
<div class="overflow-x-auto text-xs md:text-sm">
<table class="table">
<thead>
<tr>
@@ -360,30 +376,30 @@
</div>
</div>
<div id="modal-backed-up" 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.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-100 ~info @low mb-2"><span class="flex items-center" id="settings-backed-up-download">{{ .strings.download }}</span></button>
<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">
@@ -395,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">
@@ -408,6 +437,9 @@
{{ if .ombiEnabled }}
<th>Ombi</th>
{{ end }}
{{ if .jellyseerrEnabled }}
<th>Jellyseerr</th>
{{ end }}
{{ if .referralsEnabled }}
<th>{{ .strings.referrals }}</th>
{{ end }}
@@ -422,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>
@@ -445,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">
@@ -461,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>
@@ -479,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">
@@ -490,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">
@@ -503,7 +535,7 @@
</form>
</div>
<div id="notification-box"></div>
<div class="top-4 left-4 absolute">
<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>
@@ -532,8 +564,8 @@
{{ 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>
@@ -547,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>
@@ -603,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>
@@ -648,77 +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="col">
<label class="label supra" for="create-user-label"> {{ .strings.userLabel }}</label>
<p class="support">{{ .strings.userLabelDescription }}</p>
<input type="text" id="create-user-label" class="input ~neutral @low mb-2 mt-4">
<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-[-2.64rem] rounded-s-none 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 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>
@@ -726,12 +774,12 @@
</div>
</div>
</div>
<span class="col button ~urge @low center max-w-[20%]" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
<span class="button ~urge @low center " id="accounts-modify-user">{{ .strings.modifySettings }}</span>
{{ if .referralsEnabled }}
<span class="col button ~urge @low center max-w-[20%]" id="accounts-enable-referrals">{{ .strings.enableReferrals }}</span>
<span class="button ~urge @low center " id="accounts-enable-referrals">{{ .strings.enableReferrals }}</span>
{{ end }}
<div id="accounts-expiry-dropdown" class="col dropdown pb-0i max-w-[20%]" tabindex="0">
<span class="w-100 button ~positive @low center" id="accounts-expiry-dropdown-button">{{ .strings.expiry }} <i class="ri-arrow-down-s-line ml-2"></i></span>
<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>
@@ -739,16 +787,16 @@
</div>
</div>
</div>
<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>
<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">
@@ -791,27 +839,33 @@
</div>
<div id="tab-activity" class="unfocused">
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible">
<div class="flex-expand align-middle">
<span class="text-3xl font-bold mr-4">{{ .strings.activity }}</span>
<div id="activity-filter-dropdown" class="dropdown z-10" tabindex="0">
<span class="h-100 button ~neutral @low center" id="activity-filter-button">{{ .strings.filters }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low mt-2" id="activity-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
<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>
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
<input type="search" class="field ~neutral @low input search ml-2 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 class="flex flex-row justify-between py-2">
<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">
<span id="activity-total-records" class="mx-2"></span>
<span id="activity-loaded-records" class="mx-2"></span>
<span id="activity-shown-records" class="mx-2"></span>
<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">
@@ -842,24 +896,24 @@
</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 ~neutral @low my-1" id="settings-logs">{{ .strings.logs }}</span>
<span class="button ~info @low my-1" id="settings-backups">{{ .strings.backups }}</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-expand">
<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>

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">

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,21 +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 }}
{{ if index . "userPageEnabled" }}
{{ if and .userPageEnabled .showUserPageLink }}
<div class="card mx-2 flex-initial w-[100%] xl:w-[35%] mb-4 xl:mb-0 dark:~d_neutral @low content">
{{ 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-100" href="{{ .urlBase }}/my/account"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
<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 flex-auto form-login w-[100%] xl:w-[55%] mb-0" href="">
<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>
@@ -150,7 +150,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>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</section>
@@ -189,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>
@@ -235,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>
@@ -258,7 +258,7 @@
<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>
@@ -273,7 +273,7 @@
<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-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>
@@ -370,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>
@@ -392,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>
@@ -413,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>
@@ -450,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>
@@ -483,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>
@@ -513,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

@@ -109,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 }}
@@ -162,7 +162,7 @@
<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="row flex-expand">
<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>

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
}

25
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

View File

@@ -32,12 +32,12 @@
"before": "قبل",
"user": "مستخدم",
"userExpiry": "انتهاء صلاحية المستخدم",
"userExpiryDescription": "",
"userExpiryDescription": "بعد وقت محدد من تسجيل مستخدم جديد, jfa-go سوف يمسح\\يلغي تفعيل الحساب. بامكانك تغيير هذا السلوك في الاعدادات.",
"aboutProgram": "حول",
"version": "إصدار",
"commitNoun": "تعديل",
"commitNoun": "فرض",
"newUser": "مستخدم جديد",
"profile": "ملف",
"profile": "حساب تعريفي",
"unknown": "غير معروف",
"label": "وسم",
"logs": "السجلات",
@@ -63,19 +63,19 @@
"markdownSupported": "",
"modifySettings": "",
"modifySettingsDescription": "",
"applyHomescreenLayout": "",
"sendDeleteNotificationEmail": "",
"sendDeleteNotifiationExample": "",
"settingsRestart": "",
"settingsRestarting": "",
"settingsRestartRequired": "",
"settingsRestartRequiredDescription": "",
"settingsApplyRestartLater": "",
"settingsApplyRestartNow": "",
"settingsApplied": "",
"settingsRefreshPage": "",
"settingsRequiredOrRestartMessage": "",
"settingsSave": "",
"applyHomescreenLayout": "تطبيق ترتيب الصفحه الرئيسيه",
"sendDeleteNotificationEmail": "ارسال رساله اشعار",
"sendDeleteNotifiationExample": "تم حذف حسابك.",
"settingsRestart": "اعاده تشغيل",
"settingsRestarting": "اعاده التشغيل…",
"settingsRestartRequired": "يجب اعاده التشغيل",
"settingsRestartRequiredDescription": "يجب اعاده التشغيل لتطبيق بعض الاعدادات التي تم تغييرها. اعاده التشغيل الان ام لاحقا؟",
"settingsApplyRestartLater": "تطبيق الاعدادات, اعاده التشغيل لاحقا",
"settingsApplyRestartNow": "تطبيق الاعدادات و اعاده التشغيل",
"settingsApplied": "تم تطبيق الاعدادات.",
"settingsRefreshPage": "اعد انعاش الصفحه بعد بضع ثوان.",
"settingsRequiredOrRestartMessage": "ملاحظه: {n} تشير الى حقل اجباري, {n} تشير ان التغييرات تحتاج لاعاده التشغيل.",
"settingsSave": "حفظ",
"ombiProfile": "",
"ombiUserDefaultsDescription": "",
"userProfiles": "",
@@ -117,7 +117,15 @@
"userPageLogin": "",
"userPagePage": "",
"buildTime": "",
"builtBy": ""
"builtBy": "",
"activity": "الانشطه",
"userLabel": "وسم المستخدم",
"userLabelDescription": "الوسام للمستخدمين المفعلين من هذه الدعوه.",
"enableReferrals": "تفعيل الاحالات",
"disableReferrals": "ابطال الاحالات",
"invite": "دعوه",
"enableReferralsProfileDescription": "تمكين المستخدمين من هذا الحساب التعريفي للاحالات الخاصه, لارسالها للعائله\\الاصدقاء. انشاء دعوه بالاعدادات المطلوبه, ثم اختارها هنا. كل احاله سوف تكون مبنيه على اعدادات هذه الدعوه. بامكانك مسح الدعوه عند لانتهاء.",
"enableReferralsDescription": "تمكين المستخدمين لاستعمال احالات خاصه مثل الدعوه, لارسالها للعائله\\للاصدقاء. ممكن اصدارها من قوالب الاحالات في الحساب التعريفي, او من دعوه مفعله."
},
"notifications": {
"changedEmailAddress": "",

View File

@@ -3,7 +3,7 @@
"name": "Deutsch (DE)"
},
"strings": {
"invites": "Invites",
"invites": "Einladungen",
"accounts": "Konten",
"settings": "Einstellungen",
"inviteDays": "Tage",
@@ -94,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",
@@ -115,7 +115,28 @@
"after": "nach",
"before": "vor",
"unlink": "Account trennen",
"sortingBy": "Sortieren nach"
"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.",

View File

@@ -81,6 +81,9 @@
"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",
@@ -99,6 +102,8 @@
"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",
@@ -137,6 +142,8 @@
"jellyfinID": "Jellyfin ID",
"userPageLogin": "User Page: Login",
"userPagePage": "User Page: Page",
"postSignupCard": "Post-signup help card",
"postSignupCardDescription": "Card shown to user after signing up. Overrides \"Success Message\". Overriden by \"Auto redirect on success\" setting.",
"buildTime": "Build Time",
"builtBy": "Built By",
"loginNotAdmin": "Not an Admin?",
@@ -150,7 +157,8 @@
"accountDisabled": "Account disabled: {user}",
"accountReEnabled": "Account re-enabled: {user}",
"accountExpired": "Account expired: {user}",
"accountWillExpire": "Account will expire on {date}",
"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}",
@@ -205,6 +213,7 @@
"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.",
@@ -221,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.",

View File

@@ -116,7 +116,27 @@
"after": "Después",
"before": "Antes",
"unlink": "Desvincular cuenta",
"clickToRemoveFilter": "Haga clic para eliminar el filtro."
"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}.",

View File

@@ -125,7 +125,78 @@
"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."
"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}.",
@@ -165,7 +236,13 @@
"setOmbiProfile": "Profil ombi enregistré.",
"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."
"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": {

View File

@@ -74,7 +74,9 @@
"select": "Pilih",
"search": "Cari",
"download": "Unduh",
"inviteMonths": "Bulan"
"inviteMonths": "Bulan",
"inviteDuration": "Durasi undangan",
"activity": "Aktivitas"
},
"notifications": {
"changedEmailAddress": "Alamat email {n} diubah.",

View File

@@ -130,7 +130,72 @@
"searchOptions": "Zoekopties",
"matchText": "Tekstovereenkomst",
"jellyfinID": "Jellyfin ID",
"userPagePage": "Gebruikerspagina: Pagina"
"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.",
@@ -170,7 +235,13 @@
"setOmbiProfile": "Opgeslagen ombi-profiel.",
"errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt.",
"errorNoReferralTemplate": "Profiel bevat geen verwijzingssjabloon, voeg er een toe bij instellingen.",
"referralsEnabled": "Verwijzingen actief."
"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": {

View File

@@ -101,7 +101,8 @@
"deleteTemplate": "Usuń szablon",
"templateEnterName": "Wprowadź nazwę aby zapisać szablon.",
"accessJFA": "",
"accessJFASettings": ""
"accessJFASettings": "",
"invite": "Zaproś"
},
"notifications": {
"changedEmailAddress": "Zmieniono adres email {n}.",
@@ -197,4 +198,4 @@
"plural": ""
}
}
}
}

View File

@@ -83,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.",
@@ -102,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}.",
@@ -140,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": {
@@ -198,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

@@ -69,7 +69,7 @@
"ombiUserDefaults": "Ombi 用户默认值",
"ombiUserDefaultsDescription": "创建并配置 Ombi 用户,然后在下面选择它。它的设置/权限将被存储并应用于由 jfa-go 创建的新 Ombi 用户。",
"userProfiles": "用户档案",
"userProfilesDescription": "配置文件在用户创建帐户时应用于用户。配置文件包括库访问权限和主屏幕布局。",
"userProfilesDescription": "个人资料在用户创建帐户时应用于他们。个人资料包括库访问权限和主屏幕布局。",
"userProfilesIsDefault": "默认",
"userProfilesLibraries": "库",
"addProfile": "添加档案",
@@ -117,7 +117,85 @@
"before": "之前",
"unlink": "取消关联帐户",
"sortingBy": "排序方式",
"userPageLogin": "用户页面:登录"
"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} 的电子邮件地址。",
@@ -155,7 +233,15 @@
"updateAvailable": "有新更新可用,请检查设置。",
"noUpdatesAvailable": "没有可用的更新。",
"setOmbiProfile": "保存ombi配置文件。",
"errorSetOmbiProfile": "无法保存ombi配置文件。"
"errorSetOmbiProfile": "无法保存ombi配置文件。",
"activityDeleted": "活动已删除。",
"errorNoReferralTemplate": "个人资料不包含推荐模板,请在设置中添加一个。",
"referralsEnabled": "已启用推荐。",
"errorInviteNoLongerExists": "邀请已不存在。",
"errorInviteNotFound": "未找到邀请。",
"errorLoadActivities": "无法加载活动。",
"pathCopied": "完整路径已复制到剪贴板。",
"errorInvalidDate": "日期无效。"
},
"quantityStrings": {
"modifySettingsFor": {
@@ -213,6 +299,10 @@
"setExpiry": {
"plural": "为{n}用户设置到期时间",
"singular": "为{n}用户设置到期时间"
},
"enableReferralsFor": {
"singular": "为{n}用户启用推荐功能",
"plural": "为{n}个用户启用推荐功能"
}
}
}

View File

@@ -36,7 +36,12 @@
"add": "Agregar",
"edit": "Editar",
"delete": "Eliminar",
"inviteRemainingUses": "Usos restantes"
"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.",
@@ -45,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 Email",
"emailAddress": "Adresse mail",
"name": "Nom",
"submit": "Soumettre",
"send": "Envoyer",
@@ -29,9 +29,9 @@
"logout": "Se déconnecter",
"admin": "Administrateur",
"enabled": "Activé",
"disabled": "Désactiver",
"disabled": "Désactivé",
"reEnable": "Ré-activé",
"disable": "Désactivé",
"disable": "Désactiver",
"expiry": "Expiration",
"add": "Ajouter",
"edit": "Éditer",

View File

@@ -7,7 +7,7 @@
"password": "Senha",
"emailAddress": "Endereço de e-mail",
"name": "Nome",
"submit": "Enviar",
"submit": "Envie",
"send": "Enviar",
"success": "Sucesso",
"continue": "Continuar",
@@ -36,7 +36,12 @@
"add": "Adicionar",
"edit": "Editar",
"delete": "Deletar",
"inviteRemainingUses": "Uso restantes"
"inviteRemainingUses": "Uso restantes",
"referrals": "Referências",
"contactMethods": "Métodos de contato",
"accountStatus": "Estado da conta",
"notSet": "Não configurado",
"myAccount": "Minha conta"
},
"notifications": {
"errorLoginBlank": "O nome de usuário e/ou senha foram deixados em branco.",
@@ -45,5 +50,18 @@
"error401Unauthorized": "Não autorizado. Tente atualizar a página.",
"errorSaveSettings": "Não foi possível salvar as configurações."
},
"quantityStrings": {}
}
"quantityStrings": {
"day": {
"plural": "{n} Dias",
"singular": "{n} Dia"
},
"year": {
"singular": "{n} Ano",
"plural": "{n} anos"
},
"month": {
"singular": "{n} Mês",
"plural": "{n} meses"
}
}
}

View File

@@ -29,7 +29,7 @@
"logout": "登出",
"admin": "管理员",
"enabled": "已启用",
"disabled": "禁用",
"disabled": "禁用",
"reEnable": "重新启用",
"disable": "禁用",
"expiry": "到期",
@@ -40,7 +40,8 @@
"contactMethods": "联系方式",
"accountStatus": "帐户状态",
"notSet": "未设置",
"myAccount": "我的帐户"
"myAccount": "我的帐户",
"referrals": "推荐"
},
"notifications": {
"errorLoginBlank": "用户名/密码留空。",

View File

@@ -45,6 +45,13 @@
"title": "Your account has been re-enabled - Jellyfin",
"yourAccountWasEnabled": "Your account was re-enabled."
},
"userExpiryAdjusted": {
"name": "Expiry adjusted",
"title": "Account expiry adjusted - Jellyfin",
"yourExpiryWasAdjusted": "Your account's expiry date has been adjusted.",
"ifPreviouslyDisabled": "If your account was previously disabled, it may have been re-enabled.",
"newExpiry": "Your account will now expire on {date}."
},
"inviteEmail": {
"name": "Invite email",
"title": "Invite - Jellyfin",
@@ -74,4 +81,4 @@
"yourAccountHasExpired": "Your account has expired.",
"contactTheAdmin": "Contact the administrator for more info."
}
}
}

View File

@@ -17,9 +17,30 @@
"confirmationRequired": "Se requiere confirmación por correo electrónico",
"confirmationRequiredMessage": "Por favor, revise la bandeja de entrada de su correo electrónico para verificar su dirección.",
"yourAccountIsValidUntil": "Su cuenta será válida hasta el {date}.",
"sendPINDiscord": "Escribe {command} en {server_channel} en Discord, luego envía el PIN a través de DM al bot.",
"sendPINDiscord": "Escriba {command} en {server_channel} en Discord, luego envíe el PIN a través de DM al bot.",
"sendPIN": "Envíe el PIN a continuación al bot, luego regrese aquí para vincular su cuenta.",
"matrixEnterUser": "Ingrese su ID de usuario, presione enviar y se le enviará un PIN. Ingrese aquí para continuar."
"matrixEnterUser": "Ingrese su ID de usuario, presione enviar y se le enviará un PIN. Ingrese aquí para continuar.",
"oldPassword": "Contraseña anterior",
"newPassword": "Nueva contraseña",
"addContactMethod": "Añadir método de contacto",
"editContactMethod": "Editar método de contacto",
"resetPassword": "Restablecer contraseña",
"joinTheServer": "Unirse al servidor:",
"resetPasswordThroughJellyfin": "Para restablecer la contraseña, visite {jfLink} y presione el botón \"Contraseña olvidada\".",
"userPageSuccessMessage": "Puede ver y modificar los detalles de su cuenta más tarde desde la página de {myAccount}.",
"resetPasswordThroughLink": "Para restablecer la contraseña, introduzca su nombre de usuario, dirección de correo electrónico o el nombre de usuario de un método de contacto vinculado, y presione enviar. Recibirá un enlace para restablecer la contraseña.",
"resetSent": "Restablecimiento enviado.",
"changePassword": "Cambiar contraseña",
"copyReferral": "Copiar enlace",
"invitedBy": "Invitación recibida de parte del usuario {user}.",
"referralsDescription": "Invite a amigos y familiares a Jellyfin con este enlace. Vuelva aquí para conseguir uno nuevo si este caduca.",
"resetPasswordThroughLinkStart": "Para restablecer su contraseña, introduzca uno de los siguientes a continuación:",
"resetPasswordThroughLinkEnd": "Y presione enviar. Se le mandará un enlace para restablecer su contraseña.",
"resetPasswordUsername": "Su nombre de usuario de Jellyfin",
"resetPasswordEmail": "Su dirección de correo electrónico",
"resetPasswordContactMethod": "El nombre de usuario de cualquier método de contacto vinculado a su cuenta",
"resetSentDescription": "Si una cuenta con el nombre de usuario o método de contacto suministrados existe, se habrá enviado un enlace de restablecimiento de contraseña a través de todos los métodos de contacto disponibles. El código caducará a los 30 minutos.",
"referralsWithExpiryDescription": "Invite a amigos y familia a Jellyfin con este enlace. El enlace se desactivará cuando caduque."
},
"notifications": {
"errorUserExists": "El usuario ya existe.",
@@ -35,7 +56,9 @@
"errorPassword": "Requisitos para la contraseña.",
"errorNoMatch": "Las contraseñas no coinciden.",
"errorAccountLinked": "La cuenta ya está en uso.",
"errorEmailLinked": "El correo electrónico ya está en uso."
"errorEmailLinked": "El correo electrónico ya está en uso.",
"errorOldPassword": "Contraseña anterior incorrecta.",
"passwordChanged": "Contraseña cambiada."
},
"validationStrings": {
"length": {
@@ -59,4 +82,4 @@
"plural": "Debe tener al menos {n} caracteres especiales"
}
}
}
}

View File

@@ -7,7 +7,7 @@
"pageTitle": "Créer un compte Jellyfin",
"createAccountHeader": "Création du compte",
"accountDetails": "Détails",
"emailAddress": "Email",
"emailAddress": "Mail",
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"reEnterPassword": "Confirmez mot de passe",
@@ -38,7 +38,13 @@
"resetPassword": "Réinitialisation mot de passe",
"referralsDescription": "Invitez vos amis et votre famille à Jellyfin avec ce lien. Revenez ici pour en obtenir un nouveau s'il expire.",
"copyReferral": "Copier le lien",
"invitedBy": "Vous avez été invité par l'utilisateur {user}."
"invitedBy": "Vous avez été invité par l'utilisateur {user}.",
"resetPasswordUsername": "Votre username Jellyfin",
"resetPasswordEmail": "Votre adresse email",
"resetPasswordThroughLinkStart": "Pour réinitialiser votre mot de passe, saisissez l'une des informations suivantes :",
"resetPasswordContactMethod": "Le nom d'utilisateur de toute méthode de contact liée à votre compte",
"resetPasswordThroughLinkEnd": "Appuyez ensuite sur Soumettre. Un lien vous sera envoyé pour réinitialiser votre mot de passe.",
"referralsWithExpiryDescription": "Invitez vos amis et votre famille à Jellyfin avec ce lien. Le lien sera désactivé une fois expiré."
},
"validationStrings": {
"length": {

View File

@@ -37,7 +37,13 @@
"resetSent": "Reset-link verstuurd.",
"referralsDescription": "Nodig vrienden en familie uit met deze link. Kom hier terug voor een nieuwe als hij verloopt.",
"copyReferral": "Kopieer link",
"invitedBy": "Je bent uitgenodigd door gebruiker {user}."
"invitedBy": "Je bent uitgenodigd door gebruiker {user}.",
"resetPasswordThroughLinkStart": "Voer één van onderstaande velden in om je wachtwoord te resetten:",
"resetPasswordUsername": "Je Jellyfin gebruikersnaam",
"referralsWithExpiryDescription": "Nodig vrienden en familie uit voor Jellyfin met deze link. De link wordt uitgeschakeld zodra hij verloopt.",
"resetPasswordEmail": "Je e-mailadres",
"resetPasswordThroughLinkEnd": "Druk daarna op versturen. Er wordt dan een link verstuurd om je wachtwoord te resetten.",
"resetPasswordContactMethod": "De gebruikersnaam van een contactmethode die aan je account gekoppeld is"
},
"validationStrings": {
"length": {

View File

@@ -3,7 +3,7 @@
"name": "Português (BR)"
},
"strings": {
"pageTitle": "Criar Conta Jellyfin",
"pageTitle": "Crie uma conta Jellyfin",
"createAccountHeader": "Criar Conta",
"accountDetails": "Detalhes",
"emailAddress": "E-mail",
@@ -17,9 +17,33 @@
"confirmationRequired": "Confirmação por e-mail",
"confirmationRequiredMessage": "Verifique sua caixa de email para finalizar o cadastro.",
"yourAccountIsValidUntil": "Sua conta é válida até {date}.",
"sendPIN": "Envie o PIN abaixo para o bot e volte aqui para vincular sua conta.",
"sendPIN": "Envie o PIN abaixo para o bot e depois volte aqui para vincular sua conta.",
"sendPINDiscord": "Digite {command} em {server_channel} no Discord e envie o PIN abaixo.",
"matrixEnterUser": "Digite sua ID de usuário, pressione enviar e um PIN será enviado. E digite aqui para continuar."
"matrixEnterUser": "Digite sua ID de usuário, pressione enviar e um PIN será enviado. E digite aqui para continuar.",
"resetPasswordUsername": "Seu nome de usuário Jellyfin",
"resetPasswordThroughLinkEnd": "Em seguida, pressione enviar. Um link será enviado para redefinir sua senha.",
"resetPasswordEmail": "Seu endereço de email",
"resetPasswordContactMethod": "O nome de usuário de qualquer método de contato vinculado à sua conta",
"resetSentDescription": "Se existir uma conta com o nome de usuário/método de contato fornecido, um link de redefinição de senha será enviado por todos os métodos de contato disponíveis. O código expirará em 30 minutos.",
"oldPassword": "Senha Antiga",
"newPassword": "Nova Senha",
"welcomeUser": "Bem-vindo, {usuário}!",
"changePassword": "Alterar a senha",
"addContactMethod": "Adicionar Método de Contato",
"editContactMethod": "Editar Método de Contato",
"joinTheServer": "Junte-se ao servidor:",
"customMessagePlaceholderContent": "Clique no botão de edição da página do usuário nas configurações para personalizar este cartão ou mostre um na tela de login e não se preocupe, o usuário não poderá ver isso.",
"customMessagePlaceholderHeader": "Personalize este cartão",
"userPageSuccessMessage": "Você poderá ver e alterar os detalhes da sua conta posteriormente na página {minha conta}.",
"resetPassword": "Redefinir senha",
"resetPasswordThroughJellyfin": "Para redefinir sua senha, visite {jfLink} e pressione o botão \"Esqueci minha senha\".",
"resetPasswordThroughLink": "Para redefinir sua senha, insira seu nome de usuário, endereço de e-mail ou um nome de usuário de método de contato vinculado e envie. Um link será enviado para redefinir sua senha.",
"resetSent": "Redefinir enviado.",
"referralsDescription": "Convide amigos e familiares para Jellyfin com este link. Volte aqui para comprar um novo se ele expirar.",
"copyReferral": "Copiar link",
"invitedBy": "Você foi convidado pelo usuário {user}.",
"resetPasswordThroughLinkStart": "Para redefinir sua senha, digite um dos seguintes itens abaixo:",
"referralsWithExpiryDescription": "Convide amigos e familiares para Jellyfin com este link. O link será desativado quando expirar."
},
"notifications": {
"errorUserExists": "Esse usuário já existe.",
@@ -36,7 +60,9 @@
"errorPassword": "Verifique os requisitos de senha.",
"errorNoMatch": "As senhas não coincidem.",
"errorEmailLinked": "Este E-mail já está sendo utilizado.",
"errorAccountLinked": "Esta conta já está sendo utilizada."
"errorAccountLinked": "Esta conta já está sendo utilizada.",
"errorOldPassword": "Senha antiga incorreta.",
"passwordChanged": "Senha alterada."
},
"validationStrings": {
"length": {
@@ -60,4 +86,4 @@
"plural": "Deve ter pelo menos {n} caracteres especiais"
}
}
}
}

View File

@@ -34,7 +34,16 @@
"welcomeUser": "欢迎,{user}",
"editContactMethod": "修改联系方式",
"joinTheServer": "加入服务器:",
"customMessagePlaceholderHeader": "自定义此卡"
"customMessagePlaceholderHeader": "自定义此卡",
"referralsDescription": "使用此链接邀请朋友和家人加入Jellyfin。如果链接过期请回到这里获取一个新的。",
"copyReferral": "复制链接",
"invitedBy": "您是由用户{user}邀请的。",
"resetPasswordThroughLinkEnd": "按下提交按钮后,将会发送一个链接用以重置您的密码。",
"resetPasswordUsername": "你的Jellyfin用户名",
"resetPasswordThroughLinkStart": "要重置密码,请输入以下任一信息:",
"resetPasswordEmail": "你的邮箱地址",
"resetPasswordContactMethod": "与您的账户相关联的任何联系方式的用户名",
"referralsWithExpiryDescription": "使用此链接邀请亲朋好友加入 Jellyfin。过期后链接将失效。"
},
"notifications": {
"errorUserExists": "用户已经存在。",

View File

@@ -22,7 +22,8 @@
"error404": "404, prüfe die interne URL.",
"errorConnectionRefused": "Verbindung abgelehnt.",
"error": "Fehler",
"errorUnknown": "Unbekannter Fehler, prüfe die Logs."
"errorUnknown": "Unbekannter Fehler, prüfe die Logs.",
"errorProxy": "Proxy Konfiguration ungültig."
},
"startPage": {
"welcome": "Willkommen!",
@@ -63,7 +64,8 @@
"adminOnly": "Nur Admin-Benutzer (empfohlen)",
"emailNotice": "Deine E-Mail-Adresse kann verwendet werden, um Benachrichtigungen zu erhalten.",
"allowAll": "Erlaube allen Jellyfin-Nutzern sich anzumelden",
"allowAllDescription": "Nicht empfohlen. Nach der Einrichtung solltest du einzelnen Benutzern erlauben, sich anzumelden."
"allowAllDescription": "Nicht empfohlen. Nach der Einrichtung solltest du einzelnen Benutzern erlauben, sich anzumelden.",
"authorizeManualUserPageNotice": "Dadurch wird die Funktion \"Benutzerseite\" deaktiviert."
},
"jellyfinEmby": {
"title": "Jellyfin/Emby",
@@ -148,5 +150,11 @@
"messages": {
"title": "Mitteilungen",
"description": "jfa-go kann Passwortrücksetzungen und verschiedene Benachrichtigungen per E-Mail, Discord, Telegram und/oder Matrix verschicken. E-Mail kannst du unten einrichten, die Anderen später in den Einstellungen. Anweisungen findest du im {n}. Falls nicht benötigt, kannst du diese Funktionen hier deaktivieren."
},
"proxy": {
"description": "Lass jfa-go alle Verbindungen über einen HTTP/SOCKS5-Proxy herstellen. Die Verbindung zu Jellyfin wird über diesen Proxy getestet.",
"title": "Proxy",
"protocol": "Protokoll",
"address": "Adresse (inkl. Port)"
}
}

View File

@@ -3,7 +3,7 @@
"name": "Español (ES)"
},
"strings": {
"pageTitle": "instalación - jfa-go",
"pageTitle": "Instalación - jfa-go",
"next": "Siguiente",
"back": "Volver",
"optional": "Opcional",
@@ -22,7 +22,8 @@
"error404": "404, verifique la URL interna.",
"errorConnectionRefused": "Conexión rechazada.",
"errorUnknown": "Error desconocido, verifique los registros de la aplicación.",
"error": "Error"
"error": "Error",
"errorProxy": "Configuración de proxy no válida."
},
"startPage": {
"welcome": "¡Bienvenido!",
@@ -32,12 +33,12 @@
},
"endPage": {
"finished": "¡Terminado!",
"restartMessage": "Puede configurar los bots de Discord/Telegram/Matrix, personalizar sus mensajes y más en Configuración. Haga clic a continuación para reiniciar, luego actualice la página.",
"refreshPage": "Actualizar"
"restartMessage": "Funciones como bots de Discord/Telegram/Matrix, mensajes Markdown personalizados y una página de \"Mi cuenta\" accesible por el usuario pueden encontrarse en Ajustes, así que asegúrese de echarle un vistazo. Haga clic a continuación para reiniciar, y luego actualice la página.",
"refreshPage": "Recargar"
},
"language": {
"title": "Lenguaje",
"description": "Las traducciones de la comunidad están disponibles para la mayor parte de jfa-go. Puede elegir los idiomas predeterminados a continuación, pero los usuarios aún pueden cambiarlo si lo desean. Si quieres ayudar a traducir, regístrate en {n} para empezar a contribuir!",
"title": "Idioma",
"description": "Las traducciones de la comunidad están disponibles para la mayor parte de jfa-go. Puede elegir los idiomas predeterminados a continuación, pero los usuarios aún pueden cambiarlo si lo desean. Si quieres ayudar a traducir, ¡regístrate en {n} para empezar a contribuir!",
"defaultAdminLang": "Idioma de administrador predeterminado",
"defaultFormLang": "Idioma de creación de cuenta predeterminado",
"defaultEmailLang": "Idioma de correo electrónico predeterminado"
@@ -70,7 +71,8 @@
"adminOnly": "Solo usuarios administradores (recomendado)",
"emailNotice": "Su dirección de correo electrónico se puede utilizar para recibir notificaciones.",
"allowAll": "Permitir el acceso a todos los usuarios de Jellyfin",
"allowAllDescription": "No se recomienda, debe permitir que los usuarios individuales se conecten una vez configurados."
"allowAllDescription": "No se recomienda, debe permitir que los usuarios individuales se conecten una vez configurados.",
"authorizeManualUserPageNotice": "Utilizar esto deshabilitará la función de \"Página de usuario\"."
},
"jellyfinEmby": {
"title": "Jellyfin/Emby",
@@ -115,14 +117,15 @@
},
"passwordResets": {
"title": "Restablecimiento de contraseña",
"description": "Cuando un usuario intenta restablecer su contraseña, Jellyfin crea un archivo llamado 'passwordreset - *. Json' que contiene un PIN. jfa-go lee el archivo y envía el PIN al usuario.",
"description": "Cuando un usuario intenta restablecer su contraseña, Jellyfin crea un archivo llamado 'passwordreset -*.json' que contiene un PIN. jfa-go lee el archivo y envía el PIN al usuario. Si activó la función de \"Página de usuario\", también se puede realizar un restablecimiento desde ahí, proporcionando un nombre de usuario, correo electrónico, o método de contacto.",
"pathToJellyfin": "Ruta al directorio de configuración de Jellyfin",
"pathToJellyfinNotice": "Si no sabe dónde está, intente restablecer su contraseña en Jellyfin. Aparecerá una ventana emergente con '<ruta a jellyfin>/passwordreset-. Json'.",
"pathToJellyfinNotice": "Si no sabe dónde está, intente restablecer su contraseña en Jellyfin. Aparecerá una ventana emergente con '<ruta a jellyfin>/passwordreset-*.json'. Esto no es necesario si solo quiere usar el restablecimiento de contraseña de autoservicio a través de la \"Página de usuario\".",
"resetLinks": "Envía un enlace en lugar de un PIN",
"resetLinksNotice": "Si la integración de Ombi está habilitada, utilícela para sincronizar los restablecimientos de contraseña de Jellyfin con Ombi.",
"resetLinksLanguage": "Enlace de restablecimiento predeterminado",
"setPassword": "Establecer contraseña a través del enlace",
"setPasswordNotice": "Habilitar esto significa que el usuario no tiene que cambiar su contraseña del PIN después del restablecimiento. También se aplicará la validación de la contraseña."
"setPasswordNotice": "Habilitar esto significa que el usuario no tiene que cambiar su contraseña del PIN después del restablecimiento. También se aplicará la validación de la contraseña.",
"resetLinksRequiredForUserPage": "Obligatorio para restablecimiento de contraseña de autoservicio en la Página de usuario."
},
"passwordValidation": {
"title": "Validación de contraseña",
@@ -148,5 +151,17 @@
"messages": {
"description": "jfa-go puede enviar restablecimientos de contraseña y varios mensajes a través de correo electrónico, Discord, Telegram y/o Matrix. Puede configurar el correo electrónico a continuación y los demás se pueden configurar en Configuración más adelante. Las instrucciones se pueden encontrar en el {n}. Si no lo necesita, puede desactivar estas funciones aquí.",
"title": "Mensajes"
},
"userPage": {
"title": "Página de usuario",
"customizeMessages": "Clique en el botón de editar al lado de \"Página de usuario\" en ajustes para establecerlos más tarde.",
"description": "La página de usuario (mostrada como \"Mi cuenta\") permite a los usuarios acceder a información sobre su cuenta, como por ejemplo sus métodos de contacto y caducidad de la cuenta. También pueden cambiar su contraseña, iniciar un restablecimiento de contraseña, y vincular o cambiar métodos de contacto, sin tener que recurrir a usted. Además, se pueden enseñar mensajes personalizados de Markdown a los usuarios antes y después de que inicien sesión.",
"requiredSettings": "Debe establecerse un inicio de sesión en jfa-go a través de Jellyfin. Asegúrese de que \"Restablecer contraseña a través de enlace\" está seleccionado para un restablecimiento de contraseña de autoservicio."
},
"proxy": {
"title": "Proxy",
"description": "Hacer que jfa-go realice todas las conexiones a través de un proxy HTTP/SOCK5. La conexión a Jellyfin será evaluada a través de este.",
"protocol": "Protocolo",
"address": "Dirección (incluido el puerto)"
}
}

View File

@@ -3,12 +3,12 @@
"name": "Français (FR)"
},
"strings": {
"pageTitle": "Installation - JFA-Go",
"pageTitle": "Installation - jfa-go",
"next": "Suivant",
"back": "Retour",
"optional": "Optionnel",
"serverType": "Type de serveur",
"disabled": "Désactiver",
"disabled": "Désactivé",
"enabled": "Activé",
"port": "Port",
"message": "Message",
@@ -22,7 +22,8 @@
"errorNotAdmin": "L'utilisateur n'est pas autorisé à gérer le serveur.",
"errorConnectionRefused": "Connexion refusée.",
"error": "Erreur",
"errorUnknown": "Erreur inconnue, vérifier les logs de l'application."
"errorUnknown": "Erreur inconnue, vérifier les logs de l'application.",
"errorProxy": "Configuration du proxy invalide."
},
"startPage": {
"welcome": "Bienvenue !",
@@ -32,7 +33,7 @@
},
"endPage": {
"finished": "Terminé !",
"restartMessage": "Vous pouvez configurer les bots Discord/Telegram/Matrix, personnaliser vos messages et plus encore dans Paramètres. Cliquez ci-dessous pour redémarrer, puis actualisez la page.",
"restartMessage": "Vous pouvez configurer les bots Discord/Telegram/Matrix, personnaliser lapparence vos messages, et plus encore dans Paramètres. Cliquez ci-dessous pour redémarrer, puis actualisez la page.",
"refreshPage": "Rafraichir"
},
"language": {
@@ -63,7 +64,8 @@
"adminOnly": "Administrateurs seulement (recommandé)",
"emailNotice": "Votre adresse e-mail peut être utilisée pour recevoir des notifications.",
"allowAll": "Autoriser tous les utilisateurs de Jellyfin à se connecter",
"allowAllDescription": "Non recommandé, vous devez autoriser individuellement les utilisateurs à se connecter une fois la configuration effectuée."
"allowAllDescription": "Non recommandé, vous devez autoriser individuellement les utilisateurs à se connecter une fois la configuration effectuée.",
"authorizeManualUserPageNotice": "Son utilisation désactivera la fonctionnalité \"Page utilisateur\"."
},
"jellyfinEmby": {
"title": "Jellyfin/Emby",
@@ -108,14 +110,15 @@
},
"passwordResets": {
"title": "Réinitialisation de mot de passe",
"description": "Lorsqu'un utilisateur essaie de réinitialiser son mot de passe, Jellyfin créé un fichier nommé 'passwordreset-*.json' qui contient le code PIN. jfa-go lit le fichier et envoie le code PIN à l'utilisateur.",
"description": "Lorsqu'un utilisateur essaie de réinitialiser son mot de passe, Jellyfin créé un fichier nommé 'passwordreset-*.json' qui contient le code PIN. jfa-go lit le fichier et envoie le code PIN à l'utilisateur. Si vous avez activé la fonctionnalité « Page utilisateur », une réinitialisation peut également y être effectuée, en fonction d'un nom d'utilisateur, d'un e-mail ou d'une méthode de contact.",
"pathToJellyfin": "Chemin du dossier de configuration de Jellyfin",
"pathToJellyfinNotice": "Si vous ne savez pas où c'est, essayez de réinitialiser votre mot de passe dans Jellyfin. Une popup avec '<path to jellyfin>/passwordreset-*.json' apparaitra.",
"pathToJellyfinNotice": "Si vous ne savez pas où c'est, essayez de réinitialiser votre mot de passe dans Jellyfin. Une popup avec '<path to jellyfin>/passwordreset-*.json' apparaitra. Cela n'est pas nécessaire si vous souhaitez uniquement utiliser la réinitialisation de mot de passe en libre-service via la « Page utilisateur ».",
"resetLinks": "Envoyer un lien plutôt qu'un PIN",
"resetLinksNotice": "Si l'intégration est activée, utilisez ceci pour synchroniser les réinitialisations de mots de passe Jellyfin avec Ombi.",
"resetLinksLanguage": "Langue du lien de réinitialisation par défaut",
"setPassword": "Définir le mot de passe via le lien",
"setPasswordNotice": "L'activation de cette option signifie que l'utilisateur n'a pas à modifier son mot de passe à partir du code PIN après la réinitialisation. La validation du mot de passe sera également appliquée."
"setPasswordNotice": "L'activation de cette option signifie que l'utilisateur n'a pas à modifier son mot de passe à partir du code PIN après la réinitialisation. La validation du mot de passe sera également appliquée.",
"resetLinksRequiredForUserPage": "Requis pour la réinitialisation du mot de passe par l'utilisateur sur la page utilisateur."
},
"passwordValidation": {
"title": "Validation du mot de passe",
@@ -148,5 +151,17 @@
"messages": {
"title": "Messages",
"description": "jfa-go peut envoyer des réinitialisations de mot de passe et divers messages par e-mail, Discord, Telegram et/ou Matrix. Vous pouvez configurer l'e-mail ci-dessous, et les autres peuvent être configurés dans les paramètres plus tard. Les instructions se trouvent sur le {n}. Si vous n'en avez pas besoin, vous pouvez désactiver ces fonctionnalités ici."
},
"userPage": {
"title": "Page utilisateur",
"customizeMessages": "Cliquez sur le bouton Modifier à côté de « Page utilisateur » dans les paramètres pour les définir ultérieurement.",
"requiredSettings": "La connexion à jfa-go via Jellyfin doit être définie. Assurez-vous que « Réinitialiser le mot de passe via un lien » est sélectionné ultérieurement pour les réinitialisations de mot de passe par les utilisateurs.",
"description": "La page utilisateur (affichée sous « Mon compte ») permet aux utilisateurs d'accéder aux informations sur leur compte, telles que leurs méthodes de contact et l'expiration de leur compte. Ils peuvent également modifier leur mot de passe, lancer une réinitialisation de mot de passe et lier/modifier les méthodes de contact, sans avoir à vous le demander. De plus, des messages personnalisés peuvent être affichés aux utilisateurs avant et après la connexion."
},
"proxy": {
"title": "Proxy",
"protocol": "Protocole",
"address": "Adresse (y compris le port)",
"description": "Demandez à jfa-go d'établir toutes les connexions via un proxy HTTP/SOCKS5. La connexion à Jellyfin sera testée à travers cela."
}
}

View File

@@ -22,7 +22,8 @@
"errorNotAdmin": "Gebruiker heeft geen beheersrechten.",
"errorConnectionRefused": "Verbinding geweigerd.",
"errorUnknown": "Onbekende fout, bekijk de logs.",
"error": "Fout"
"error": "Fout",
"errorProxy": "Proxy-instellingen onjuist."
},
"startPage": {
"welcome": "Welkom!",
@@ -156,5 +157,11 @@
"title": "Gebruikerspagina",
"customizeMessages": "Gebruik de bewerken knop naast \"Gebruikerspagina\" in de instellingen om dit later in te stellen.",
"requiredSettings": "Inloggen bij jfa-go via Jellyfin moet ingesteld zijn. Controleer dat \"reset wachtwoord via link\" later wordt gekozen voor zelfservice wachtwoord-resets."
},
"proxy": {
"title": "Proxy",
"description": "Laat jfa-go alle verbindingen via een HTTP/SOCKS5 proxy maken. De verbinding met Jellyfin wordt hierdoorheen getest.",
"protocol": "Protocol",
"address": "Adres (inclusief poort)"
}
}

View File

@@ -3,12 +3,12 @@
"name": "Português (BR)"
},
"strings": {
"pageTitle": "Configuração - jfa-go",
"pageTitle": "Configuração do jfa-go",
"next": "Próximo",
"back": "Voltar",
"optional": "Opcional",
"serverType": "Tipo de Servidor",
"disabled": "Desativado",
"disabled": "Desabilitado",
"enabled": "Habilitado",
"port": "Porta",
"message": "Mensagem",
@@ -20,7 +20,10 @@
"errorUserDisabled": "O usuário pode estar desabilitado.",
"error404": "404, verifique a URL interna.",
"errorInvalidUserPass": "Usuário ou Senha Inválidos.",
"errorConnectionRefused": "Conexão recusada."
"errorConnectionRefused": "Conexão recusada.",
"errorUnknown": "Erro desconhecido. Verifique os registros do aplicativo.",
"errorProxy": "Configuração de proxy inválida.",
"error": "Error"
},
"startPage": {
"welcome": "Bem Vindo!",
@@ -61,7 +64,8 @@
"adminOnly": "Apenas usuários administradores (recomendado)",
"emailNotice": "Seu endereço de email pode ser usado para receber notificações.",
"allowAll": "Permitir que todos os usuários do Jellyfin façam login",
"allowAllDescription": "Não recomendado, você deve permitir que usuários individuais façam login após a configuração."
"allowAllDescription": "Não recomendado, você deve permitir que usuários individuais façam login após a configuração.",
"authorizeManualUserPageNotice": "Usar isso desativará o recurso \"Página do usuário\"."
},
"jellyfinEmby": {
"title": "Jellyfin/Emby",
@@ -113,7 +117,8 @@
"resetLinksNotice": "Se a integração do Ombi estiver habilitada, use para sincronizar as redefinições de senha do Jellyfin com o Ombi.",
"resetLinksLanguage": "Idioma do link de redefinição padrão",
"setPassword": "Definir a senha por meio de link",
"setPasswordNotice": "Se habilitar significa que o usuário não precisa alterar a senha do PIN após a redefinição. A validação de senha também será aplicada."
"setPasswordNotice": "Se habilitar significa que o usuário não precisa alterar a senha do PIN após a redefinição. A validação de senha também será aplicada.",
"resetLinksRequiredForUserPage": "Obrigatório para redefinição de senha de autoatendimento na página do usuário."
},
"passwordValidation": {
"title": "Validar Senha",
@@ -125,7 +130,7 @@
"special": "Caracteres Especial (%, *, etc.)"
},
"helpMessages": {
"title": "Mensagem de Ajuda",
"title": "Mensagem para Ajuda",
"description": "Essas mensagens serão exibidas na página de criação de conta e em alguns emails.",
"contactMessage": "Mensagem de Contato",
"contactMessageNotice": "Exibido na parte inferior de todas as páginas, exceto na administradora.",
@@ -146,5 +151,17 @@
"messages": {
"title": "Mensagens",
"description": "jfa-go pode enviar redefinições de senha e várias mensagens por e-mail, Discord, Telegram e/ou Matrix. Você pode configurar o e-mail abaixo e os outros podem ser configurados em Configurações posteriormente. As instruções podem ser encontradas em {n}. Se você não precisar, pode desativar esses recursos aqui."
},
"proxy": {
"description": "Faça com que o jfa-go faça todas as conexões por meio de um proxy HTTP/SOCKS5. A conexão com o Jellyfin será testada através disso.",
"title": "Proxy",
"protocol": "Procedimento",
"address": "Endereço (incluindo porta)"
},
"userPage": {
"description": "A página do usuário (mostrada como “Minha Conta”) permite que os usuários acessem informações sobre sua conta, como métodos de contato e vencimento da conta. Eles também podem alterar sua senha, iniciar uma redefinição de senha e vincular/alterar métodos de contato, sem precisar perguntar a você. Além disso, mensagens Markdown personalizadas podem ser mostradas aos usuários antes e depois do entrar.",
"title": "Página de usuário",
"customizeMessages": "Clique no botão de edição próximo a “Página do usuário” nas configurações para defini-las mais tarde.",
"requiredSettings": "Ao entra no jfa-go via Jellyfin deve ser configurado. Certifique-se de que \"redefinir senha via link\" esteja selecionado posteriormente para redefinições de senha de autoatendimento."
}
}
}

View File

@@ -8,7 +8,7 @@
"back": "上一步",
"optional": "可选的",
"serverType": "服务类型",
"disabled": "禁用",
"disabled": "禁用",
"enabled": "已启用",
"port": "端口",
"message": "信息",
@@ -17,12 +17,13 @@
"URL": "链接",
"apiKey": "API 密钥",
"errorInvalidUserPass": "无效的用户名/密码。",
"errorNotAdmin": "用户不允许管理服务。",
"errorNotAdmin": "用户没有权限管理服务。",
"errorUserDisabled": "此永固可能已被禁用。",
"error404": "404请检查内部URL。",
"errorConnectionRefused": "连接被拒绝。",
"error": "错误",
"errorUnknown": "未知错误,请检查应用程序日志。"
"errorUnknown": "未知错误,请检查应用程序日志。",
"errorProxy": "代理配置无效。"
},
"startPage": {
"welcome": "欢迎!",
@@ -70,7 +71,8 @@
"adminOnly": "仅允许管理员账户(推荐)",
"emailNotice": "您的电子邮件地址可以用来接收通知。",
"allowAllDescription": "不建议,您应该允许单个用户在设置后登录。",
"allowAll": "允许所有Jellyfin用户登录"
"allowAll": "允许所有Jellyfin用户登录",
"authorizeManualUserPageNotice": "使用此选项将禁用“用户页面”功能。"
},
"jellyfinEmby": {
"title": "Jellyfin/Emby",
@@ -126,7 +128,8 @@
"resetLinksNotice": "如果启用了 Ombi 集成,请使用它与 Ombi 同步 Jellyfin 密码重置。",
"resetLinksLanguage": "默认重置链接语言",
"setPassword": "通过链接设置密码",
"setPasswordNotice": "启用此功能意味着用户无需在重置后通过 PIN 更改其密码。并将强制执行密码验证。"
"setPasswordNotice": "启用此功能意味着用户无需在重置后通过 PIN 更改其密码。并将强制执行密码验证。",
"resetLinksRequiredForUserPage": "对于用户页面上的自助密码重置,这是必需的。"
},
"passwordValidation": {
"title": "密码验证",
@@ -148,5 +151,17 @@
"successMessageNotice": "在用户创建账户时显示。",
"emailMessage": "电子邮件",
"emailMessageNotice": "显示在电子邮件的底部。"
},
"proxy": {
"description": "让jfa-go通过HTTP/SOCKS5代理进行所有连接。连接到Jellyfin将通过此代理进行测试。",
"title": "代理",
"protocol": "协议",
"address": "地址(包括端口)"
},
"userPage": {
"description": "用户页面(显示为“我的帐户”)允许用户访问有关他们帐户的信息,如其联系方式和账户过期日期。他们还可以更改密码、启动密码重置,并在无需询问您的情况下链接/更改联系方式。此外用户还可以在登录前后看到自定义的Markdown消息。",
"title": "用户页面",
"customizeMessages": "在设置中,单击“用户页面”旁边的编辑按钮以稍后进行设置。",
"requiredSettings": "通过Jellyfin登录到jfa-go必须设置。确保稍后选择“通过链接重置密码”以进行自助密码重置。"
}
}

View File

@@ -8,9 +8,11 @@
"invalidPIN": "Ce code PIN est invalide, réessayez.",
"pinSuccess": "Succès ! Vous pouvez maintenant retourner à la page dinscription.",
"languageMessage": "Note : Découvrez les langues disponibles avec {command} et paramétrez la langue souhaitée avec {command} <language code>.",
"discordStartMessage": "Salut !\nEntrez votre code PIN avec `/pin <PIN>` pour vérifier votre compte.",
"discordStartMessage": "Salut !\n.Entrez votre code PIN avec `/pin <PIN>` pour vérifier votre compte.",
"discordDMs": "Veuillez vérifier vos DM pour une réponse.",
"languageSet": "Langue définie sur {language}.",
"languageMessageDiscord": "Note: définissez votre langue avec /lang <nom de la langue>."
"languageMessageDiscord": "Note : définissez votre langue avec /lang <nom de la langue>.",
"sentInvite": "Invitation envoyée.",
"sentInviteFailure": "Échec de l'envoi de l'invitation, vérifiez les journaux."
}
}

View File

@@ -11,6 +11,8 @@
"languageMessageDiscord": "Nota: defina seu idioma com /lang <language name>.",
"languageSet": "Idioma definido como {idioma}.",
"discordDMs": "Por favor, verifique seus DMs para uma resposta.",
"discordStartMessage": "Oi!\n Digite seu PIN com `/pin <PIN>` para verificar sua conta."
"discordStartMessage": "Oi!\n Digite seu PIN com `/pin <PIN>` para verificar sua conta.",
"sentInvite": "Enviou convite.",
"sentInviteFailure": "Falha ao enviar o convite. Verifique os registros."
}
}
}

View File

@@ -11,6 +11,8 @@
"discordStartMessage": "您好!\n请输入 `/pin <PIN码>`以验证您的账户。",
"languageMessageDiscord": "提示:使用 `/lang <语言>` 设置语言。",
"languageSet": "语言改成 {language}。",
"discordDMs": "请检查您的DM找回答。"
"discordDMs": "请检查您的DM找回答。",
"sentInvite": "已发送邀请。",
"sentInviteFailure": "发送邀请失败,请检查日志。"
}
}
}

83
mail/expiry-adjusted.mjml Normal file
View File

@@ -0,0 +1,83 @@
<mjml>
<mj-head>
<mj-raw>
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
</mj-raw>
<mj-style>
:root {
Color-scheme: light dark;
supported-color-schemes: light dark;
}
@media (prefers-color-scheme: light) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
@media (prefers-color-scheme: dark) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
</mj-style>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="#cacaca" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> {{ .jellyfin }} </mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<p>{{ .helloUser }}</p>
<h3>{{ .yourExpiryWasAdjusted }}</h3>
<p>{{ .ifPreviouslyDisabled }}</p>
<h4>{{ .newExpiry }}</h4>
<p>{{ .reasonString }}: <i>{{ .reason }}</i></p>
</mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
{{ .message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

11
mail/expiry-adjusted.txt Normal file
View File

@@ -0,0 +1,11 @@
{{ .helloUser }}
{{ .yourExpiryWasAdjusted }}
{{ .ifPreviouslyDisabled }}
{{ .newExpiry }}
{{ .reasonString }}: {{ .reason }}
{{ .message }}

55
main.go
View File

@@ -25,6 +25,7 @@ import (
"github.com/hrfee/jfa-go/common"
_ "github.com/hrfee/jfa-go/docs"
"github.com/hrfee/jfa-go/easyproxy"
"github.com/hrfee/jfa-go/jellyseerr"
"github.com/hrfee/jfa-go/logger"
"github.com/hrfee/jfa-go/ombi"
"github.com/hrfee/mediabrowser"
@@ -46,6 +47,8 @@ var (
SWAGGER *bool
QUIT = false
RUNNING = false
LOGIP = false // Log admin IPs
LOGIPU = false // Log user IPs
// Used to know how many times to re-broadcast restart signal.
RESTARTLISTENERCOUNT = 0
warning = color.New(color.FgYellow).SprintfFunc()
@@ -99,6 +102,7 @@ type appContext struct {
jf *mediabrowser.MediaBrowser
authJf *mediabrowser.MediaBrowser
ombi *ombi.Ombi
js *jellyseerr.Jellyseerr
datePattern string
timePattern string
storage Storage
@@ -120,6 +124,7 @@ type appContext struct {
proxyTransport *http.Transport
proxyConfig easyproxy.ProxyConfig
internalPWRs map[string]InternalPWR
pwrCaptchas map[string]Captcha
ConfirmationKeys map[string]map[string]newUserDTO // Map of invite code to jwt to request
confirmationKeysLock sync.Mutex
}
@@ -356,6 +361,19 @@ func start(asDaemon, firstCall bool) {
}
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
app.debug.Printf("Connecting to Jellyseerr")
jellyseerrServer := app.config.Section("jellyseerr").Key("server").String()
app.js = jellyseerr.NewJellyseerr(
jellyseerrServer,
app.config.Section("jellyseerr").Key("api_key").String(),
common.NewTimeoutHandler("Jellyseerr", jellyseerrServer, true),
)
app.js.AutoImportUsers = app.config.Section("jellyseerr").Key("import_existing").MustBool(false)
// app.js.LogRequestBodies = true
}
app.storage.db_path = filepath.Join(app.dataPath, "db")
app.loadPendingBackup()
app.ConnectDB()
@@ -463,13 +481,21 @@ func start(asDaemon, firstCall bool) {
os.Exit(0)
}
invDaemon := newInviteDaemon(time.Duration(60*time.Second), app)
invDaemon := newHousekeepingDaemon(time.Duration(60*time.Second), app)
go invDaemon.run()
defer invDaemon.Shutdown()
userDaemon := newUserDaemon(time.Duration(60*time.Second), app)
go userDaemon.run()
defer userDaemon.shutdown()
defer userDaemon.Shutdown()
var jellyseerrDaemon *GenericDaemon
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) && app.config.Section("jellyseerr").Key("import_existing").MustBool(false) {
// jellyseerrDaemon = newJellyseerrDaemon(time.Duration(30*time.Second), app)
jellyseerrDaemon = newJellyseerrDaemon(time.Duration(10*time.Minute), app)
go jellyseerrDaemon.run()
defer jellyseerrDaemon.Shutdown()
}
if app.config.Section("password_resets").Key("enabled").MustBool(false) && serverType == mediabrowser.JellyfinServer {
go app.StartPWR()
@@ -479,7 +505,7 @@ func start(asDaemon, firstCall bool) {
go app.checkForUpdates()
}
var backupDaemon *housekeepingDaemon
var backupDaemon *GenericDaemon
if app.config.Section("backups").Key("enabled").MustBool(false) {
backupDaemon = newBackupDaemon(app)
go backupDaemon.run()
@@ -554,11 +580,28 @@ func start(asDaemon, firstCall bool) {
cert := app.config.Section("advanced").Key("tls_cert").MustString("")
key := app.config.Section("advanced").Key("tls_key").MustString("")
if err := SRV.ListenAndServeTLS(cert, key); err != nil {
app.err.Printf("Failure serving: %s", err)
filesToCheck := []string{cert, key}
fileNames := []string{"Certificate", "Key"}
for i, v := range filesToCheck {
_, err := os.Stat(v)
if err != nil {
app.err.Printf("SSL/TLS %s: %v\n", fileNames[i], err)
}
}
if err == http.ErrServerClosed {
app.err.Printf("Failure serving with SSL/TLS: %s", err)
} else {
app.err.Fatalf("Failure serving with SSL/TLS: %s", err)
}
}
} else {
if err := SRV.ListenAndServe(); err != nil {
app.err.Printf("Failure serving: %s", err)
if err == http.ErrServerClosed {
app.err.Printf("Failure serving: %s", err)
} else {
app.err.Fatalf("Failure serving: %s", err)
}
}
}
}()
@@ -616,7 +659,7 @@ func flagPassed(name string) (found bool) {
}
// @title jfa-go internal API
// @version 0.4.0
// @version 0.5.1
// @description API for the jfa-go frontend
// @contact.name Harvey Tindall
// @contact.email hrfee@hrfee.dev

View File

@@ -17,6 +17,7 @@ func runMigrations(app *appContext) {
linkExistingOmbiDiscordTelegram(app)
// migrateHyphens(app)
migrateToBadger(app)
intialiseCustomContent(app)
}
// Migrate pre-0.2.0 user templates to profiles
@@ -329,6 +330,8 @@ func migrateToBadger(app *appContext) {
app.storage.SetCustomContentKey("UserPage", app.storage.deprecatedUserPageContent.Page)
}
// Custom content not present here was added post-badger.
err := app.storage.db.Upsert("migrated_to_db", MigrationStatus{true})
if err != nil {
app.err.Fatalf("Failed to migrate to DB: %v\n", err)
@@ -336,6 +339,57 @@ func migrateToBadger(app *appContext) {
app.info.Println("All data migrated to database. JSON files in the config folder can be deleted if you are sure all data is correct in the app. Create an issue if you have problems.")
}
// Simply creates an emply CC template if not in the DB already.
// Add new CC types here!
func intialiseCustomContent(app *appContext) {
emptyCC := CustomContent{
Enabled: false,
}
if _, ok := app.storage.GetCustomContentKey("UserCreated"); !ok {
app.storage.SetCustomContentKey("UserCreated", emptyCC)
}
if _, ok := app.storage.GetCustomContentKey("InviteExpiry"); !ok {
app.storage.SetCustomContentKey("InviteExpiry", emptyCC)
}
if _, ok := app.storage.GetCustomContentKey("PasswordReset"); !ok {
app.storage.SetCustomContentKey("PasswordReset", emptyCC)
}
if _, ok := app.storage.GetCustomContentKey("UserDeleted"); !ok {
app.storage.SetCustomContentKey("UserDeleted", emptyCC)
}
if _, ok := app.storage.GetCustomContentKey("UserDisabled"); !ok {
app.storage.SetCustomContentKey("UserDisabled", emptyCC)
}
if _, ok := app.storage.GetCustomContentKey("UserEnabled"); !ok {
app.storage.SetCustomContentKey("UserEnabled", emptyCC)
}
if _, ok := app.storage.GetCustomContentKey("InviteEmail"); !ok {
app.storage.SetCustomContentKey("InviteEmail", emptyCC)
}
if _, ok := app.storage.GetCustomContentKey("WelcomeEmail"); !ok {
app.storage.SetCustomContentKey("WelcomeEmail", emptyCC)
}
if _, ok := app.storage.GetCustomContentKey("EmailConfirmation"); !ok {
app.storage.SetCustomContentKey("EmailConfirmation", emptyCC)
}
if _, ok := app.storage.GetCustomContentKey("UserExpired"); !ok {
app.storage.SetCustomContentKey("UserExpired", emptyCC)
}
if _, ok := app.storage.GetCustomContentKey("UserLogin"); !ok {
app.storage.SetCustomContentKey("UserLogin", emptyCC)
}
if _, ok := app.storage.GetCustomContentKey("UserPage"); !ok {
app.storage.SetCustomContentKey("UserPage", emptyCC)
}
if _, ok := app.storage.GetCustomContentKey("UserExpiryAdjusted"); !ok {
app.storage.SetCustomContentKey("UserExpiryAdjusted", emptyCC)
}
if _, ok := app.storage.GetCustomContentKey("PostSignupCard"); !ok {
app.storage.SetCustomContentKey("PostSignupCard", emptyCC)
}
}
// Migrate between hyphenated & non-hyphenated user IDs. Doesn't seem to happen anymore, so disabled.
// func migrateHyphens(app *appContext) {
// checkVersion := func(version string) int {

View File

@@ -16,6 +16,7 @@ type newUserDTO struct {
Username string `json:"username" example:"jeff" binding:"required"` // User's username
Password string `json:"password" example:"guest" binding:"required"` // User's password
Email string `json:"email" example:"jeff@jellyf.in"` // User's email address
EmailContact bool `json:"email_contact"` // Whether or not to use email for notifications/pwrs
Code string `json:"code" example:"abc0933jncjkcjj"` // Invite code (required on /newUser)
TelegramPIN string `json:"telegram_pin" example:"A1-B2-3C"` // Telegram verification PIN (if used)
TelegramContact bool `json:"telegram_contact"` // Whether or not to use telegram for notifications/pwrs
@@ -76,6 +77,7 @@ type profileDTO struct {
LibraryAccess string `json:"libraries" example:"all"` // Number of libraries profile has access to
FromUser string `json:"fromUser" example:"jeff"` // The user the profile is based on
Ombi bool `json:"ombi"` // Whether or not Ombi settings are stored in this profile.
Jellyseerr bool `json:"jellyseerr"` // Whether or not Jellyseerr settings are stored in this profile.
ReferralsEnabled bool `json:"referrals_enabled" example:"true"` // Whether or not the profile has referrals enabled, and has a template invite stored.
}
@@ -172,11 +174,16 @@ type ombiUsersDTO struct {
type modifyEmailsDTO map[string]string
type userSettingsDTO struct {
From string `json:"from"` // Whether to apply from "user" or "profile"
Profile string `json:"profile"` // Name of profile (if from = "profile")
ApplyTo []string `json:"apply_to"` // Users to apply settings to
ID string `json:"id"` // ID of user (if from = "user")
Homescreen bool `json:"homescreen"` // Whether to apply homescreen layout or not
From string `json:"from"` // Whether to apply from "user" or "profile"
Profile string `json:"profile"` // Name of profile (if from = "profile")
ApplyTo []string `json:"apply_to"` // Users to apply settings to
ID string `json:"id"` // ID of user (if from = "user")
// Note confusing name: "Configuration" on the admin UI just means it in the sense
// of the account's settings.
Policy bool `json:"configuration"` // Whether to apply jf policy not
Homescreen bool `json:"homescreen"` // Whether to apply homescreen layout or not
Ombi bool `json:"ombi"` // Whether to apply ombi profile or not
Jellyseerr bool `json:"jellyseerr"` // Whether to apply jellyseerr profile or not
}
type announcementDTO struct {
@@ -239,8 +246,9 @@ type langDTO map[string]string
type emailListDTO map[string]emailListEl
type emailListEl struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Description string `json:"description"`
}
type emailSetDTO struct {
@@ -261,12 +269,14 @@ type customEmailDTO struct {
}
type extendExpiryDTO struct {
Users []string `json:"users"` // List of user IDs to apply to.
Months int `json:"months" example:"1"` // Number of months to add.
Days int `json:"days" example:"1"` // Number of days to add.
Hours int `json:"hours" example:"2"` // Number of hours to add.
Minutes int `json:"minutes" example:"3"` // Number of minutes to add.
Timestamp int64 `json:"timestamp"` // Optional, exact time to expire at. Overrides other fields.
Users []string `json:"users"` // List of user IDs to apply to.
Months int `json:"months" example:"1"` // Number of months to add.
Days int `json:"days" example:"1"` // Number of days to add.
Hours int `json:"hours" example:"2"` // Number of hours to add.
Minutes int `json:"minutes" example:"3"` // Number of minutes to add.
Timestamp int64 `json:"timestamp"` // Optional, exact time to expire at. Overrides other fields.
Notify bool `json:"notify"` // Whether to message the user(s) about the change.
Reason string `json:"reason" example:"i felt like it"` // Reason for adjustment.
}
type checkUpdateDTO struct {
@@ -332,8 +342,9 @@ type MatrixLoginDTO struct {
}
type ResetPasswordDTO struct {
PIN string `json:"pin"`
Password string `json:"password"`
PIN string `json:"pin"`
Password string `json:"password"`
CaptchaText string `json:"captcha_text"`
}
type AdminPasswordResetDTO struct {
@@ -444,6 +455,7 @@ type ActivityDTO struct {
InviteCode string `json:"invite_code"`
Value string `json:"value"`
Time int64 `json:"time"`
IP string `json:"ip"`
}
type GetActivitiesDTO struct {

1
package-lock.json generated
View File

@@ -1653,6 +1653,7 @@
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
"integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"bin": {
"esbuild": "bin/esbuild"

View File

@@ -24,19 +24,29 @@ func (app *appContext) loadHTML(router *gin.Engine) {
app.err.Fatalf("Couldn't access template directory: \"%s\"", templatePath)
return
}
loadFiles := make([]string, len(htmlFiles))
for i, f := range htmlFiles {
loadInternal := []string{}
loadExternal := []string{}
for _, f := range htmlFiles {
if _, err := os.Stat(filepath.Join(customPath, f.Name())); os.IsNotExist(err) {
app.debug.Printf("Using default \"%s\"", f.Name())
loadFiles[i] = FSJoin(templatePath, f.Name())
loadInternal = append(loadInternal, FSJoin(templatePath, f.Name()))
} else {
app.info.Printf("Using custom \"%s\"", f.Name())
loadFiles[i] = filepath.Join(filepath.Join(customPath, f.Name()))
loadExternal = append(loadExternal, filepath.Join(filepath.Join(customPath, f.Name())))
}
}
tmpl, err := template.ParseFS(localFS, loadFiles...)
if err != nil {
app.err.Fatalf("Failed to load templates: %v", err)
var tmpl *template.Template
if len(loadInternal) != 0 {
tmpl, err = template.ParseFS(localFS, loadInternal...)
if err != nil {
app.err.Fatalf("Failed to load templates: %v", err)
}
}
if len(loadExternal) != 0 {
tmpl, err = tmpl.ParseFiles(loadExternal...)
if err != nil {
app.err.Fatalf("Failed to load external templates: %v", err)
}
}
router.SetHTMLTemplate(tmpl)
}
@@ -228,6 +238,11 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.GET(p+"/users/discord/:username", app.DiscordGetUsers)
api.POST(p+"/users/discord", app.DiscordConnect)
}
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
api.GET(p+"/jellyseerr/users", app.JellyseerrUsers)
api.POST(p+"/profiles/jellyseerr/:profile/:id", app.SetJellyseerrProfile)
api.DELETE(p+"/profiles/jellyseerr/:profile", app.DeleteJellyseerrProfile)
}
if app.config.Section("ombi").Key("enabled").MustBool(false) {
api.GET(p+"/ombi/users", app.OmbiUsers)
api.POST(p+"/profiles/ombi/:profile", app.SetOmbiProfile)

View File

@@ -16,4 +16,6 @@ else
fi
JFA_GO_VERSION=$(git describe --exact-match HEAD 2> /dev/null || echo 'vgit')
JFA_GO_CSS_VERSION="v3" JFA_GO_NFPM_EPOCH=$(git rev-list --all --count) JFA_GO_BUILD_TIME=$(date +%s) JFA_GO_BUILT_BY=${JFA_GO_BUILT_BY:-"???"} JFA_GO_VERSION="$(echo $JFA_GO_VERSION | sed 's/v//g')" $@
TIMEOUT=60m
JFA_GO_CSS_VERSION="v3" JFA_GO_NFPM_EPOCH=$(git rev-list --all --count) JFA_GO_BUILD_TIME=$(date +%s) JFA_GO_BUILT_BY=${JFA_GO_BUILT_BY:-"???"} JFA_GO_VERSION="$(echo $JFA_GO_VERSION | sed 's/v//g')" $@ --timeout $TIMEOUT

View File

@@ -7,11 +7,11 @@ all:
cp -r ts tempts
../scripts/dark-variant.sh tempts
npx esbuild --target=es6 --bundle tempts/main.ts --outfile=out/main.js --minify
npx esbuild --bundle base.css --outfile=out/bundle.css --external:remixicon.css --external:modal.css --minify
npx esbuild --bundle base.css --outfile=out/bundle.css --external:remixicon.css --external:modal.css --external:../fonts/hanken* --minify
npx tailwindcss -c tailwind.config.js -i out/bundle.css -o out/bundle.css
cd out && npx uncss index.html --stylesheets remixicon.css > _remixicon.css; cd ..
mv out/_remixicon.css out/remixicon.css
cp ../static/* out/
cp -r ../static/* out/
node inject.js
debug:
@@ -22,10 +22,10 @@ debug:
-rm -r tempts
cp -r ts tempts
../scripts/dark-variant.sh tempts
npx esbuild --bundle base.css --outfile=out/bundle.css --external:remixicon.css --minify
npx esbuild --bundle base.css --outfile=out/bundle.css --external:remixicon.css --external:../fonts/hanken* --minify
npx esbuild --target=es6 --bundle ts/main.ts --sourcemap --outfile=out/main.js --minify
npx tailwindcss -c tailwind.config.js -i out/bundle.css -o out/bundle.css
cp ../static/* out/
cp -r ../static/* out/
monitor:
npx live-server --watch=out --open=out/index.html &

View File

@@ -21,7 +21,8 @@
<div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
<span class="heading"> Debian/Ubuntu (apt)</span>
<div class="mt-1">
<pre style="margin: 0; line-height: 125%">curl https://apt.hrfee.dev/hrfee.pubkey.gpg | sudo apt-key add -
<pre style="margin: 0; line-height: 125%">sudo apt-get update && sudo apt-get install curl apt-transport-https gnupg
curl https://apt.hrfee.dev/hrfee.pubkey.gpg | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/apt.hrfee.dev.gpg
echo <span style="color: #aa5500">&quot;deb https://apt.hrfee.dev trusty<span id="deb-unstable" class="unfocused">-unstable</span> main&quot;</span> | sudo tee /etc/apt/sources.list.d/hrfee.list
@@ -129,7 +130,7 @@ sudo apt-get install jfa-go-tray
<a class="button ~info mr-2 mb-2 lang-link" target="_blank" href="https://aur.archlinux.org/packages/jfa-go-git">arch (aur git)</a>
</div>
</div>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE" class="support">© 2023 Harvey Tindall</a>
</section>
</div>

View File

@@ -10,6 +10,8 @@ import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/jellyseerr"
"github.com/hrfee/jfa-go/logger"
"github.com/hrfee/mediabrowser"
"github.com/timshannon/badgerhold/v4"
@@ -55,6 +57,7 @@ type Activity struct {
InviteCode string // Set for ActivityCreation, create/deleteInvite
Value string // Used for ActivityContactLinked where it's "email/discord/telegram/matrix", Create/DeleteInvite, where it's the label, and Creation/Deletion, where it's the Username.
Time time.Time
IP string
}
type UserExpiry struct {
@@ -563,8 +566,12 @@ func (st *Storage) GetActivityKey(k string) (Activity, bool) {
}
// SetActivityKey stores value v in key k.
func (st *Storage) SetActivityKey(k string, v Activity) {
// If the IP should be logged, pass "gc", and whether or not the action is of a user
func (st *Storage) SetActivityKey(k string, v Activity, gc *gin.Context, user bool) {
v.ID = k
if gc != nil && ((LOGIPU && user) || (LOGIP && !user)) {
v.IP = gc.ClientIP()
}
err := st.db.Upsert(k, v)
if err != nil {
// fmt.Printf("Failed to set custom content: %v\n", err)
@@ -604,16 +611,17 @@ type EmailAddress struct {
}
type customEmails struct {
UserCreated CustomContent `json:"userCreated"`
InviteExpiry CustomContent `json:"inviteExpiry"`
PasswordReset CustomContent `json:"passwordReset"`
UserDeleted CustomContent `json:"userDeleted"`
UserDisabled CustomContent `json:"userDisabled"`
UserEnabled CustomContent `json:"userEnabled"`
InviteEmail CustomContent `json:"inviteEmail"`
WelcomeEmail CustomContent `json:"welcomeEmail"`
EmailConfirmation CustomContent `json:"emailConfirmation"`
UserExpired CustomContent `json:"userExpired"`
UserCreated CustomContent `json:"userCreated"`
InviteExpiry CustomContent `json:"inviteExpiry"`
PasswordReset CustomContent `json:"passwordReset"`
UserDeleted CustomContent `json:"userDeleted"`
UserDisabled CustomContent `json:"userDisabled"`
UserEnabled CustomContent `json:"userEnabled"`
UserExpiryAdjusted CustomContent `json:"userExpiryAdjusted"`
InviteEmail CustomContent `json:"inviteEmail"`
WelcomeEmail CustomContent `json:"welcomeEmail"`
EmailConfirmation CustomContent `json:"emailConfirmation"`
UserExpired CustomContent `json:"userExpired"`
}
// CustomContent stores customized versions of jfa-go content, including emails and user messages.
@@ -643,9 +651,16 @@ type Profile struct {
Displayprefs map[string]interface{} `json:"displayprefs,omitempty"`
Default bool `json:"default,omitempty"`
Ombi map[string]interface{} `json:"ombi,omitempty"`
Jellyseerr JellyseerrTemplate `json:"jellyseerr,omitempty"`
ReferralTemplateKey string
}
type JellyseerrTemplate struct {
Enabled bool `json:"enabled,omitempty"`
User jellyseerr.UserTemplate `json:"user,omitempty"`
Notifications jellyseerr.NotificationsTemplate `json:"notifications,omitempty"`
}
type Invite struct {
Code string `badgerhold:"key"`
Created time.Time `json:"created"`
@@ -1219,6 +1234,7 @@ func (st *Storage) loadLangEmail(filesystems ...fs.FS) error {
patchLang(&lang.UserDeleted, &fallback.UserDeleted, &english.UserDeleted)
patchLang(&lang.UserDisabled, &fallback.UserDisabled, &english.UserDisabled)
patchLang(&lang.UserEnabled, &fallback.UserEnabled, &english.UserEnabled)
patchLang(&lang.UserExpiryAdjusted, &fallback.UserExpiryAdjusted, &english.UserExpiryAdjusted)
patchLang(&lang.InviteEmail, &fallback.InviteEmail, &english.InviteEmail)
patchLang(&lang.WelcomeEmail, &fallback.WelcomeEmail, &english.WelcomeEmail)
patchLang(&lang.EmailConfirmation, &fallback.EmailConfirmation, &english.EmailConfirmation)
@@ -1233,6 +1249,7 @@ func (st *Storage) loadLangEmail(filesystems ...fs.FS) error {
patchLang(&lang.UserDeleted, &english.UserDeleted)
patchLang(&lang.UserDisabled, &english.UserDisabled)
patchLang(&lang.UserEnabled, &english.UserEnabled)
patchLang(&lang.UserExpiryAdjusted, &english.UserExpiryAdjusted)
patchLang(&lang.InviteEmail, &english.InviteEmail)
patchLang(&lang.WelcomeEmail, &english.WelcomeEmail)
patchLang(&lang.EmailConfirmation, &english.EmailConfirmation)

View File

@@ -7,7 +7,7 @@ import { accountsList } from "./modules/accounts.js";
import { settingsList } from "./modules/settings.js";
import { activityList } from "./modules/activity.js";
import { ProfileEditor } from "./modules/profiles.js";
import { _get, _post, notificationBox, whichAnimationEvent } from "./modules/common.js";
import { _get, _post, notificationBox, whichAnimationEvent, bindManualDropdowns } from "./modules/common.js";
import { Updater } from "./modules/update.js";
import { Login } from "./modules/login.js";
@@ -49,6 +49,9 @@ window.availableProfiles = window.availableProfiles || [];
window.modals.ombiProfile = new Modal(document.getElementById('modal-ombi-profile'));
document.getElementById('form-ombi-defaults').addEventListener('submit', window.modals.ombiProfile.close);
window.modals.jellyseerrProfile = new Modal(document.getElementById('modal-jellyseerr-profile'));
document.getElementById('form-jellyseerr-defaults').addEventListener('submit', window.modals.jellyseerrProfile.close);
window.modals.profiles = new Modal(document.getElementById("modal-user-profiles"));
@@ -184,6 +187,7 @@ login.onLogin = () => {
console.log("Logged in.");
window.updater = new Updater();
// FIXME: Decide whether to autoload activity or not
window.invites.reload()
setInterval(() => { window.invites.reload(); accounts.reload(); }, 30*1000);
const currentTab = window.tabs.current;
switch (currentTab) {
@@ -216,6 +220,8 @@ login.onLogin = () => {
}
}
bindManualDropdowns();
login.bindLogout(document.getElementById("logout-button"));
login.login("", "");

View File

@@ -4,6 +4,7 @@ import { _get, _post, toggleLoader, addLoader, removeLoader, toDateString } from
import { loadLangSelector } from "./modules/lang.js";
import { Validator, ValidatorConf, ValidatorRespDTO } from "./modules/validator.js";
import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js";
import { Captcha, GreCAPTCHA } from "./modules/captcha.js";
interface formWindow extends Window {
invalidPassword: string;
@@ -37,6 +38,7 @@ interface formWindow extends Window {
reCAPTCHASiteKey: string;
userPageEnabled: boolean;
userPageAddress: string;
customSuccessCard: boolean;
}
loadLangSelector("form");
@@ -172,35 +174,7 @@ if (!window.usernameEnabled) { usernameField.parentElement.remove(); usernameFie
const passwordField = document.getElementById("create-password") as HTMLInputElement;
const rePasswordField = document.getElementById("create-reenter-password") as HTMLInputElement;
let captchaVerified = false;
let captchaID = "";
let captchaInput = document.getElementById("captcha-input") as HTMLInputElement;
const captchaCheckbox = document.getElementById("captcha-success") as HTMLSpanElement;
let prevCaptcha = "";
let baseValidator = (oncomplete: (valid: boolean) => void): void => {
if (window.captcha && !window.reCAPTCHA && (captchaInput.value != prevCaptcha)) {
prevCaptcha = captchaInput.value;
_post("/captcha/verify/" + window.code + "/" + captchaID + "/" + captchaInput.value, null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 204) {
captchaCheckbox.innerHTML = `<i class="ri-check-line"></i>`;
captchaCheckbox.classList.add("~positive");
captchaCheckbox.classList.remove("~critical");
captchaVerified = true;
} else {
captchaCheckbox.innerHTML = `<i class="ri-close-line"></i>`;
captchaCheckbox.classList.add("~critical");
captchaCheckbox.classList.remove("~positive");
captchaVerified = false;
}
_baseValidator(oncomplete, captchaVerified);
}
});
} else {
_baseValidator(oncomplete, captchaVerified);
}
}
let captcha = new Captcha(window.code, window.captcha, window.reCAPTCHA, false);
function _baseValidator(oncomplete: (valid: boolean) => void, captchaValid: boolean): void {
if (window.emailRequired) {
@@ -228,20 +202,9 @@ function _baseValidator(oncomplete: (valid: boolean) => void, captchaValid: bool
oncomplete(true);
}
interface GreCAPTCHA {
render: (container: HTMLDivElement, parameters: {
sitekey?: string,
theme?: string,
size?: string,
tabindex?: number,
"callback"?: () => void,
"expired-callback"?: () => void,
"error-callback"?: () => void
}) => void;
getResponse: (opt_widget_id?: HTMLDivElement) => string;
}
let baseValidator = captcha.baseValidatorWrapper(_baseValidator);
declare var grecaptcha: GreCAPTCHA
declare var grecaptcha: GreCAPTCHA;
let validatorConf: ValidatorConf = {
passwordField: passwordField,
@@ -261,6 +224,7 @@ if (window.emailRequired) {
interface sendDTO {
code: string;
email: string;
email_contact?: boolean;
username: string;
password: string;
telegram_pin?: string;
@@ -273,29 +237,15 @@ interface sendDTO {
captcha_text?: string;
}
const genCaptcha = () => {
_get("/captcha/gen/"+window.code, null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 200) {
captchaID = req.response["id"];
document.getElementById("captcha-img").innerHTML = `
<img class="w-100" src="${window.location.toString().substring(0, window.location.toString().lastIndexOf("/invite"))}/captcha/img/${window.code}/${captchaID}"></img>
`;
captchaInput.value = "";
}
}
});
};
if (window.captcha && !window.reCAPTCHA) {
genCaptcha();
(document.getElementById("captcha-regen") as HTMLSpanElement).onclick = genCaptcha;
captchaInput.onkeyup = validator.validate;
captcha.generate();
(document.getElementById("captcha-regen") as HTMLSpanElement).onclick = captcha.generate;
captcha.input.onkeyup = validator.validate;
}
const create = (event: SubmitEvent) => {
event.preventDefault();
if (window.captcha && !window.reCAPTCHA && !captchaVerified) {
if (window.captcha && !window.reCAPTCHA && !captcha.verified) {
}
addLoader(submitSpan);
@@ -303,8 +253,9 @@ const create = (event: SubmitEvent) => {
code: window.code,
username: usernameField.value,
email: emailField.value,
email_contact: true,
password: passwordField.value
};
}
if (telegramVerified) {
send.telegram_pin = window.telegramPIN;
const checkbox = document.getElementById("contact-via-telegram") as HTMLInputElement;
@@ -326,12 +277,16 @@ const create = (event: SubmitEvent) => {
send.matrix_contact = true;
}
}
if (matrixVerified || discordVerified || telegramVerified) {
const checkbox = document.getElementById("contact-via-email") as HTMLInputElement;
send.email_contact = checkbox.checked;
}
if (window.captcha) {
if (window.reCAPTCHA) {
send.captcha_text = grecaptcha.getResponse();
} else {
send.captcha_id = captchaID;
send.captcha_text = captchaInput.value;
send.captcha_id = captcha.captchaID;
send.captcha_text = captcha.input.value;
}
}
_post("/newUser", send, (req: XMLHttpRequest) => {
@@ -348,7 +303,10 @@ const create = (event: SubmitEvent) => {
const url = ((document.getElementById("modal-success") as HTMLDivElement).querySelector("a.submit") as HTMLAnchorElement).href;
window.location.href = url;
} else {
if (window.userPageEnabled) {
if (window.customSuccessCard) {
const content = window.successModal.asElement().querySelector(".card");
content.innerHTML = content.innerHTML.replace(new RegExp("{username}", "g"), send.username)
} else if (window.userPageEnabled) {
const userPageNoticeArea = document.getElementById("modal-success-user-page-area");
const link = `<a href="${window.userPageAddress}" target="_blank">${userPageNoticeArea.getAttribute("my-account-term")}</a>`;
userPageNoticeArea.innerHTML = userPageNoticeArea.textContent.replace("{myAccount}", link);

View File

@@ -188,7 +188,7 @@ class user implements User, SearchableItem {
if (!telegram && !discord && !matrix && !email) return;
let innerHTML = `
<i class="icon ri-settings-2-line ml-2 dropdown-button"></i>
<div class="dropdown over-top manual">
<div class="dropdown manual">
<div class="dropdown-display lg">
<div class="card ~neutral @low">
<div class="supra sm mb-2">${window.lang.strings("contactThrough")}</div>
@@ -218,13 +218,13 @@ class user implements User, SearchableItem {
</div>
<div class="supra sm mb-2 accounts-unlink-header">${window.lang.strings("unlink")}:</div>
<div class="accounts-unlink-telegram">
<button class="button ~critical mb-2 w-100">Telegram</button>
<button class="button ~critical mb-2 w-full">Telegram</button>
</div>
<div class="accounts-unlink-discord">
<button class="button ~critical mb-2 w-100">Discord</button>
<button class="button ~critical mb-2 w-full">Discord</button>
</div>
<div class="accounts-unlink-matrix">
<button class="button ~critical mb-2 w-100">Matrix</button>
<button class="button ~critical mb-2 w-full">Matrix</button>
</div>
</div>
</div>
@@ -795,6 +795,11 @@ export class accountsList {
private _searchBox = document.getElementById("accounts-search") as HTMLInputElement;
private _search: Search;
private _applyHomescreen = document.getElementById("modify-user-homescreen") as HTMLInputElement;
private _applyConfiguration = document.getElementById("modify-user-configuration") as HTMLInputElement;
private _applyOmbi = document.getElementById("modify-user-ombi") as HTMLInputElement;
private _applyJellyseerr = document.getElementById("modify-user-jellyseerr") as HTMLInputElement;
private _selectAll = document.getElementById("accounts-select-all") as HTMLInputElement;
private _users: { [id: string]: user };
private _ordering: string[] = [];
@@ -1271,12 +1276,13 @@ export class accountsList {
dList.textContent = '';
for (let name of list) {
const el = document.createElement("div") as HTMLDivElement;
el.classList.add("flex-expand", "ellipsis", "mt-2");
el.classList.add("flex", "flex-row", "justify-between", "truncate", "mt-2");
el.innerHTML = `
<span class="button ~neutral sm full-width accounts-announce-template-button">${name}</span><span class="button ~critical fr ml-4 accounts-announce-template-delete">&times;</span>
`;
let urlSafeName = encodeURIComponent(encodeURIComponent(name));
(el.querySelector("span.accounts-announce-template-button") as HTMLSpanElement).onclick = () => {
_get("/users/announce/" + name, null, (req: XMLHttpRequest) => {
_get("/users/announce/" + urlSafeName, null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
let template: announcementTemplate;
if (req.status != 200) {
@@ -1289,7 +1295,7 @@ export class accountsList {
});
};
(el.querySelector("span.accounts-announce-template-delete") as HTMLSpanElement).onclick = () => {
_delete("/users/announce/" + name, null, (req: XMLHttpRequest) => {
_delete("/users/announce/" + urlSafeName, null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status != 200) {
window.notifications.customError("deleteTemplateError", window.lang.notif("errorFailureCheckLogs"));
@@ -1458,6 +1464,7 @@ export class accountsList {
const modalHeader = document.getElementById("header-modify-user");
modalHeader.textContent = window.lang.quantity("modifySettingsFor", this._collectUsers().length)
let list = this._collectUsers();
(() => {
let innerHTML = "";
for (const profile of window.availableProfiles) {
@@ -1476,6 +1483,7 @@ export class accountsList {
const form = document.getElementById("form-modify-user") as HTMLFormElement;
const button = form.querySelector("span.submit") as HTMLSpanElement;
this._modifySettingsProfile.checked = true;
this._modifySettingsUser.checked = false;
form.onsubmit = (event: Event) => {
@@ -1483,7 +1491,10 @@ export class accountsList {
toggleLoader(button);
let send = {
"apply_to": list,
"homescreen": (document.getElementById("modify-user-homescreen") as HTMLInputElement).checked
"homescreen": this._applyHomescreen.checked,
"configuration": this._applyConfiguration.checked,
"ombi": this._applyOmbi.checked,
"jellyseerr": this._applyJellyseerr.checked
};
if (this._modifySettingsProfile.checked && !this._modifySettingsUser.checked) {
send["from"] = "profile";
@@ -1634,6 +1645,7 @@ export class accountsList {
_displayExpiryDate = () => {
let date: Date;
let invalid = false;
let users = this._collectUsers();
if (this._usingExtendExpiryTextInput) {
date = (Date as any).fromString(this._extendExpiryTextInput.value) as Date;
invalid = "invalid" in (date as any);
@@ -1645,7 +1657,7 @@ export class accountsList {
document.getElementById("extend-expiry-minutes") as HTMLSelectElement
];
invalid = fields[0].value == "0" && fields[1].value == "0" && fields[2].value == "0" && fields[3].value == "0";
let id = this._collectUsers().length == 1 ? this._collectUsers()[0] : "";
let id = users.length > 0 ? users[0] : "";
if (!id) invalid = true;
else {
date = new Date(this._users[id].expiry*1000);
@@ -1665,7 +1677,12 @@ export class accountsList {
} else {
submit.disabled = false;
submitSpan.classList.remove("opacity-60");
this._extendExpiryDate.textContent = window.lang.strings("accountWillExpire").replace("{date}", toDateString(date));
this._extendExpiryDate.innerHTML = `
<div class="flex flex-col">
<span>${window.lang.strings("accountWillExpire").replace("{date}", toDateString(date))}</span>
${users.length > 1 ? "<span>"+window.lang.strings("expirationBasedOn")+"</span>" : ""}
</div>
`;
this._extendExpiryDate.classList.remove("unfocused");
}
}
@@ -1677,22 +1694,25 @@ export class accountsList {
applyList.push(id);
}
this._enableExpiryReason.classList.add("unfocused");
this._enableExpiryNotify.parentElement.classList.remove("unfocused");
this._enableExpiryNotify.checked = false;
this._enableExpiryReason.value = "";
let header: string;
if (enableUser) {
header = window.lang.quantity("reEnableUsers", list.length);
this._enableExpiryNotify.parentElement.classList.remove("unfocused");
this._enableExpiryNotify.checked = false;
this._enableExpiryReason.value = "";
} else if (this._settingExpiry) {
header = window.lang.quantity("setExpiry", list.length);
this._enableExpiryNotify.parentElement.classList.add("unfocused");
// this._enableExpiryNotify.parentElement.classList.add("unfocused");
} else {
header = window.lang.quantity("extendExpiry", applyList.length);
this._enableExpiryNotify.parentElement.classList.add("unfocused");
// this._enableExpiryNotify.parentElement.classList.add("unfocused");
}
document.getElementById("header-extend-expiry").textContent = header;
const extend = () => {
let send = { "users": applyList, "timestamp": 0 }
let send = { "users": applyList, "timestamp": 0, "notify": this._enableExpiryNotify.checked }
if (this._enableExpiryNotify.checked) {
send["reason"] = this._enableExpiryReason.value;
}
if (this._usingExtendExpiryTextInput) {
let date = (Date as any).fromString(this._extendExpiryTextInput.value) as Date;
send["timestamp"] = Math.floor(date.getTime() / 1000);
@@ -1721,7 +1741,7 @@ export class accountsList {
this._extendExpiryForm.onsubmit = (event: Event) => {
event.preventDefault();
if (enableUser) {
this._enableDisableUsers(applyList, true, this._enableExpiryNotify.checked, this._enableExpiryNotify ? this._enableExpiryReason.value : null, (req: XMLHttpRequest) => {
this._enableDisableUsers(applyList, true, this._enableExpiryNotify.checked, this._enableExpiryNotify.checked ? this._enableExpiryReason.value : null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status != 200 && req.status != 204) {
window.modals.extendExpiry.close();
@@ -1740,6 +1760,9 @@ export class accountsList {
}
}
this._extendExpiryTextInput.value = "";
this._usingExtendExpiryTextInput = false;
this._extendExpiryDate.classList.add("unfocused");
this._displayExpiryDate();
window.modals.extendExpiry.show();
}
@@ -1808,6 +1831,16 @@ export class accountsList {
};
this._modifySettings.onclick = this.modifyUsers;
this._modifySettings.classList.add("unfocused");
if (window.ombiEnabled)
this._applyOmbi.parentElement.classList.remove("unfocused");
else
this._applyOmbi.parentElement.classList.add("unfocused");
if (window.jellyseerrEnabled)
this._applyJellyseerr.parentElement.classList.remove("unfocused");
else
this._applyJellyseerr.parentElement.classList.add("unfocused");
const checkSource = () => {
const profileSpan = this._modifySettingsProfile.nextElementSibling as HTMLSpanElement;
const userSpan = this._modifySettingsUser.nextElementSibling as HTMLSpanElement;
@@ -1818,6 +1851,8 @@ export class accountsList {
profileSpan.classList.remove("@low");
userSpan.classList.remove("@high");
userSpan.classList.add("@low");
this._applyOmbi.parentElement.classList.remove("unfocused");
this._applyJellyseerr.parentElement.classList.remove("unfocused");
} else {
this._userSelect.parentElement.classList.remove("unfocused");
this._profileSelect.parentElement.classList.add("unfocused");
@@ -1825,6 +1860,8 @@ export class accountsList {
userSpan.classList.remove("@low");
profileSpan.classList.remove("@high");
profileSpan.classList.add("@low");
this._applyOmbi.parentElement.classList.add("unfocused");
this._applyJellyseerr.parentElement.classList.add("unfocused");
}
};
this._modifySettingsProfile.onchange = checkSource;

View File

@@ -14,6 +14,7 @@ export interface activity {
time: number;
username: string;
source_username: string;
ip: string;
}
var activityTypeMoods = {
@@ -43,6 +44,7 @@ export class Activity implements activity, SearchableItem {
private _referrer: HTMLElement;
private _expiryTypeBadge: HTMLElement;
private _delete: HTMLElement;
private _ip: HTMLElement;
private _act: activity;
private _urlBase: string = ((): string => {
let link = window.location.href;
@@ -205,6 +207,16 @@ export class Activity implements activity, SearchableItem {
}
}
get ip(): string { return this._act.ip; }
set ip(v: string) {
this._act.ip = v;
if (v) {
this._ip.innerHTML = `<span class="supra mr-2">IP</span><span class="font-mono bg-inherit">${v}</span>`;
} else {
this._ip.textContent = ``;
}
}
get invite_code(): string { return this._act.invite_code; }
set invite_code(v: string) {
this._act.invite_code = v;
@@ -260,12 +272,13 @@ export class Activity implements activity, SearchableItem {
<span class="activity-expiry-type badge self-start md:self-end mt-1"></span>
</div>
</div>
<div class="flex flex-col md:flex-row justify-between">
<div>
<span class="content supra mr-2 activity-source-type"></span><span class="activity-source"></span>
</div>
<div>
<div class="flex flex-row justify-between items-end">
<div class="flex flex-col md:flex-row gap-2">
<div>
<span class="content supra mr-2 activity-source-type"></span><span class="activity-source"></span>
</div>
<span class="content activity-referrer"></span>
<span class="content activity-ip"></span>
</div>
<div>
<button class="button @low hover:~critical rounded-full px-1 py-px activity-delete" aria-label="${window.lang.strings("delete")}"><i class="ri-close-line"></i></button>
@@ -277,6 +290,7 @@ export class Activity implements activity, SearchableItem {
this._time = this._card.querySelector(".activity-time");
this._sourceType = this._card.querySelector(".activity-source-type");
this._source = this._card.querySelector(".activity-source");
this._ip = this._card.querySelector(".activity-ip");
this._referrer = this._card.querySelector(".activity-referrer");
this._expiryTypeBadge = this._card.querySelector(".activity-expiry-type");
this._delete = this._card.querySelector(".activity-delete");
@@ -324,6 +338,7 @@ export class Activity implements activity, SearchableItem {
this.source = act.source;
this.value = act.value;
this.type = act.type;
this.ip = act.ip;
}
delete = () => _delete("/activity/" + this._act.id, null, (req: XMLHttpRequest) => {

81
ts/modules/captcha.ts Normal file
View File

@@ -0,0 +1,81 @@
import { _get, _post } from "./common.js";
export class Captcha {
isPWR = false;
enabled = true;
verified = false;
captchaID = "";
input = document.getElementById("captcha-input") as HTMLInputElement;
checkbox = document.getElementById("captcha-success") as HTMLSpanElement;
previous = "";
reCAPTCHA = false;
code = "";
get value(): string { return this.input.value; }
hasChanged = (): boolean => { return this.value != this.previous; }
baseValidatorWrapper = (_baseValidator: (oncomplete: (valid: boolean) => void, captchaValid: boolean) => void) => {
return (oncomplete: (valid: boolean) => void): void => {
if (this.enabled && !this.reCAPTCHA && this.hasChanged()) {
this.previous = this.value;
this.verify(() => {
_baseValidator(oncomplete, this.verified);
});
} else {
_baseValidator(oncomplete, this.verified);
}
};
};
verify = (callback: () => void) => _post("/captcha/verify/" + this.code + "/" + this.captchaID + "/" + this.input.value + (this.isPWR ? "?pwr=true" : ""), null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 204) {
this.checkbox.innerHTML = `<i class="ri-check-line"></i>`;
this.checkbox.classList.add("~positive");
this.checkbox.classList.remove("~critical");
this.verified = true;
} else {
this.checkbox.innerHTML = `<i class="ri-close-line"></i>`;
this.checkbox.classList.add("~critical");
this.checkbox.classList.remove("~positive");
this.verified = false;
}
callback();
}
});
generate = () => _get("/captcha/gen/"+this.code+(this.isPWR ? "?pwr=true" : ""), null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 200) {
this.captchaID = this.isPWR ? this.code : req.response["id"];
// the Math.random() appearance below is used for PWRs, since they don't have a unique captchaID. The parameter is ignored by the server, but tells the browser to reload the image.
document.getElementById("captcha-img").innerHTML = `
<img class="w-full" src="${window.location.toString().substring(0, window.location.toString().lastIndexOf("/invite"))}/captcha/img/${this.code}/${this.isPWR ? Math.random() : this.captchaID}${this.isPWR ? "?pwr=true" : ""}"></img>
`;
this.input.value = "";
}
}
});
constructor(code: string, enabled: boolean, reCAPTCHA: boolean, isPWR: boolean) {
this.code = code;
this.enabled = enabled;
this.reCAPTCHA = reCAPTCHA;
this.isPWR = isPWR;
}
}
export interface GreCAPTCHA {
render: (container: HTMLDivElement, parameters: {
sitekey?: string,
theme?: string,
size?: string,
tabindex?: number,
"callback"?: () => void,
"expired-callback"?: () => void,
"error-callback"?: () => void
}) => void;
getResponse: (opt_widget_id?: HTMLDivElement) => string;
}

View File

@@ -263,3 +263,31 @@ export function insertText(textarea: HTMLTextAreaElement, text: string) {
textarea.focus();
}
}
export function bindManualDropdowns() {
const buttons = Array.from(document.getElementsByClassName("dropdown-manual-toggle") as HTMLCollectionOf<HTMLSpanElement>);
for (let button of buttons) {
const parent = button.closest(".dropdown.manual");
const display = parent.querySelector(".dropdown-display");
const mousein = () => parent.classList.add("selected");
const mouseout = () => parent.classList.remove("selected");
button.addEventListener("mouseover", mousein);
button.addEventListener("mouseout", mouseout);
display.addEventListener("mouseover", mousein);
display.addEventListener("mouseout", mouseout);
button.onclick = () => {
parent.classList.add("selected");
document.addEventListener("click", outerClickListener);
button.removeEventListener("mouseout", mouseout);
display.removeEventListener("mouseout", mouseout);
};
const outerClickListener = (event: Event) => {
if (!(event.target instanceof HTMLElement && (display.contains(event.target) || button.contains(event.target)))) {
parent.classList.remove("selected");
document.removeEventListener("click", outerClickListener);
button.addEventListener("mouseout", mouseout);
display.addEventListener("mouseout", mouseout);
}
};
}
}

View File

@@ -78,7 +78,7 @@ class DOMInvite implements Invite {
get expiresIn(): string { return this._expiresIn }
set expiresIn(expiry: string) {
this._expiresIn = expiry;
this._infoArea.querySelector("span.inv-duration").textContent = expiry;
this._codeArea.querySelector("span.inv-duration").textContent = expiry;
}
private _userExpiry: string;
@@ -110,15 +110,15 @@ class DOMInvite implements Invite {
const chip = container.querySelector("span.inv-email-chip");
const tooltip = container.querySelector("span.content") as HTMLSpanElement;
if (address == "") {
container.classList.remove("mr-4");
icon.classList.remove("ri-mail-line");
icon.classList.remove("ri-mail-close-line");
chip.classList.remove("~neutral");
chip.classList.remove("~critical");
chip.classList.remove("chip");
chip.classList.remove("button");
chip.parentElement.classList.remove("h-full");
} else {
container.classList.add("mr-4");
chip.classList.add("chip");
chip.classList.add("button");
chip.parentElement.classList.add("h-full");
if (address.includes("Failed")) {
icon.classList.remove("ri-mail-line");
icon.classList.add("ri-mail-close-line");
@@ -146,10 +146,11 @@ class DOMInvite implements Invite {
}
this._right.classList.remove("empty");
let innerHTML = `
<table class="table inv-table">
<table class="table inv-table table-p-0">
<thead>
<tr>
<th>${window.lang.strings("name")}</th>
<th class="w-2"></th>
<th>${window.lang.strings("date")}</th>
</tr>
</thead>
@@ -159,6 +160,7 @@ class DOMInvite implements Invite {
innerHTML += `
<tr>
<td>${username}</td>
<td class="w-2"></td>
<td>${toDateString(new Date(uB[username] * 1000))}</td>
</tr>
`;
@@ -266,18 +268,21 @@ class DOMInvite implements Invite {
constructor(invite: Invite) {
// first create the invite structure, then use our setter methods to fill in the data.
this._container = document.createElement('div') as HTMLDivElement;
this._container.classList.add("inv");
this._container.classList.add("inv", "overflow-visible");
this._header = document.createElement('div') as HTMLDivElement;
this._container.appendChild(this._header);
this._header.classList.add("card", "dark:~d_neutral", "@low", "inv-header", "elem-pad", "no-pad", "flex-expand", "row", "mt-2", "overflow-y");
this._header.classList.add("card", "dark:~d_neutral", "@low", "inv-header", "flex", "flex-row", "justify-between", "mt-2", "overflow-visible", "gap-2");
this._codeArea = document.createElement('div') as HTMLDivElement;
this._header.appendChild(this._codeArea);
this._codeArea.classList.add("inv-codearea");
this._codeArea.classList.add("flex", "flex-row", "flex-wrap", "justify-between", "w-full", "items-baseline", "gap-2", "truncate");
this._codeArea.innerHTML = `
<a class="invite-link text-black dark:text-white font-mono bg-inherit mr-4" href=""></a>
<span class="button ~info @low" title="${window.lang.strings("copy")}"><i class="ri-file-copy-line"></i></span>
<div class="flex items-baseline gap-x-4 gap-y-2 truncate">
<a class="invite-link text-black dark:text-white font-mono bg-inherit truncate" href=""></a>
<span class="button ~info @low" title="${window.lang.strings("copy")}"><i class="ri-file-copy-line"></i></span>
</div>
<span class="inv-duration"></span>
`;
const copyButton = this._codeArea.querySelector("span.button") as HTMLSpanElement;
copyButton.onclick = () => {
@@ -297,16 +302,15 @@ class DOMInvite implements Invite {
this._infoArea = document.createElement('div') as HTMLDivElement;
this._header.appendChild(this._infoArea);
this._infoArea.classList.add("inv-infoarea");
this._infoArea.classList.add("inv-infoarea", "flex", "flex-row", "items-baseline", "gap-2");
this._infoArea.innerHTML = `
<div class="tooltip left">
<span class="inv-email-chip"><i></i></span>
<span class="content sm"></span>
<div class="tooltip below darker" tabindex="0">
<span class="inv-email-chip h-full"><i></i></span>
<span class="content sm p-1"></span>
</div>
<span class="inv-duration mr-4"></span>
<span class="button ~critical @low inv-delete">${window.lang.strings("delete")}</span>
<span class="button ~critical @low inv-delete h-full">${window.lang.strings("delete")}</span>
<label>
<i class="icon clickable ri-arrow-down-s-line not-rotated"></i>
<i class="icon px-2.5 py-2 ri-arrow-down-s-line not-rotated"></i>
<input class="inv-toggle-details unfocused" type="checkbox">
</label>
`;
@@ -315,25 +319,30 @@ class DOMInvite implements Invite {
const toggle = (this._infoArea.querySelector("input.inv-toggle-details") as HTMLInputElement);
toggle.onchange = () => { this.expanded = !this.expanded; };
this._header.onclick = (event: Event) => {
if (event.target == this._header) {
const toggleDetails = (event: Event) => {
if (event.target == this._header || event.target == this._codeArea || event.target == this._infoArea) {
this.expanded = !this.expanded;
}
};
this._header.onclick = toggleDetails;
this._details = document.createElement('div') as HTMLDivElement;
this._container.appendChild(this._details);
this._details.classList.add("card", "~neutral", "@low", "mt-2", "no-pad", "inv-details");
this._details.classList.add("card", "~neutral", "@low", "mt-2", "inv-details");
const detailsInner = document.createElement('div') as HTMLDivElement;
this._details.appendChild(detailsInner);
detailsInner.classList.add("inv-row", "flex-expand", "row", "elem-pad", "align-top");
detailsInner.classList.add("inv-row", "flex", "flex-row", "flex-wrap", "justify-between", "gap-4");
this._left = document.createElement('div') as HTMLDivElement;
this._left.classList.add("flex", "flex-row", "flex-wrap", "gap-4", "min-w-full", "sm:min-w-fit", "whitespace-nowrap");
detailsInner.appendChild(this._left);
this._left.classList.add("inv-profilearea");
const leftLeft = document.createElement("div") as HTMLDivElement;
this._left.appendChild(leftLeft);
leftLeft.classList.add("inv-profilearea", "min-w-full", "sm:min-w-fit");
let innerHTML = `
<p class="supra mb-2 top">${window.lang.strings("profile")}</p>
<div class="select ~neutral @low inv-profileselect inline-block mb-2">
<div class="select ~neutral @low inv-profileselect min-w-full inline-block mb-2">
<select>
<option value="noProfile" selected>${window.lang.strings("inviteNoProfile")}</option>
</select>
@@ -352,7 +361,7 @@ class DOMInvite implements Invite {
</label>
`;
}
this._left.innerHTML = innerHTML;
leftLeft.innerHTML = innerHTML;
(this._left.querySelector("select") as HTMLSelectElement).onchange = this.updateProfile;
if (window.notificationsEnabled) {
@@ -364,20 +373,21 @@ class DOMInvite implements Invite {
}
this._middle = document.createElement('div') as HTMLDivElement;
detailsInner.appendChild(this._middle);
this._middle.classList.add("block");
this._left.appendChild(this._middle);
this._middle.classList.add("flex", "flex-col", "justify-between");
this._middle.innerHTML = `
<p class="supra mb-4 top">${window.lang.strings("inviteDateCreated")} <strong class="inv-created"></strong></p>
<p class="supra mb-4">${window.lang.strings("inviteRemainingUses")} <strong class="inv-remaining"></strong></p>
<p class="supra mb-4"><span class="user-expiry"></span> <strong class="user-expiry-time"></strong></p>
<p class="mb-4 flex items-center"><span class="user-label-label supra mr-2"></span> <span class="user-label chip ~blue unfocused"></span></p>
<p class="supra top">${window.lang.strings("inviteDateCreated")} <strong class="inv-created"></strong></p>
<p class="supra">${window.lang.strings("inviteRemainingUses")} <strong class="inv-remaining"></strong></p>
<p class="supra"><span class="user-expiry"></span> <strong class="user-expiry-time"></strong></p>
<p class="flex items-center"><span class="user-label-label supra mr-2"></span> <span class="user-label chip ~blue unfocused"></span></p>
`;
this._right = document.createElement('div') as HTMLDivElement;
detailsInner.appendChild(this._right);
this._right.classList.add("card", "~neutral", "@low", "inv-created-users");
this._right.innerHTML = `<strong class="supra table-header">${window.lang.strings("inviteUsersCreated")}</strong>`;
this._right.classList.add("card", "~neutral", "@low", "inv-created-users", "min-w-full", "sm:min-w-fit", "whitespace-nowrap");
this._right.innerHTML = `<span class="supra table-header">${window.lang.strings("inviteUsersCreated")}</span>`;
this._userTable = document.createElement('div') as HTMLDivElement;
this._userTable.classList.add("text-sm", "mt-1", );
this._right.appendChild(this._userTable);
@@ -470,8 +480,8 @@ export class inviteList implements inviteList {
this._list.classList.add("empty");
this._list.innerHTML = `
<div class="inv inv-empty">
<div class="card dark:~d_neutral @low inv-header flex-expand mt-2">
<div class="inv-codearea">
<div class="card dark:~d_neutral @low inv-header mt-2">
<div class="justify-start">
<span class="text-black dark:text-white font-mono bg-inherit">${window.lang.strings("inviteNoInvites")}</span>
</div>
</div>

View File

@@ -83,7 +83,7 @@ export const loadLangSelector = (page: string) => {
let innerHTML = '';
for (let code in req.response) {
queryString.set("lang", code);
innerHTML += `<a href="?${queryString.toString()}" class="button w-100 al justify-start ~neutral mb-2 lang-link">${req.response[code]}</a>`;
innerHTML += `<a href="?${queryString.toString()}" class="button w-full text-left justify-start ~neutral mb-2 lang-link">${req.response[code]}</a>`;
queryString.delete("lang");
}
list.innerHTML = innerHTML;

View File

@@ -5,6 +5,7 @@ interface Profile {
libraries: string;
fromUser: string;
ombi: boolean;
jellyseerr: boolean;
referrals_enabled: boolean;
}
@@ -14,9 +15,11 @@ class profile implements Profile {
private _adminChip: HTMLSpanElement;
private _libraries: HTMLTableDataCellElement;
private _ombiButton: HTMLSpanElement;
private _ombi: boolean;
private _jellyseerrButton: HTMLSpanElement;
private _jellyseerr: boolean;
private _fromUser: HTMLTableDataCellElement;
private _defaultRadio: HTMLInputElement;
private _ombi: boolean;
private _referralsButton: HTMLSpanElement;
private _referralsEnabled: boolean;
@@ -51,6 +54,21 @@ class profile implements Profile {
this._ombiButton.classList.remove("~critical");
}
}
get jellyseerr(): boolean { return this._jellyseerr; }
set jellyseerr(v: boolean) {
if (!window.jellyseerrEnabled) return;
this._jellyseerr = v;
if (v) {
this._jellyseerrButton.textContent = window.lang.strings("delete");
this._jellyseerrButton.classList.add("~critical");
this._jellyseerrButton.classList.remove("~neutral");
} else {
this._jellyseerrButton.textContent = window.lang.strings("add");
this._jellyseerrButton.classList.add("~neutral");
this._jellyseerrButton.classList.remove("~critical");
}
}
get fromUser(): string { return this._fromUser.textContent; }
set fromUser(v: string) { this._fromUser.textContent = v; }
@@ -82,11 +100,14 @@ class profile implements Profile {
if (window.ombiEnabled) innerHTML += `
<td><span class="button @low profile-ombi"></span></td>
`;
if (window.jellyseerrEnabled) innerHTML += `
<td><span class="button @low profile-jellyseerr"></span></td>
`;
if (window.referralsEnabled) innerHTML += `
<td><span class="button @low profile-referrals"></span></td>
`;
innerHTML += `
<td class="profile-from ellipsis"></td>
<td class="profile-from truncate"></td>
<td class="profile-libraries"></td>
<td><span class="button ~critical @low">${window.lang.strings("delete")}</span></td>
`;
@@ -96,6 +117,8 @@ class profile implements Profile {
this._libraries = this._row.querySelector("td.profile-libraries") as HTMLTableDataCellElement;
if (window.ombiEnabled)
this._ombiButton = this._row.querySelector("span.profile-ombi") as HTMLSpanElement;
if (window.jellyseerrEnabled)
this._jellyseerrButton = this._row.querySelector("span.profile-jellyseerr") as HTMLSpanElement;
if (window.referralsEnabled)
this._referralsButton = this._row.querySelector("span.profile-referrals") as HTMLSpanElement;
this._fromUser = this._row.querySelector("td.profile-from") as HTMLTableDataCellElement;
@@ -112,10 +135,12 @@ class profile implements Profile {
this.fromUser = p.fromUser;
this.libraries = p.libraries;
this.ombi = p.ombi;
this.jellyseerr = p.jellyseerr;
this.referrals_enabled = p.referrals_enabled;
}
setOmbiFunc = (ombiFunc: (ombi: boolean) => void) => { this._ombiButton.onclick = () => ombiFunc(this._ombi); }
setJellyseerrFunc = (jellyseerrFunc: (jellyseerr: boolean) => void) => { this._jellyseerrButton.onclick = () => jellyseerrFunc(this._jellyseerr); }
setReferralFunc = (referralFunc: (enabled: boolean) => void) => { this._referralsButton.onclick = () => referralFunc(this._referralsEnabled); }
remove = () => { document.dispatchEvent(new CustomEvent("profiles-delete", { detail: this._name })); this._row.remove(); }
@@ -144,6 +169,7 @@ export class ProfileEditor {
private _profiles: { [name: string]: profile } = {};
private _default: string;
private _ombiProfiles: ombiProfiles;
private _jellyseerrProfiles: jellyseerrProfiles;
private _createForm = document.getElementById("form-add-profile") as HTMLFormElement;
private _profileName = document.getElementById("add-profile-name") as HTMLInputElement;
@@ -181,7 +207,7 @@ export class ProfileEditor {
this._profiles[name].update(name, resp.profiles[name]);
} else {
this._profiles[name] = new profile(name, resp.profiles[name]);
if (window.ombiEnabled)
if (window.ombiEnabled) {
this._profiles[name].setOmbiFunc((ombi: boolean) => {
if (ombi) {
this._ombiProfiles.delete(name, (req: XMLHttpRequest) => {
@@ -198,7 +224,26 @@ export class ProfileEditor {
this._ombiProfiles.load(name);
}
});
if (window.referralsEnabled)
}
if (window.jellyseerrEnabled) {
this._profiles[name].setJellyseerrFunc((jellyseerr: boolean) => {
if (jellyseerr) {
this._jellyseerrProfiles.delete(name, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status != 204) {
window.notifications.customError("errorDeleteJellyseerr", window.lang.notif("errorUnknown"));
return;
}
this._profiles[name].jellyseerr = false;
}
});
} else {
window.modals.profiles.close();
this._jellyseerrProfiles.load(name);
}
});
}
if (window.referralsEnabled) {
this._profiles[name].setReferralFunc((enabled: boolean) => {
if (enabled) {
this.disableReferrals(name);
@@ -206,6 +251,7 @@ export class ProfileEditor {
this.enableReferrals(name);
}
});
}
this._table.appendChild(this._profiles[name].asElement());
}
}
@@ -299,6 +345,8 @@ export class ProfileEditor {
if (window.ombiEnabled)
this._ombiProfiles = new ombiProfiles();
if (window.jellyseerrEnabled)
this._jellyseerrProfiles = new jellyseerrProfiles();
this._createButton.onclick = () => _get("/users", null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
@@ -366,7 +414,7 @@ export class ombiProfiles {
let resp = {} as ombiUser;
resp.id = this._select.value;
resp.name = this._users[resp.id];
_post("/profiles/ombi/" + this._currentProfile, resp, (req: XMLHttpRequest) => {
_post("/profiles/ombi/" + encodeURIComponent(encodeURIComponent(this._currentProfile)), resp, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
if (req.status == 200 || req.status == 204) {
@@ -379,7 +427,7 @@ export class ombiProfiles {
});
}
delete = (profile: string, post?: (req: XMLHttpRequest) => void) => _delete("/profiles/ombi/" + profile, null, post);
delete = (profile: string, post?: (req: XMLHttpRequest) => void) => _delete("/profiles/ombi/" + encodeURIComponent(encodeURIComponent(profile)), null, post);
load = (profile: string) => {
this._currentProfile = profile;
@@ -401,3 +449,54 @@ export class ombiProfiles {
});
}
}
export class jellyseerrProfiles {
private _form: HTMLFormElement;
private _select: HTMLSelectElement;
private _users: { [id: string]: string } = {};
private _currentProfile: string;
constructor() {
this._form = document.getElementById("form-jellyseerr-defaults") as HTMLFormElement;
this._form.onsubmit = this.send;
this._select = this._form.querySelector("select") as HTMLSelectElement;
}
send = () => {
const button = this._form.querySelector("span.submit") as HTMLSpanElement;
toggleLoader(button);
let encodedProfile = encodeURIComponent(encodeURIComponent(this._currentProfile));
_post("/profiles/jellyseerr/" + encodedProfile + "/" + this._select.value, null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
if (req.status == 200 || req.status == 204) {
window.notifications.customSuccess("jellyseerrDefaults", window.lang.notif("savedProfile"));
} else {
window.notifications.customError("jellyseerrDefaults", window.lang.notif("errorSavedProfile"));
}
window.modals.jellyseerrProfile.close();
}
});
}
delete = (profile: string, post?: (req: XMLHttpRequest) => void) => _delete("/profiles/jellyseerr/" + encodeURIComponent(encodeURIComponent(profile)), null, post);
load = (profile: string) => {
this._currentProfile = profile;
_get("/jellyseerr/users", null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 200 && "users" in req.response) {
const users = req.response["users"] as ombiUser[];
let innerHTML = "";
for (let user of users) {
this._users[user.id] = user.name;
innerHTML += `<option value="${user.id}">${user.name}</option>`;
}
this._select.innerHTML = innerHTML;
window.modals.jellyseerrProfile.show();
} else {
window.notifications.customError("jellyseerrLoadError", window.lang.notif("errorLoadUsers"))
}
}
});
}
}

View File

@@ -311,7 +311,7 @@ export class Search {
}
const container = document.createElement("span") as HTMLSpanElement;
container.classList.add("button", "button-xl", "~neutral", "@low", "mb-1", "mr-2");
container.classList.add("button", "button-xl", "~neutral", "@low", "mb-1", "mr-2", "align-bottom");
container.innerHTML = `
<div class="flex flex-col mr-2">
<span>${query.name}</span>

View File

@@ -1083,6 +1083,7 @@ export interface templateEmail {
interface emailListEl {
name: string;
enabled: boolean;
description: string;
}
class MessageEditor {
@@ -1092,6 +1093,7 @@ class MessageEditor {
private _templ: templateEmail;
private _form = document.getElementById("form-editor") as HTMLFormElement;
private _header = document.getElementById("header-editor") as HTMLSpanElement;
private _aside = document.getElementById("aside-editor") as HTMLElement;
private _variables = document.getElementById("editor-variables") as HTMLDivElement;
private _variablesLabel = document.getElementById("label-editor-variables") as HTMLElement;
private _conditionals = document.getElementById("editor-conditionals") as HTMLDivElement;
@@ -1113,6 +1115,12 @@ class MessageEditor {
if (this._names[id] !== undefined) {
this._header.textContent = this._names[id].name;
}
this._aside.classList.add("unfocused");
if (this._names[id].description != "") {
this._aside.textContent = this._names[id].description;
this._aside.classList.remove("unfocused");
}
this._templ = req.response as templateEmail;
this._textArea.value = this._templ.content;
if (this._templ.html == "") {
@@ -1212,11 +1220,22 @@ class MessageEditor {
if (this._names[id].enabled) {
resetButton = `<i class="icon ri-restart-line" title="${window.lang.get("strings", "reset")}"></i>`;
}
tr.innerHTML = `
<td>${this._names[id].name}</td>
let innerHTML = `
<td>
${this._names[id].name}
`;
if (this._names[id].description != "") innerHTML += `
<div class="tooltip right">
<i class="icon ri-information-line"></i>
<span class="content sm">${this._names[id].description}</span>
</div>
`;
innerHTML += `
</td>
<td class="table-inline justify-center"><span class="customize-reset">${resetButton}</span></td>
<td><span class="button ~info @low" title="${window.lang.get("strings", "edit")}"><i class="icon ri-edit-line"></i></span></td>
`;
tr.innerHTML = innerHTML;
(tr.querySelector("span.button") as HTMLSpanElement).onclick = () => {
window.modals.customizeEmails.close()
this.loadEditor(id);

View File

@@ -22,13 +22,18 @@ export class Tabs implements Tabs {
switch = (tabID: string, noRun: boolean = false, keepURL: boolean = false) => {
this._current = tabID;
let baseOffset = -1;
for (let t of this.tabs) {
if (baseOffset == -1) baseOffset = t.buttonEl.offsetLeft;
if (t.tabID == tabID) {
t.buttonEl.classList.add("active", "~urge");
if (t.preFunc && !noRun) { t.preFunc(); }
t.tabEl.classList.remove("unfocused");
if (t.postFunc && !noRun) { t.postFunc(); }
document.dispatchEvent(new CustomEvent("tab-change", { detail: keepURL ? "" : tabID }));
// t.buttonEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' })
t.buttonEl.parentElement.scrollTo(t.buttonEl.offsetLeft-baseOffset, 0);
} else {
t.buttonEl.classList.remove("active");
t.buttonEl.classList.remove("~urge");

View File

@@ -2,6 +2,7 @@ import { Modal } from "./modules/modal.js";
import { Validator, ValidatorConf } from "./modules/validator.js";
import { _post, addLoader, removeLoader } from "./modules/common.js";
import { loadLangSelector } from "./modules/lang.js";
import { Captcha, GreCAPTCHA } from "./modules/captcha.js";
interface formWindow extends Window {
invalidPassword: string;
@@ -28,6 +29,10 @@ interface formWindow extends Window {
userExpiryHours: number;
userExpiryMinutes: number;
userExpiryMessage: string;
captcha: boolean;
reCAPTCHA: boolean;
reCAPTCHASiteKey: string;
pwrPIN: string;
}
loadLangSelector("pwr");
@@ -42,11 +47,26 @@ const rePasswordField = document.getElementById("create-reenter-password") as HT
window.successModal = new Modal(document.getElementById("modal-success"), true);
function _baseValidator(oncomplete: (valid: boolean) => void, captchaValid: boolean): void {
if (window.captcha && !window.reCAPTCHA && !captchaValid) {
oncomplete(false);
return;
}
oncomplete(true);
}
let captcha = new Captcha(window.pwrPIN, window.captcha, window.reCAPTCHA, true);
declare var grecaptcha: GreCAPTCHA;
let baseValidator = captcha.baseValidatorWrapper(_baseValidator);
let validatorConf: ValidatorConf = {
passwordField: passwordField,
rePasswordField: rePasswordField,
submitInput: submitInput,
submitButton: submitSpan
submitButton: submitSpan,
validatorFunc: baseValidator
};
var validator = new Validator(validatorConf);
@@ -55,6 +75,13 @@ var requirements = validator.requirements;
interface sendDTO {
pin: string;
password: string;
captcha_text?: string;
}
if (window.captcha && !window.reCAPTCHA) {
captcha.generate();
(document.getElementById("captcha-regen") as HTMLSpanElement).onclick = captcha.generate;
captcha.input.onkeyup = validator.validate;
}
form.onsubmit = (event: Event) => {
@@ -65,12 +92,31 @@ form.onsubmit = (event: Event) => {
pin: params.get("pin"),
password: passwordField.value
};
if (window.captcha) {
if (window.reCAPTCHA) {
send.captcha_text = grecaptcha.getResponse();
} else {
send.captcha_text = captcha.input.value;
}
}
_post("/reset", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
removeLoader(submitSpan);
if (req.status == 400) {
for (let type in req.response) {
if (requirements[type]) { requirements[type].valid = req.response[type] as boolean; }
if (req.response["error"] as string) {
const old = submitSpan.textContent;
submitSpan.textContent = window.messages[req.response["error"]];
submitSpan.classList.add("~critical");
submitSpan.classList.remove("~urge");
setTimeout(() => {
submitSpan.classList.add("~urge");
submitSpan.classList.remove("~critical");
submitSpan.textContent = old;
}, 2000);
} else {
for (let type in req.response) {
if (requirements[type]) { requirements[type].valid = req.response[type] as boolean; }
}
}
return;
} else if (req.status != 200) {

View File

@@ -24,6 +24,7 @@ declare interface Window {
discordEnabled: boolean;
matrixEnabled: boolean;
ombiEnabled: boolean;
jellyseerrEnabled: boolean;
usernameEnabled: boolean;
linkResetEnabled: boolean;
token: string;
@@ -101,6 +102,7 @@ declare interface Modals {
settingsRestart: Modal;
settingsRefresh: Modal;
ombiProfile?: Modal;
jellyseerrProfile?: Modal;
profiles: Modal;
addProfile: Modal;
announce: Modal;

View File

@@ -144,9 +144,9 @@ class ContactMethods {
append = (name: string, details: MyDetailsContactMethod, icon: string, addEditFunc?: (add: boolean) => void, required?: boolean) => {
const row = document.createElement("div");
row.classList.add("flex", "flex-expand", "my-2", "flex-nowrap");
row.classList.add("flex", "flex-row", "justify-between", "my-2", "flex-nowrap");
let innerHTML = `
<div class="flex items-baseline flex-nowrap ellipsis">
<div class="flex items-baseline flex-nowrap truncate">
<span class="shield ~urge" alt="${name}">
<span class="icon">
${icon}
@@ -266,13 +266,20 @@ class ReferralCard {
get code(): string { return this._code; }
set code(c: string) {
this._code = c;
let url = window.location.href;
for (let split of ["#", "?", "account", "my"]) {
url = url.split(split)[0];
let u = new URL(window.location.href);
let path = u.pathname;
for (let split of ["account", "my"]) {
path = path.split(split)[0];
}
if (url.slice(-1) != "/") { url += "/"; }
url = url + "invite/" + this._code;
this._url = url;
if (path.slice(-1) != "/") { path += "/"; }
path = path + "invite/" + this._code;
u.pathname = path;
u.hash = "";
u.search = "";
this._url = u.toString();
}
get remaining_uses(): number { return this._remainingUses; }
@@ -637,10 +644,10 @@ document.addEventListener("details-reload", () => {
if (typeof(messageCard) != "undefined" && messageCard != null) {
messageCard.innerHTML = messageCard.innerHTML.replace(new RegExp("{username}", "g"), details.username);
setBestRowSpan(messageCard, false);
// setBestRowSpan(messageCard, false);
// contactCard.querySelector(".content").classList.add("h-100");
} else if (!statusCard.classList.contains("unfocused")) {
setBestRowSpan(passwordCard, true);
// setBestRowSpan(passwordCard, true);
}
if (window.referralsEnabled) {
@@ -649,15 +656,69 @@ document.addEventListener("details-reload", () => {
if (req.readyState != 4 || req.status != 200) return;
const referral: MyReferral = req.response as MyReferral;
referralCard.update(referral);
setCardOrder(messageCard);
});
} else {
referralCard.hide();
setCardOrder(messageCard);
}
} else {
setCardOrder(messageCard);
}
}
});
});
const setCardOrder = (messageCard: HTMLElement) => {
const cards = document.getElementById("user-cardlist");
const children = Array.from(cards.children);
const idxs = [...Array(cards.childElementCount).keys()]
// The message card is the first element and should always be so, so remove it from the list.
const hasMessageCard = !(typeof(messageCard) == "undefined" || messageCard == null);
if (hasMessageCard) idxs.shift();
const perms = generatePermutations(idxs);
let minHeight = 999999;
let minHeightPerm: [number[], number[]];
for (let perm of perms) {
let leftHeight = 0;
for (let idx of perm[0]) {
leftHeight += (cards.children[idx] as HTMLElement).offsetHeight;
}
if (hasMessageCard) leftHeight += (cards.children[0] as HTMLElement).offsetHeight;
let rightHeight = 0;
for (let idx of perm[1]) {
rightHeight += (cards.children[idx] as HTMLElement).offsetHeight;
}
let height = Math.max(leftHeight, rightHeight);
// console.log("got height", leftHeight, rightHeight, height, "for", perm);
if (height < minHeight) {
minHeight = height;
minHeightPerm = perm;
}
}
const gapDiv = () => {
const g = document.createElement("div");
g.classList.add("my-4");
return g;
};
let addValue = hasMessageCard ? 1 : 0;
// if (hasMessageCard) cards.appendChild(children[0]);
if (hasMessageCard) cards.appendChild(gapDiv());
for (let side of minHeightPerm) {
for (let i = 0; i < side.length; i++) {
// (cards.children[side[i]] as HTMLElement).style.order = (i+addValue).toString();
children[side[i]].remove();
cards.appendChild(children[side[i]]);
cards.appendChild(gapDiv());
}
// addValue += side.length;
}
console.log("Shortest order:", minHeightPerm);
};
const login = new Login(window.modals.login as Modal, "/my/", "opaque");
login.onLogin = () => {
console.log("Logged in.");
@@ -699,6 +760,24 @@ const computeRealHeight = (el: HTMLElement): number => {
return total;
}
const generatePermutations = (xs: number[]): [number[], number[]][] => {
const l = xs.length;
let out: [number[], number[]][] = [];
for (let i = 0; i < (l << 1); i++) {
let incl = [];
let excl = [];
for (let j = 0; j < l; j++) {
if (i & (1 << j)) {
incl.push(xs[j]);
} else {
excl.push(xs[j]);
}
}
out.push([incl, excl]);
}
return out;
}
login.bindLogout(document.getElementById("logout-button"));
login.login("", "");

View File

@@ -45,13 +45,13 @@ func (app *appContext) getUserTokenLogin(gc *gin.Context) {
respond(500, "Contact Admin", gc)
return
}
app.info.Println("UserToken requested (login attempt)")
username, password, ok := app.decodeValidateLoginHeader(gc)
app.logIpInfo(gc, true, "UserToken requested (login attempt)")
username, password, ok := app.decodeValidateLoginHeader(gc, true)
if !ok {
return
}
user, ok := app.validateJellyfinCredentials(username, password, gc)
user, ok := app.validateJellyfinCredentials(username, password, gc, true)
if !ok {
return
}
@@ -86,7 +86,7 @@ func (app *appContext) getUserTokenRefresh(gc *gin.Context) {
return
}
app.info.Println("UserToken request (refresh token)")
app.logIpInfo(gc, true, "UserToken request (refresh token)")
claims, ok := app.decodeValidateRefreshCookie(gc, "user-refresh")
if !ok {
return

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