Compare commits

..

222 Commits

Author SHA1 Message Date
Harvey Tindall
550cb36bd1 ci: fix goreleaser, rename arm_v6, upload to dockerhub temporarily 2025-12-01 15:14:06 +00:00
Harvey Tindall
03d3cee18b ci: attempt to combine git-docker with git-binary
using a second Dockerfile, and the prevous Makefile changes to share the
precompile step. git-binary.yaml temporarily set for ci-streamline
branch and is in a "dry run" form.
2025-12-01 14:52:10 +00:00
Harvey Tindall
331e7c13fa Makefile: share $(DATA) on internal/external, keep record of build flags
This means you could make precompile, then make INTERNAL=on then make
INTERNAL=off and each time it would re-generate the go files only since
the flags have changed.
2025-12-01 14:18:10 +00:00
Harvey Tindall
429538688c ci: pull tags for docker build
needed for CSSVERSION.
2025-12-01 13:24:28 +00:00
Harvey Tindall
70ee98f9f0 fix printf types
was causing builds to fail on ci.
2025-12-01 12:55:24 +00:00
Harvey Tindall
320e9cd9d0 mediabrowser: bump
imrpvoed UserNotFound error classification, no longer vulnerable to
random 404s from proxies or whatever (for Jellyfin only, not emby)
2025-12-01 12:43:40 +00:00
Harvey Tindall
b525b03ef8 Merge branch 'main' of github.com:hrfee/jfa-go 2025-11-30 21:42:12 +00:00
Harvey Tindall
641669873d setup: mark external URL required
this should really be given but things'll probably work without it.
2025-11-30 21:40:59 +00:00
Harvey Tindall
fb1b673dee settings: add "tasks" button (advanced)
added a GET /tasks route to list tasks with a description (untranslated,
but this is mostly a dev feature anyway). Loaded in a modal by enabling
advanced settings and pressing the Tasks button at the top (where logs,
backups, restart are). Also added some icons in settings, and removed
some redundant "flex flex-row"s on buttons and reduced the spacing in
those with icons to gap-1.
2025-11-29 15:43:06 +00:00
Harvey Tindall
598a389e3d jellyseerr: fix extremely long import, run only once
cache was being invalidated for every user, and on my 5000 user test
instance, this sweated jellyseerr and my computer (audibly). Also, since
this only needs to realistically run once, a flag is set in the database
to indicate it's been done, and unset once the feature is disabled.
It'll only run on boot if the flag is unset, or if triggered by the
/tasks route. Will likely add manual trigger buttons on the web as well.
2025-11-29 14:13:34 +00:00
Harvey Tindall
1a0e32504f accounts: hide js/ombi checks when not enabled correctly on modify
settings
2025-11-28 20:52:37 +00:00
Harvey Tindall
cbff3085fa profiles: "add jellyseerr" checkbox on profile creation
makes things clearer to new users. Fixes #438.
2025-11-28 20:37:45 +00:00
Harvey Tindall
0ecacc6064 tasks: add /tasks/jellyseerr, document
now live in tasks.go and have actual API documentation.
/tasks/jellyseerr triggers account import.
2025-11-28 17:26:27 +00:00
Harvey Tindall
f36a32773a jellyseerr: fix background daemon auto import of telegram
ChatID fix was done in the TPS implementation but not in the daemon.
2025-11-28 17:10:38 +00:00
Harvey Tindall
58a3fe1f72 jellyseerr: don't attempt to apply profile if disabled
fixes #441
2025-11-28 16:44:03 +00:00
Harvey Tindall
4d58fc5f88 telegram: fix linking on sign-up
fixes #440.
2025-11-28 16:34:39 +00:00
Harvey Tindall
7d947015d3 pwr: allow jellyfin pwrs when not using email
switched if !emailEnabled { return } to if !messagesEnabled { return }.
Fixes #439.
2025-11-28 16:21:00 +00:00
Harvey Tindall
77d2ad3b6b profiles: add ability to directly edit profile JSON
allows for customizing small things, like changing admin status.
2025-11-28 15:13:46 +00:00
Harvey Tindall
f83695190d build: use most recent tag as cssversion
the value is fixed in html files though, hopefully this will the light
the fire under me to make sure I keep changing the version with updates.
2025-11-28 12:36:49 +00:00
Harvey Tindall
815721adb2 profiles: add routes for viewing/modifying
most of Profile struct is now in ProfileDTO (which is embedded in the
former), admin can pull it from /profiles/raw/{name} GET, and replace it
with PUT. Will make a ui for this.
2025-11-28 12:06:17 +00:00
Harvey Tindall
836974e1b2 scripts: stop using gopkg.in/yaml
used a different library in scripts/yaml, forgot to in scripts/ini and
main.go
2025-11-27 20:38:16 +00:00
Harvey Tindall
96983d70c8 ci: fix stable, add gh token for publishing
haven't done a stable with woodpecker, I hope it works!
2025-11-27 20:31:56 +00:00
Harvey Tindall
9400a5bc66 update LICENSE date 2025-11-27 20:19:59 +00:00
Harvey Tindall
033319af29 css: bump CSSVERSION
gonna set it to the version number of the software from now on, i think.
2025-11-27 20:13:31 +00:00
Harvey Tindall
787d0e7b4c config: rename some sections
removing redundant words from section title for those now in groups.
2025-11-27 19:50:32 +00:00
Aldo
d90617c027 Translated using Weblate (Spanish)
Currently translated at 100.0% (13 of 13 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/es/
2025-11-27 20:49:58 +01:00
lkbro
98303a286a translation from Weblate (Italian)
Currently translated at 98.5% (67 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/it/
2025-11-27 20:49:58 +01:00
lkbro
aa791f1948 translation from Weblate (Italian)
Currently translated at 1.4% (4 of 282 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/it/
2025-11-27 20:49:58 +01:00
lkbro
e46466180d Translated using Weblate (Italian)
Currently translated at 90.7% (49 of 54 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/it/
2025-11-27 20:49:58 +01:00
Adnan
3b956ca82e Translated using Weblate (Turkish)
Currently translated at 15.4% (21 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/tr/
2025-11-27 20:49:58 +01:00
Adnan
a0e69009f0 translation from Weblate (Turkish)
Currently translated at 4.2% (12 of 282 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/tr/
2025-11-27 20:49:58 +01:00
Adnan
59400dbc61 translation from Weblate (Turkish)
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/tr/
2025-11-27 20:49:58 +01:00
Adnan
0b06dd29c4 Translated using Weblate (Turkish)
Currently translated at 100.0% (54 of 54 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/tr/
2025-11-27 20:49:58 +01:00
Adnan
0152acde9a Translated using Weblate (Turkish)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/tr/
2025-11-27 20:49:58 +01:00
Adnan
273e5caa6b Added translation using Weblate (Turkish) 2025-11-27 20:49:58 +01:00
Adnan
8d5aa0d0ae add translation from Weblate (Turkish) 2025-11-27 20:49:58 +01:00
Adnan
e75c71e0a2 Added translation using Weblate (Turkish) 2025-11-27 20:49:58 +01:00
Adnan
f423b221e6 Added translation using Weblate (Turkish) 2025-11-27 20:49:58 +01:00
Adnan
702e42b8b3 translation from Weblate (Turkish)
Currently translated at 69.1% (47 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/tr/
2025-11-27 20:49:58 +01:00
Dr. D
bbc99bbeaa translation from Weblate (German)
Currently translated at 80.1% (226 of 282 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/de/
2025-11-27 20:49:58 +01:00
Adnan
e2543bda67 add translation from Weblate (Turkish) 2025-11-27 20:49:58 +01:00
Oszi2
442ce1fac1 Translated using Weblate (Hungarian)
Currently translated at 100.0% (56 of 56 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/hu/
2025-11-27 20:49:58 +01:00
Oszi2
03367b2cac Translated using Weblate (Hungarian)
Currently translated at 100.0% (13 of 13 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/hu/
2025-11-27 20:49:58 +01:00
Oszi2
a2d212e396 Translated using Weblate (Hungarian)
Currently translated at 100.0% (54 of 54 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/hu/
2025-11-27 20:49:58 +01:00
Oszi2
cecf9ba0d4 Translated using Weblate (Hungarian)
Currently translated at 100.0% (136 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/hu/
2025-11-27 20:49:58 +01:00
Oszi2
5aebc323d5 translation from Weblate (Hungarian)
Currently translated at 50.7% (143 of 282 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/hu/
2025-11-27 20:49:58 +01:00
Oszi2
2543cd08c2 translation from Weblate (Hungarian)
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/hu/
2025-11-27 20:49:58 +01:00
Oszi2
9c353f2a91 Translated using Weblate (English (United Kingdom))
Currently translated at 78.6% (107 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/en_GB/
2025-11-27 20:49:58 +01:00
Oszi2
722e7e66c2 Translated using Weblate (English)
Currently translated at 100.0% (136 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/en/
2025-11-27 20:49:57 +01:00
Harvey Tindall
1296992752 userpage: fix login saving, url rewriting on subpath
Fixes #437.
2025-11-27 18:10:28 +00:00
Harvey Tindall
ab5a82858e userpage: fix back to admin button 2025-11-27 17:58:52 +00:00
Harvey Tindall
073772ad60 form: functional "collect on sign-up" setting
was added without functionality by accident in a7aa3fd. This commit adds
the functionality in. Probably some other fixes too.
2025-11-27 17:53:01 +00:00
Harvey Tindall
d7e4431bd8 settings: more dependency fixes 2025-11-27 17:52:49 +00:00
Harvey Tindall
96ec12f2bd settings: hide groups if all children are hidden
only really affects the "Email" group when messages|enabled is false.
2025-11-27 16:36:26 +00:00
Harvey Tindall
85eea23d98 accounts: share telegram linking modal with other pages
the one on the admin page was a little messed up anyway. Not relevant to
the other linking modals, as the process is different (simpler) on the
admin page.
2025-11-27 10:50:18 +00:00
Harvey Tindall
51961d16ba jellyseerr: format telegram ChatID as string
didn't accept an integer.
2025-11-26 21:16:07 +00:00
Harvey Tindall
d6d73e81d6 settings: fix (restart)required detection on save 2025-11-26 21:12:43 +00:00
Harvey Tindall
27a80734f9 ThirdPartyServices: SetContactMethods for setting, enable/disable
replace AddContactMethods with a more generalised SetContactMethods,
which can set (or leave alone) contact method addresses/names/ids and
set (or leave alone) contact preferences. A little awkward to use, but
works everywhere, and now a bunch of Jellyseerr integration features are
present for Ombi (which they should've been anyway).
2025-11-26 20:57:11 +00:00
Harvey Tindall
1d4ea7d0a0 jellyseerr: fix unlinking discord/telegram
was accidentally using gc.getString("jfId") instead of req.ID.
2025-11-26 18:13:49 +00:00
Harvey Tindall
6d2e517e82 api-users: trigger tps contact info link on admin newUser
only adds email, since that's all that's available.
2025-11-26 18:03:45 +00:00
Harvey Tindall
982d3ec4c9 jellyseerr: fix error message parsing
was overriding the err value and so the route that parsed the error
message never got hit.
2025-11-26 18:02:56 +00:00
Harvey Tindall
5e653c51f3 accounts: add "extend from previous expiry"
If the expiry time of an expired user is still in the activity log,
extending and re-enabling a user with this option checked will extend
the expiry from this time, rather than the current time. For #379, i
think this is basically what they wanted.
2025-11-26 15:40:02 +00:00
Harvey Tindall
875387166e settings: fix weirdness on mobile
the check and button's onclick were both firing occasionally, so added
check to button.onclick that the target isn't the check or it's icon, as
I did for the invite details toggle.
2025-11-25 21:04:12 +00:00
Harvey Tindall
909614c3e7 invites: add details expand transition
why not.
2025-11-25 20:49:24 +00:00
Harvey Tindall
3178ca7572 settings: remove badge note, add tooltips to them
change required and restart required badges to icons with tooltips, and
removed the note at the top of settings. As a result, the sidebar is
much thinner.
2025-11-25 17:13:43 +00:00
Harvey Tindall
442bdd2220 settings: leave groups opened/closed on advanced settings toggle 2025-11-25 15:04:24 +00:00
Harvey Tindall
a680db92a7 settings: fix group indent
obviously groups don't need an increased margin value, they already have
a margin from the parent! Also increased it to ml-6.
2025-11-25 14:56:53 +00:00
Harvey Tindall
08c350d50b settings: fix search with groups
works now, as in searching for a group's name works, and seeing matches
within groups works.
2025-11-25 14:43:56 +00:00
Harvey Tindall
fe20187b0c scripts: add "yaml" script
takes over the "Order" validation of scripts/ini, and also re-orders the
"Sections" section according to "Order". Used instead of copying
config-base.yaml into the executable's data.
2025-11-25 14:43:33 +00:00
Harvey Tindall
65a25a7e66 config: update wiki links
some were outdated.
2025-11-25 14:42:18 +00:00
Harvey Tindall
607d8e9566 settings: show updates at top if one available 2025-11-24 18:43:08 +00:00
Harvey Tindall
8f3b860cc7 settings/config: add root order and use on web, fix nesting and
animation

added an optional root "Order" field to the config. scripts/ini will
warn if you've used this and forgot to include any sections.

added more/most sections to a group now.

groups have their maxHeight set to 9999px once animation finishes, and
have it quickly set back to ~scrollHeight before they're animated
closed.
2025-11-24 18:31:35 +00:00
Harvey Tindall
a3dc8b7e07 settings: render groups
a little off at the moment but works, groups show as accordions and can
be nested. Maybe add indentation, and probably show them first in the
list. Also make sure search works with them.
2025-11-24 15:19:20 +00:00
Harvey Tindall
6bfb345169 config add "Group" notion
a group contains an ordered list of settings sections and/or other
groups. Intended to be rendered as an accordion tree in the app. Has no
effect on INI structure.
2025-11-24 15:16:47 +00:00
Harvey Tindall
704157be00 bump api version 2025-11-24 11:49:26 +00:00
Harvey Tindall
b1c578ccf4 mediabrowser: bump
also updated everything else.
2025-11-23 20:10:34 +00:00
Harvey Tindall
7c9f917114 swag: add new statistics tag, add filtered user count route 2025-11-23 16:55:23 +00:00
Harvey Tindall
b5f28da452 updater: demote "tag empty" to debug log
the stable tag is usually empty because i rarely update it so it'd be
nice if this didn't show up so much for normal users. For #313, #329 and
more, probably.
2025-11-21 17:22:49 +00:00
Harvey Tindall
60ccc51232 settings: deprecate most custom file path settings 2025-10-21 17:32:05 +01:00
Harvey Tindall
1780aa567f Merge branch 'main' of github.com:hrfee/jfa-go 2025-10-21 17:15:52 +01:00
Harvey Tindall
6a8b21c5f2 mention 10.11.0 compatibility
seems to work, someone opened an issue but closed it right after also.
Release notes don't say anything alarming either.
2025-10-21 17:15:00 +01:00
Harvey Tindall
fc4cd4cd27 Merge pull request #432 from mxkyb/fix-template-key
Adjust Template Key
2025-09-25 19:56:34 +01:00
Max Kieltyka
465ed9f84f adjust template key 2025-09-25 20:28:02 +02:00
Harvey Tindall
d88194b9bd accounts: invalidate user cache in more/all places
using app.userSummary as a source of relevant storage places and things
to look for.
2025-09-01 21:28:56 +01:00
Harvey Tindall
6ebc7d18bf accounts: fix bool queries on (some) string fields
wasn't implemented for things like email on the server side.
also changed text mail variant's footers to all use {{ .footer }} like I
should have before.
2025-09-01 20:44:19 +01:00
Harvey Tindall
0fe574fbd9 discord: clarify "Invite channel" setting
mention it's the name you put there, not the ID.
2025-09-01 19:18:48 +01:00
Harvey Tindall
c7ba9944f0 images: change banner font
use plus jakarta sans, the font used on the newer Jellyfin logo for the
banner and social images.
2025-09-01 18:30:16 +01:00
Harvey Tindall
8781e48601 email: change font, template common parts
Using the newer Jellyfin logo font for the header and hanken grotesk for the body.
Tried to redo emails with maizzle because using tailwind sounded nice, but getting it
to look like a17t would be more trouble than it's worth, since you can't
use CSS vars in emails and a17t uses them heavily. Instead, cleaned up
the mj-header a little and stored it in a separate file, and also the
header & footer, and changed the template vars with {{ .header }}  and
{{ .footer }} for all emails. Values are determined by
CustomContentInfo.Header/FooterText funcs. nil values are replaced at
program start by _runtimeValidator.

also, i beg of you don't try to do light/dark mode with mjml, you'll
want to die.
2025-09-01 15:31:28 +01:00
Harvey Tindall
eb941794a8 ci: run tests
added precompile and test "steps".
2025-08-31 18:10:51 +01:00
Harvey Tindall
0783749e6e matrix: add log for matrix crypto store init
deleting the crypto DB resulted in InitMatrixCrypto taking ages, added a
Initing/Inited log pair around the function so it's obvious this is the
culprit if any one else faces the same thing.
2025-08-31 17:59:49 +01:00
Harvey Tindall
87c0f54a8d email_test: allow running with INTERNAL=on 2025-08-31 17:46:37 +01:00
Harvey Tindall
febbe27a0d emails: fix conditionals not being cleared on editor load 2025-08-31 17:31:48 +01:00
Harvey Tindall
e67f1bf1a9 emails: fix and confirm function of all emails
both custom and standard emails tested, quite a few fixes made,
including to an old bug with admin notifs.
2025-08-31 17:12:50 +01:00
Harvey Tindall
60dbfa2d1e messages: custom content described in customcontent.go, message tests
customcontent.go constains a structure with all the custom content,
methods for getting display names, subjects, etc., and a list of
variables, conditionals, and placeholder values. Tests for constructX
methods included in email_test.go, and all jfa-go tests can be run with
make INTERNAL=off test.
2025-08-30 14:21:26 +01:00
Harvey Tindall
0b43ad4ed5 template: passed var and conditional names don't include braces
pass []string{"username"}, rather than []string{"{username}"}. Tests
have been updated.
2025-08-23 14:59:04 +01:00
Harvey Tindall
94efe9f746 expiry: add "remind N days before"
new setting to send an email/message N days before a user is due to
expire. Multiple can be set.
2025-08-04 20:44:29 +01:00
Harvey Tindall
5fe0e0ab9f timer: add scheduler/timer with day precision
timer.go has a timer struct for scheduling things to happen once every n
days before or after a given time. Pass a string list of day deltas to
parse, a unit to parse these as (only 24/-24 hours really make sense),
then call Check() on the returned struct with your "since" time, and the
time a timer was last fired. If one goes off, store the time so you can
pass it in subsequent calls. To be used in the user daemon for "remind
every N days" functionality. Was initially gonna allow more precision
than days, but ran into problems, most likely from me overcomplicating
it and not wanting to store too much data. some tests also in
timer_test.go.
2025-08-04 16:08:15 +01:00
Harvey Tindall
db1e812190 discord: retry auth/command register, do latter in bulk
re-use the auth retry options from the config for initial d.bot.Open and
for registering commands. The latetr is now done with the BulkOverwrite
    method, since it seems to work now. For #427.
2025-08-03 20:05:22 +01:00
Harvey Tindall
aab8d6ed77 template: report errors/warnings
error field is now logged at all points of use.
2025-08-03 18:39:47 +01:00
Harvey Tindall
5d49a56d94 template: add tests, fix up easy holes
should cope with double-braced blocks now (treating them the same as
single-braced. templateEmail now returns an error, which should not be
seen as catastrophic, but reports likely mistakes.
2025-08-03 17:36:33 +01:00
Harvey Tindall
492d5715fe ts: fix setTimeout return type for new tsc 2025-07-20 13:53:48 +01:00
Harvey Tindall
0595224daa meta: update esbuild 2025-07-20 13:50:53 +01:00
Harvey Tindall
58098a45af meta: update deps
npm and easyproxy's go deps
2025-07-20 13:48:37 +01:00
Harvey Tindall
a0bafadc39 site: modernise makefile
it's a proper one now.
2025-07-20 13:20:42 +01:00
Harvey Tindall
2190f482d1 user-d: dont bother attempting users w no contact 2025-07-20 12:41:27 +01:00
Harvey Tindall
024b692b8c Merge branch 'main' of github.com:hrfee/jfa-go 2025-07-20 12:38:55 +01:00
Harvey Tindall
6a5e97b788 user-d: patch holes in expiry mechanism
I hope so, at least. Saw a few areas where I wasn't sure something
couldn't break so I did some maybe redundant checks. For #419.
2025-07-20 12:37:26 +01:00
Anonymous
b8a1e416d4 Translated using Weblate (English (United Kingdom))
Currently translated at 78.6% (107 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/en_GB/
2025-07-19 21:20:20 +02:00
Anonymous
3ea8f272f7 Translated using Weblate (Swedish)
Currently translated at 66.9% (91 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/sv/
2025-07-19 21:20:20 +02:00
Anonymous
7c3f84ba9c Translated using Weblate (Greek)
Currently translated at 63.9% (87 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/el/
2025-07-19 21:20:20 +02:00
RebootGod
0094ce7d57 translation from Weblate (Indonesian)
Currently translated at 97.0% (66 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/id/
2025-07-19 21:19:47 +02:00
RebootGod
c2b08a326d Translated using Weblate (Indonesian)
Currently translated at 66.4% (83 of 125 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/id/
2025-07-19 21:19:47 +02:00
RebootGod
ba183660a9 translation from Weblate (Indonesian)
Currently translated at 39.7% (109 of 274 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/id/
2025-07-19 21:19:47 +02:00
RebootGod
423d8f5063 Translated using Weblate (Indonesian)
Currently translated at 98.0% (50 of 51 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/id/
2025-07-19 21:19:47 +02:00
RebootGod
3c38a0edbf Translated using Weblate (Indonesian)
Currently translated at 96.4% (54 of 56 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/id/
2025-07-19 21:19:47 +02:00
danieledu007
4df313fa43 translation from Weblate (Spanish)
Currently translated at 67.8% (186 of 274 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/es/
2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
35f1c06d34 translation from Weblate (Thai)
Currently translated at 27.3% (75 of 274 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/th/
2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
12e745691e Translated using Weblate (Thai)
Currently translated at 100.0% (125 of 125 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/th/
2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
25ed44a5f3 Translated using Weblate (Thai)
Currently translated at 100.0% (56 of 56 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/th/
2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
4ea695d81e Translated using Weblate (Thai)
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/th/
2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
dd91a5cb86 Translated using Weblate (Thai)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/th/
2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
9998aff69a Translated using Weblate (Thai)
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/th/
2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
5ebcb9d51c translation from Weblate (Thai)
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/th/
2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
c60f93dfe8 add translation from Weblate (Thai) 2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
95e77b2e21 Added translation using Weblate (Thai) 2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
5a335a1465 Added translation using Weblate (Thai) 2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
4e7256fb6c Added translation using Weblate (Thai) 2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
dd8119d952 Added translation using Weblate (Thai) 2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
4f8fd7fb5b Added translation using Weblate (Thai) 2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
bd573f34c0 add translation from Weblate (Thai) 2025-07-19 21:19:47 +02:00
nehogyirj
37576f332c translation from Weblate (Hungarian)
Currently translated at 38.6% (106 of 274 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/hu/
2025-07-19 21:19:47 +02:00
Muhammad Bayiz
81f137eed1 translation from Weblate (Kurdish (Central))
Currently translated at 36.7% (25 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/ckb/
2025-07-19 21:19:47 +02:00
Sainaif
cae22a9316 Translated using Weblate (Polish)
Currently translated at 53.6% (67 of 125 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/pl/
2025-07-19 21:19:47 +02:00
Sainaif
cbb8de01b7 Added translation using Weblate (Persian) 2025-07-19 21:19:47 +02:00
Blackspirits
a2e263a7d1 Translated using Weblate (Portuguese (Portugal))
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_PT/
2025-07-19 21:19:47 +02:00
Blackspirits
7a51acbfe4 Added translation using Weblate (Portuguese) 2025-07-19 21:19:47 +02:00
Blackspirits
aa04ede019 translation from Weblate (Portuguese (Portugal))
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_PT/
2025-07-19 21:19:47 +02:00
Blackspirits
9cca1d97cd add translation from Weblate (Portuguese) 2025-07-19 21:19:47 +02:00
TrollCLGT
e7fcdf0e65 Translated using Weblate (Vietnamese)
Currently translated at 33.3% (17 of 51 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/vi/
2025-07-19 21:19:47 +02:00
Vittat
d123d6aa9e Translated using Weblate (Spanish)
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/es/
2025-07-19 21:19:47 +02:00
Vittat
42d5785025 translation from Weblate (Spanish)
Currently translated at 97.0% (66 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/es/
2025-07-19 21:19:47 +02:00
Harvey Tindall
bdd14604d5 accounts: double click "select all" to load and select -all-
Clicking once will select all visible records, and show as
indeterminate. Clicking again will load all records, and select them all
once done.
2025-07-18 18:04:23 +01:00
Harvey Tindall
908e9f07c0 Merge pull request #420 from hrfee/dependabot/npm_and_yarn/multi-1083d179d6
build(deps): bump on-headers and morgan
2025-07-18 13:44:27 +01:00
Harvey Tindall
488ba7be38 userpage: use window.pages.MyAccount instead of
window.location.pathname

fixes #418 hopefully.
2025-07-18 13:23:25 +01:00
Harvey Tindall
a0165f6f02 auth: strip port from domain if present
app.UseProxyHost being enabled means app.ExternalDomain sometimes
returns a domain/IP with a port attached. This is now removed, so the
refresh cookie is set correctly.
2025-07-18 12:59:39 +01:00
dependabot[bot]
92f825963a build(deps): bump on-headers and morgan
Bumps [on-headers](https://github.com/jshttp/on-headers) and [morgan](https://github.com/expressjs/morgan). These dependencies needed to be updated together.

Updates `on-headers` from 1.0.2 to 1.1.0
- [Release notes](https://github.com/jshttp/on-headers/releases)
- [Changelog](https://github.com/jshttp/on-headers/blob/master/HISTORY.md)
- [Commits](https://github.com/jshttp/on-headers/compare/v1.0.2...v1.1.0)

Updates `morgan` from 1.10.0 to 1.10.1
- [Release notes](https://github.com/expressjs/morgan/releases)
- [Changelog](https://github.com/expressjs/morgan/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/morgan/compare/1.10.0...1.10.1)

---
updated-dependencies:
- dependency-name: on-headers
  dependency-version: 1.1.0
  dependency-type: indirect
- dependency-name: morgan
  dependency-version: 1.10.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-18 01:31:41 +00:00
Harvey Tindall
010ce5ff7a build: downgrade swaggo
1.16.5 has a nil pointer dereferencing bug, reported by lots of other
people too.
2025-07-17 13:43:59 +01:00
Harvey Tindall
bc4c63b998 ci: another amendment foor woodpecker 3.x 2025-07-16 21:58:09 +01:00
Harvey Tindall
537b45951e ci: update for woodpecker 3.x 2025-07-16 21:43:22 +01:00
Harvey Tindall
a92f449e7f go: update deps; mediabrowser thread-safe cache
did a go get -u, and updated mediabrowser for a thread-safe user cache,
for #415.
2025-07-16 21:23:06 +01:00
Harvey Tindall
bcb6346f81 form: allow relative redirect_url
EvaluateRelativePath will un-relative-ize a path if it is relative (has
a preceding /) in the same way as app.ExternalURI() roughly. Should fix #414.
2025-07-16 18:47:32 +01:00
Harvey Tindall
7cb66e26e5 http: add "Use reverse proxy host" option
added "Use reverse-proxy reported "Host" when possible" option, which
will prefer using the "Host" or "X-Forwarded-Host" values instead of
"External jfa-go URL" in the web app. To do so, app.ExternalDomain/URI
are now functions which take *gin.Context (the latter optionally). The
protocol for the request is determined from X-Forwarded-Proto(col), so
make sure your proxy includes it.

The wiki will have been updated to mention the new option.
2025-07-16 15:22:07 +01:00
Harvey Tindall
41ddf73e4f invites: emails -> messages, log when sendTo attempted when disabled
A user's lengthy debugging resulted in them figuring out "Invite
emails" being disabled stopped the "/inv" command from sending invites
on discord, which makes sense except the confusing setting name (now
renamed "Messages" in the UI), and the fact that no error was reported.
This setting being disabled is now logged to the console when it's
attempted through the admin page or discord. For #378.
2025-07-14 13:58:03 +01:00
Harvey Tindall
4f02c44e39 router: fix /accounts route in normal usage
mistake from previous commits, the route wasn't being assigned at all.
2025-05-28 12:39:46 +01:00
Harvey Tindall
3c87b78dd9 activity: add filtered count route
separated the search parts of ServerSearchReqDTO into
ServerFilterReqDTO, factored out part of GetActivities that made the
(filtering) query, which is then used in GetFilteredActivityCount with
db.Count.
2025-05-27 18:59:50 +01:00
Harvey Tindall
eb619b6544 invites: fix /count routes
both now filter for IsReferral == true, and a typo in /used was fixed.
2025-05-27 18:48:46 +01:00
Harvey Tindall
79e8b24d7a args: add arg to disable api auth (for development!)
a long flag to type out, that requires debug to be enabled and results
in a prompt to confirm your action.
2025-05-27 18:45:43 +01:00
Harvey Tindall
80fd7c9842 invite: add /count/used route
counts the number of active invites which have been used.
2025-05-27 18:31:01 +01:00
Harvey Tindall
9409370984 invites: add /count route 2025-05-27 18:30:06 +01:00
Harvey Tindall
006fde502e api-users: GetUserCount with app.jf, not userCache
Makes no sense to generate the web user cache for this value.
2025-05-27 18:16:25 +01:00
Harvey Tindall
c02cfffc9b router: fix /accounts collision again, gracefully fail for others
all routes are registered on the root of the host, and the "reverse
proxy subfolder". When using Reverse proxy subfolder "/accounts", and
the default URL Path for the Admin page of "/" (a.k.a. ""), a route
collision occurs that for some reason wasn't dealt with before. This is
avoided by checking before registering the second route, and a panic
recovery handler has been added telling the user off for using bad
subpaths if any others occur.
2025-05-27 17:53:39 +01:00
Harvey Tindall
688e941d64 router: implement ETags/If-None-Match based on build time
Essentially caching based on when the program was built.
2025-05-27 16:29:58 +01:00
Harvey Tindall
0fd3981d9b accounts: server-search user included in query param 2025-05-27 15:48:04 +01:00
Harvey Tindall
617f7ee133 admin: fix pseudo-urls again 2025-05-27 15:44:04 +01:00
Harvey Tindall
42d1abe130 admin: change path behaviour for the billionth time
URLs are made with app.ExternalURI (now included in window.pages), which
itself should include Base. Also fixed the subfolder being removed from
the url after login.
2025-05-27 15:40:08 +01:00
Harvey Tindall
d8e624ad22 accounts: invalidate web user cache on changes as well
previously used app.jf.CacheExpiry = time.Now(), now either call
app.InvalidateJellyfinCache() (when we only need it to get a user),
or app.InvalidateUserCaches() (when the web user list needs to be
updated).
2025-05-27 15:22:24 +01:00
Harvey Tindall
30acc4f9b8 profiles: fix "max parental rating" nil-value
fixed in v0.3.28 of mediabrowser.
2025-05-27 15:14:15 +01:00
Harvey Tindall
c93211b68f merge user "pagination"/server search
server-side search, user "pagination"/infinite scroll.
2025-05-27 15:06:58 +01:00
Harvey Tindall
1d7d82b793 search: fix server-side date behaviour
OffsetMinutesFromUTC was being passed incorrectly by the web app
(getTimezoneOffset if UTC - Timezone, we wanted Timezone - UTC), now
fixed. This value is now used if given in comparisons. Times are
truncated to minute-deep precision, and Any date comparison ignores
empty date fields (i.e. a unix timestamp being 0 or a time.Time.IsZero()
== true).
2025-05-27 14:57:56 +01:00
Harvey Tindall
b40abafb95 list: cleanup, include offset in DateAttempt
included UTC offset in minutes in DateAttempt, will be used shortly.
Also moved this stuff (ParsedDate, DateAttempt) to the common d.ts, and
the method for parsing from a string (now parseDateString) to common.
Also pre-emptively load the user cache when the admin page loads.
2025-05-27 14:29:09 +01:00
Harvey Tindall
18f8921eba list: add back default sort to _load
accidentally deleted it when merging reload and loadMore.
2025-05-27 11:00:58 +01:00
Harvey Tindall
285215cf4b Merge branch 'main' into user-pagination 2025-05-27 10:41:22 +01:00
Harvey Tindall
fe4097a724 email: remove ssltls mention 2025-05-27 10:27:35 +01:00
Harvey Tindall
364b010ceb list: refactor loading methods 2025-05-27 10:27:02 +01:00
Harvey Tindall
37bdf50bb0 Merge branch 'user-pagination' of github.com:hrfee/jfa-go into user-pagination 2025-05-26 21:52:45 +01:00
Harvey Tindall
70e35b8bd7 list: fix activities sort, css improvements
fixed activities sorting.
less oddities from me not knowing how to use flex boxes. Also kinda
standardised a button-inside-input thing, so added a .gap-<n> >
.inside-input selector which works on gap-1 and gap-2.
2025-05-26 21:52:31 +01:00
Harvey Tindall
2657e74803 search: fix "search all" button disabling logic, more
just a few more general fixes. Also changed the "Search all" button to
say "Search/sort all".
2025-05-26 21:52:31 +01:00
Harvey Tindall
372514709d search: add localOnly to web app queries, fix string+bool queries
localOnly: true in a queryType means it won't be sent to the server, but
will be evaluated by the web app on the returned search results.
2025-05-26 21:52:31 +01:00
Harvey Tindall
c922dc5b50 search: fix server-side dates, add mentionedUsers, referrer, time
QueryDTO.Value being classed as "any" meant DateAttempts would be
unmarshaled as map[string]any, so a custom UnmarshalJSON checks the data
type and unmarshals into a DateAttempt if needed. mentionedUers,
referrer and time matching implemented for activity search. Also, fixed
multi-class queries (e.g. date -and- bool for last-active).
2025-05-26 21:52:31 +01:00
Harvey Tindall
6fff8a887e activity: basic query support
only supports queries of fields actually in Activity. The web UI only
directly queries two of these, ID and Time (mistakenly referneced as date in
the web ui previously). Later commits will come up with creative ways of
dealing with all the other query types.
2025-05-26 21:52:31 +01:00
Harvey Tindall
0a7093a3b4 activities: fix updateExistingElements, rename
forgot to wipe this._ordering, so search was attempting to run on
non-existent items. Also renamed the two functions to appendNewItems and
replaceWithNewItems, this makes more sense to me.
2025-05-26 21:52:31 +01:00
Harvey Tindall
d8fe593323 list: fix RecordCounter, inf scroll in search, activities 2025-05-26 21:52:31 +01:00
Harvey Tindall
4dcec4b9c7 accounts: fix infinite scroll over-loading, use scrollend+polyfill
calculation for number of rows to be drawn was wrong, fixed now. To
compensate for overshooting with fast scrolling, speed is calculated
using previous scrollY in rows/scroll, and used to render more rows.
Also, the "scrollend" event is used to load more at the end of a scroll
always. Since this isn't available on safari/webkit(2gtk), a polyfill
has been added.
2025-05-26 21:52:31 +01:00
Harvey Tindall
ac56ad1400 accounts: add credit for infinite scroll 2025-05-26 21:52:31 +01:00
Harvey Tindall
d09ee59a1a accounts: infinite scroll for performance
Found out the bottleneck when ~2000 or more elements are loaded isn't
the search or sort or anything, but the DOM. An infinite scroll
implementation is added, where elements are added to the DOM as you
scroll. May still be a little buggy, and can't yet cope with screen
resizes. Also, the "shown" indicator is broken.
2025-05-26 21:52:31 +01:00
Harvey Tindall
3299398806 config: add user cache async/sync timeout options
previously were constants in usercache.go, now app.userCache is
instantiated in main.go with NewUserCache(time.Duration, time.Duration).
2025-05-26 21:52:31 +01:00
Harvey Tindall
b53120f271 usercache: cleanup, also elsewhere
removed some old FIXMEs and documented usercache nicely for once,
renaming some things too.
2025-05-26 21:52:31 +01:00
Harvey Tindall
1dfe13951f activities: fix slow load w/ lots of users
Added ID/Name indexing to mediabrowser, and cleaned it up a little bit.
Was taking ~2s with 5000 users and firing tons of requests at Jellyfin,
now take ~160ms from cold boot, ~1ms with cache.
2025-05-26 21:52:31 +01:00
Harvey Tindall
732ce1bc57 search: more server-search refinement
fixed bugs, added extra text on "no results found" to suggest server
searching, and conditionally disable the button based on search content
and current sort. Activities page still broken. Also fixed up cache
generation, only one should ever run now, as should sorting. Two time
thresholds exist, one to trigger a re-sync but do it in the background
(i.e. send the old one to the requester), and one to re-sync and wait
for it.
2025-05-26 21:52:31 +01:00
Harvey Tindall
94e076401e accounts: pagination, server-side search
Pagination fully factored out, and both Activities and Accounts now use
a PaginatedList superclass. Server-side search is done by pressing enter
after typing a search, or by pressing the search button. Works on
accounts, soon will on activities if it doesn't already.
2025-05-26 21:52:31 +01:00
Harvey Tindall
fb83094532 search: factor out date and bool comparison 2025-05-26 21:52:31 +01:00
Harvey Tindall
dec5197bfd usercache: we'll do it ourselves
we don't need expr or anything like that, cmp.Less and vim macros exist.
2025-05-26 21:52:31 +01:00
Harvey Tindall
ebff016b5d accounts: add "record count", start searchable user cache
RecordCounter class created from that in activityList, and put in
accountsList. PageCount-type route standardized and made for /users
(/users/count). Created userCache, which regularly generates the
respUser list returned by /users. Added a currently dumb POST /users for
searching/pagination, GET /users is now just for getting -all- users.
go-getted expr, an expression language that seems like it'll be useful
for evaluating local searches. We don't store this data in the badger
    DB, so we can't use the nice query form provided by badgerhold.
2025-05-26 21:52:31 +01:00
Harvey Tindall
da0dc7f1c0 email: Allow no SMTP encryption
added a "none" option.
2025-05-26 21:44:38 +01:00
Harvey Tindall
f6044578c0 list: fix activities sort, css improvements
fixed activities sorting.
less oddities from me not knowing how to use flex boxes. Also kinda
standardised a button-inside-input thing, so added a .gap-<n> >
.inside-input selector which works on gap-1 and gap-2.
2025-05-26 21:42:37 +01:00
Harvey Tindall
699cbee240 search: fix "search all" button disabling logic, more
just a few more general fixes. Also changed the "Search all" button to
say "Search/sort all".
2025-05-26 17:28:09 +01:00
Harvey Tindall
ef253de56b search: add localOnly to web app queries, fix string+bool queries
localOnly: true in a queryType means it won't be sent to the server, but
will be evaluated by the web app on the returned search results.
2025-05-26 16:06:41 +01:00
Harvey Tindall
9715f90a48 search: fix server-side dates, add mentionedUsers, referrer, time
QueryDTO.Value being classed as "any" meant DateAttempts would be
unmarshaled as map[string]any, so a custom UnmarshalJSON checks the data
type and unmarshals into a DateAttempt if needed. mentionedUers,
referrer and time matching implemented for activity search. Also, fixed
multi-class queries (e.g. date -and- bool for last-active).
2025-05-26 15:09:40 +01:00
Harvey Tindall
792296e3bc activity: basic query support
only supports queries of fields actually in Activity. The web UI only
directly queries two of these, ID and Time (mistakenly referneced as date in
the web ui previously). Later commits will come up with creative ways of
dealing with all the other query types.
2025-05-23 16:37:42 +01:00
Harvey Tindall
31d3e52229 activities: fix updateExistingElements, rename
forgot to wipe this._ordering, so search was attempting to run on
non-existent items. Also renamed the two functions to appendNewItems and
replaceWithNewItems, this makes more sense to me.
2025-05-23 15:02:42 +01:00
Harvey Tindall
4a92712c90 list: fix RecordCounter, inf scroll in search, activities 2025-05-23 14:54:00 +01:00
Harvey Tindall
47188da5c2 accounts: fix infinite scroll over-loading, use scrollend+polyfill
calculation for number of rows to be drawn was wrong, fixed now. To
compensate for overshooting with fast scrolling, speed is calculated
using previous scrollY in rows/scroll, and used to render more rows.
Also, the "scrollend" event is used to load more at the end of a scroll
always. Since this isn't available on safari/webkit(2gtk), a polyfill
has been added.
2025-05-23 13:58:04 +01:00
Harvey Tindall
bdae52fad7 accounts: add credit for infinite scroll 2025-05-22 21:38:30 +01:00
Harvey Tindall
1ec3ddad9f accounts: infinite scroll for performance
Found out the bottleneck when ~2000 or more elements are loaded isn't
the search or sort or anything, but the DOM. An infinite scroll
implementation is added, where elements are added to the DOM as you
scroll. May still be a little buggy, and can't yet cope with screen
resizes. Also, the "shown" indicator is broken.
2025-05-22 21:10:49 +01:00
Harvey Tindall
64a144034d config: add user cache async/sync timeout options
previously were constants in usercache.go, now app.userCache is
instantiated in main.go with NewUserCache(time.Duration, time.Duration).
2025-05-22 18:03:18 +01:00
Harvey Tindall
d0f740f99d usercache: cleanup, also elsewhere
removed some old FIXMEs and documented usercache nicely for once,
renaming some things too.
2025-05-22 14:08:17 +01:00
Harvey Tindall
58c7b695c9 activities: fix slow load w/ lots of users
Added ID/Name indexing to mediabrowser, and cleaned it up a little bit.
Was taking ~2s with 5000 users and firing tons of requests at Jellyfin,
now take ~160ms from cold boot, ~1ms with cache.
2025-05-21 21:45:53 +01:00
Harvey Tindall
b19efc4ee6 search: more server-search refinement
fixed bugs, added extra text on "no results found" to suggest server
searching, and conditionally disable the button based on search content
and current sort. Activities page still broken. Also fixed up cache
generation, only one should ever run now, as should sorting. Two time
thresholds exist, one to trigger a re-sync but do it in the background
(i.e. send the old one to the requester), and one to re-sync and wait
for it.
2025-05-21 15:23:26 +01:00
Harvey Tindall
8ba6131d22 accounts: pagination, server-side search
Pagination fully factored out, and both Activities and Accounts now use
a PaginatedList superclass. Server-side search is done by pressing enter
after typing a search, or by pressing the search button. Works on
accounts, soon will on activities if it doesn't already.
2025-05-20 18:57:16 +01:00
Harvey Tindall
c5683dbc71 search: factor out date and bool comparison 2025-05-16 16:50:13 +01:00
Harvey Tindall
3067db9c31 usercache: we'll do it ourselves
we don't need expr or anything like that, cmp.Less and vim macros exist.
2025-05-15 20:08:52 +01:00
Harvey Tindall
28440a9096 accounts: add "record count", start searchable user cache
RecordCounter class created from that in activityList, and put in
accountsList. PageCount-type route standardized and made for /users
(/users/count). Created userCache, which regularly generates the
respUser list returned by /users. Added a currently dumb POST /users for
searching/pagination, GET /users is now just for getting -all- users.
go-getted expr, an expression language that seems like it'll be useful
for evaluating local searches. We don't store this data in the badger
    DB, so we can't use the nice query form provided by badgerhold.
2025-05-15 19:19:51 +01:00
Harvey Tindall
07d02f8302 discord: fix admin-check for /inv
it was being checked in the EmailAddress record, only set if Jellyfin
login is disabled, or "access jfa-go" is checked for a
non-Jellyfin-admin user in Accounts. Instead, i've factored out the
actual auth code into a "canAccessAdminPage"-ish function, which is
called for this too. Should fix #378.
2025-05-15 17:50:18 +01:00
Harvey Tindall
01a75c3e23 settings: add jellyseerr wiki link, clarify API key src
An API key is shown in Jellyseerr's setup which is actually for
Jellyfin. I (and I imagine other users have) copied it expecting it was
for Jellyseerr and was surprised the app didn't work. It's now clarified
    in the API Key setting description to get it from the first tab in
    Jellyseerr, and not the "Jellyfin" tab.
2025-05-15 16:14:15 +01:00
Harvey Tindall
4cc5fd7189 mediabrowser: bump for parental rating setting
fixes #382.
2025-05-15 15:38:35 +01:00
Harvey Tindall
16c5420c6f setup: show internal and external links on finish
internal generated from host, port and url_base, external is just
jfa_url. Both are shown (if jfa_url is set).
2025-05-15 15:10:07 +01:00
Harvey Tindall
eab33d9f6d captcha: fix for custom invite form subpath 2025-05-14 22:58:37 +01:00
Harvey Tindall
471021623b userpage: fix invite code gen
as the admin page, use window.pages to generate link entirely.
2025-05-14 22:55:34 +01:00
Harvey Tindall
e7f4de2202 router: fix webFS on form subpath 2025-05-14 22:55:31 +01:00
Harvey Tindall
44e8035ff0 config: add http:// to all urls if needed, fix invite link generation
changed invites.ts to generate links using window.Pages entirely, rather
than chopping up window.location.href.
2025-05-14 22:46:46 +01:00
Harvey Tindall
e38ac62ae4 settings: fix search with disabled/deprecated sections/settings
it was assumed all sections and settings would exist (either in
this._sections or on the DOM with the "data-name" attribute). Now it
checks they do and just ignores them if not.
2025-05-14 22:11:24 +01:00
Harvey Tindall
b47a481678 Merge pull request #405 from hrfee/paramaterized-paths
Paramaterized paths: Put pages in different places
2025-05-14 21:52:54 +01:00
192 changed files with 15587 additions and 15182 deletions

View File

@@ -1,6 +1,6 @@
when:
- event: push
branch: main
branch: ci-streamline
# - evaluate: 'CI_PIPELINE_EVENT != "PULL_REQUEST" && CI_COMMIT_BRANCH == CI_REPO_DEFAULT_BRANCH'
clone:
@@ -12,6 +12,24 @@ clone:
depth: 0
steps:
- name: precompile
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
commands:
- npm i
- make precompile
- go mod download
- name: test
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
commands:
- make test
- name: build
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
@@ -22,30 +40,58 @@ steps:
- curl -sfL https://goreleaser.com/static/run > goreleaser
- chmod +x goreleaser
- ./scripts/version.sh ./goreleaser --snapshot --skip=publish --clean
- name: redoc
# - name: redoc
# image: docker.io/hrfee/jfa-go-build-docker:latest
# environment:
# REDOC_SSH_ID:
# from_secret: REDOC_SSH_ID
# commands:
# - sh -c "echo \"$REDOC_SSH_ID\" > /tmp/id_redoc && chmod 600 /tmp/id_redoc"
# - bash -c 'sftp -P 3625 -i /tmp/id_redoc -o StrictHostKeyChecking=no redoc@api.jfa-go.com:/home/redoc <<< $"put docs/swagger.json jfa-go.json"'
# - name: deb-repo
# image: docker.io/hrfee/jfa-go-build-docker:latest
# environment:
# REPO_SSH_ID:
# from_secret: REPO_SSH_ID
# commands:
# - sh -c "echo \"$REPO_SSH_ID\" > /tmp/id_repo && chmod 600 /tmp/id_repo"
# - bash -c 'sftp -P 2022 -i /tmp/id_repo -o StrictHostKeyChecking=no root@apt.hrfee.dev:/repo/incoming <<< $"put dist/*.deb"'
# - bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty-unstable"'
# - bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty"'
# - bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "rm -f /repo/incoming/*.deb"'
# - name: buildrone
# image: docker.io/hrfee/jfa-go-build-docker:latest
# environment:
# BUILDRONE_KEY:
# from_secret: BUILDRONE_KEY
# commands:
# - wget https://builds.hrfee.pw/upload.py
# - bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --upload ./dist/*.zip ./dist/*.rpm ./dist/*.apk --tag internal-git=true'
- name: build-external
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
REDOC_SSH_ID:
from_secret: REDOC_SSH_ID
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
commands:
- sh -c "echo \"$REDOC_SSH_ID\" > /tmp/id_redoc && chmod 600 /tmp/id_redoc"
- bash -c 'sftp -P 3625 -i /tmp/id_redoc -o StrictHostKeyChecking=no redoc@api.jfa-go.com:/home/redoc <<< $"put docs/swagger.json jfa-go.json"'
- name: deb-repo
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
REPO_SSH_ID:
from_secret: REPO_SSH_ID
commands:
- sh -c "echo \"$REPO_SSH_ID\" > /tmp/id_repo && chmod 600 /tmp/id_repo"
- bash -c 'sftp -P 2022 -i /tmp/id_repo -o StrictHostKeyChecking=no root@apt.hrfee.dev:/repo/incoming <<< $"put dist/*.deb"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty-unstable"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "rm -f /repo/incoming/*.deb"'
- name: buildrone
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
commands:
- wget https://builds.hrfee.pw/upload.py
- bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --upload ./dist/*.zip ./dist/*.rpm ./dist/*.apk --tag internal-git=true'
- sed -i 's#id="password_resets-watch_directory" placeholder="/config/jellyfin"#id="password_resets-watch_directory" value="/jf" disabled#g' ./build/data/html/setup.html
- env GOOS=linux INTERNAL=off ./scripts/version.sh ./goreleaser build --snapshot --id notray-e2ee --clean
- mv ./dist/notray-e2ee_linux_arm_6 ./dist/notray-e2ee_linux_arm
- name: container
image: docker.io/woodpeckerci/plugin-docker-buildx
settings:
dry_run: false
dockerfile: Dockerfile.ci
username:
from_secret: DOCKER_USERNAME
password:
from_secret: DOCKER_TOKEN
repo: docker.io/hrfee/jfa-go
tags: debug-ci-streamline
registry: docker.io
platforms: linux/amd64,linux/arm64,linux/arm/v7
build_args:
- BUILT_BY:
from_secret: BUILT_BY

View File

@@ -2,10 +2,17 @@ when:
- event: push
branch: main
clone:
git:
image: woodpeckerci/plugin-git
settings:
tags: true
partial: false
depth: 0
steps:
- name: build
image: docker.io/woodpeckerci/plugin-docker-buildx
secrets: [ BUILT_BY ]
settings:
username:
from_secret: DOCKER_USERNAME
@@ -16,7 +23,8 @@ steps:
registry: docker.io
platforms: linux/amd64,linux/arm64,linux/arm/v7
build_args:
- BUILT_BY: $BUILT_BY
- BUILT_BY:
from_secret: BUILT_BY
- name: buildrone
image: docker.io/python
environment:

View File

@@ -11,11 +11,31 @@ clone:
depth: 0
steps:
- name: precompile
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
commands:
- npm i
- make precompile
- go mod download
- name: test
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
commands:
- make test
- name: build
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
GITHUB_TOKEN:
from_secret: GITHUB_TOKEN
commands:
- curl -sfL https://goreleaser.com/static/run > ../goreleaser
- chmod +x ../goreleaser

View File

@@ -2,10 +2,17 @@ when:
- event: tag
branch: main
clone:
git:
image: woodpeckerci/plugin-git
settings:
tags: true
partial: false
depth: 0
steps:
- name: build
image: docker.io/woodpeckerci/plugin-docker-buildx
secrets: [ BUILT_BY ]
settings:
username:
from_secret: DOCKER_USERNAME
@@ -16,7 +23,8 @@ steps:
registry: docker.io
platforms: linux/amd64,linux/arm64,linux/arm/v7
build_args:
- BUILT_BY: $BUILT_BY
- BUILT_BY:
from_secret: BUILT_BY
- name: buildrone
image: docker.io/python
environment:

12
Dockerfile.ci Normal file
View File

@@ -0,0 +1,12 @@
FROM golang:bookworm AS final
ARG TARGETARCH
COPY ./dist/notray-e2ee_linux_${TARGETARCH}* /opt/jfa-go
COPY ./build/data /opt/jfa-go/data
RUN apt-get update -y && apt-get install libolm-dev -y
EXPOSE 8056
EXPOSE 8057
CMD [ "/opt/jfa-go/jfa-go", "-data", "/data" ]

View File

@@ -2,7 +2,7 @@
MIT License
Copyright (c) 2023 Harvey Tindall
Copyright (c) 2025 Harvey Tindall
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,4 +1,4 @@
.PHONY: configuration email typescript swagger copy compile compress inline-css variants-html install clean npm config-description config-default precompile
.PHONY: configuration email typescript swagger copy compile compress inline-css variants-html install clean npm config-description config-default precompile test
.DEFAULT_GOAL := all
GOESBUILD ?= off
@@ -9,7 +9,7 @@ else
endif
GOBINARY ?= go
CSSVERSION ?= v3
CSSVERSION ?= $(shell git describe --tags --abbrev=0)
CSS_BUNDLE = $(DATA)/web/css/$(CSSVERSION)bundle.css
VERSION ?= $(shell git describe --exact-match HEAD 2> /dev/null || echo vgit)
@@ -33,7 +33,7 @@ E2EE ?= on
TAGS := -tags "
ifeq ($(INTERNAL), on)
DATA := data
DATA := build/data
COMPDEPS := $(BUILDDEPS)
else
DATA := build/data
@@ -97,11 +97,18 @@ else
endif
ifeq (, $(shell which swag))
SWAGINSTALL := $(GOBINARY) install github.com/swaggo/swag/cmd/swag@latest
SWAGINSTALL := $(GOBINARY) install github.com/swaggo/swag/cmd/swag@v1.16.4
else
SWAGINSTALL :=
endif
# FLAG HASHING: To rebuild on flag change.
# credit for idea to https://bnikolic.co.uk/blog/sh/make/unix/2021/07/08/makefile.html
rebuildFlags := GOESBUILD GOBINARY VERSION COMMIT UPDATER INTERNAL TRAY E2EE TAGS DEBUG RACE
rebuildVals := $(foreach v,$(rebuildFlags),$(v)=$($(v)))
rebuildHash := $(strip $(shell echo $(rebuildVals) | sha256sum | cut -d " " -f1))
rebuildHashFile := $(DATA)/buildhash-$(rebuildHash).txt
CONFIG_BASE = config/config-base.yaml
# CONFIG_DESCRIPTION = $(DATA)/config-base.json
@@ -148,7 +155,7 @@ SWAGGER_SRC = $(wildcard api*.go) $(wildcard *auth.go) views.go
SWAGGER_TARGET = docs/docs.go
$(SWAGGER_TARGET): $(SWAGGER_SRC)
$(SWAGINSTALL)
swag init -g main.go
swag init --parseDependency --parseInternal -g main.go
VARIANTS_SRC = $(wildcard html/*.html)
VARIANTS_TARGET = $(DATA)/html/admin.html
@@ -160,15 +167,24 @@ $(VARIANTS_TARGET): $(VARIANTS_SRC)
ICON_SRC = node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2
ICON_TARGET = $(ICON_SRC:node_modules/remixicon/fonts/%=$(DATA)/web/css/%)
SYNTAX_LIGHT_SRC = node_modules/highlight.js/styles/base16/atelier-sulphurpool-light.min.css
SYNTAX_LIGHT_TARGET = $(DATA)/web/css/$(CSSVERSION)highlightjs-light.css
SYNTAX_DARK_SRC = node_modules/highlight.js/styles/base16/circus.min.css
SYNTAX_DARK_TARGET = $(DATA)/web/css/$(CSSVERSION)highlightjs-dark.css
CODEINPUT_SRC = node_modules/@webcoder49/code-input/code-input.min.css
CODEINPUT_TARGET = $(DATA)/web/css/$(CSSVERSION)code-input.css
CSS_SRC = $(wildcard css/*.css)
CSS_TARGET = $(DATA)/web/css/part-bundle.css
CSS_FULLTARGET = $(CSS_BUNDLE)
ALL_CSS_SRC = $(ICON_SRC) $(CSS_SRC)
ALL_CSS_SRC = $(ICON_SRC) $(CSS_SRC) $(SYNTAX_LIGHT_SRC) $(SYNTAX_DARK_SRC)
ALL_CSS_TARGET = $(ICON_TARGET)
$(CSS_FULLTARGET): $(TYPESCRIPT_TARGET) $(VARIANTS_TARGET) $(ALL_CSS_SRC) $(wildcard html/*.html)
$(info copying fonts)
cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 $(DATA)/web/css/
cp -r $(SYNTAX_LIGHT_SRC) $(SYNTAX_LIGHT_TARGET)
cp -r $(SYNTAX_DARK_SRC) $(SYNTAX_DARK_TARGET)
cp -r $(CODEINPUT_SRC) $(CODEINPUT_TARGET)
$(info bundling css)
rm -f $(CSS_TARGET) $(CSS_FULLTARGET)
$(ESBUILD) --bundle css/base.css --outfile=$(CSS_TARGET) --external:remixicon.css --external:../fonts/hanken* --minify
@@ -195,7 +211,7 @@ COPY_TARGET = $(DATA)/jfa-go.service
# $(DATA)/LICENSE $(LANG_TARGET) $(STATIC_TARGET) $(DATA)/web/css/$(CSSVERSION)bundle.css
$(COPY_TARGET): $(INLINE_TARGET) $(STATIC_SRC) $(LANG_SRC) $(CONFIG_BASE)
$(info copying $(CONFIG_BASE))
cp $(CONFIG_BASE) $(DATA)/
go run scripts/yaml/main.go -in $(CONFIG_BASE) -out $(DATA)/$(shell basename $(CONFIG_BASE))
$(info copying crash page)
cp $(DATA)/crash.html $(DATA)/html/
$(info copying static data)
@@ -210,21 +226,29 @@ $(COPY_TARGET): $(INLINE_TARGET) $(STATIC_SRC) $(LANG_SRC) $(CONFIG_BASE)
BUILDDEPS := $(DATA) $(CONFIG_DEFAULT) $(EMAIL_TARGET) $(COPY_TARGET) $(SWAGGER_TARGET) $(INLINE_TARGET) $(CSS_FULLTARGET) $(TYPESCRIPT_TARGET)
precompile: $(BUILDDEPS)
COMPDEPS =
COMPDEPS = $(rebuildHashFile)
ifeq ($(INTERNAL), on)
COMPDEPS = $(BUILDDEPS)
COMPDEPS = $(BUILDDEPS) $(rebuildHashFile)
endif
$(rebuildHashFile):
$(info recording new flags $(rebuildVals))
rm -f $(DATA)/buildhash-*.txt
touch $(rebuildHashFile)
GO_SRC = $(shell find ./ -name "*.go")
GO_TARGET = build/jfa-go
GO_TARGET = build/jfa-go
$(GO_TARGET): $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
$(info Downloading deps)
$(GOBINARY) mod download
$(info Building)
mkdir -p build
$(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o $(GO_TARGET)
$(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o $(GO_TARGET)
all: $(BUILDDEPS) $(GO_TARGET)
test: $(BUILDDEPS) $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
$(GOBINARY) test -ldflags="$(LDFLAGS)" $(TAGS) -p 1
all: $(BUILDDEPS) $(GO_TARGET) $(rebuildHashFile)
compress:
upx --lzma $(GO_TARGET)

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.9.8, the latest version as of 31/07/2024. I should be able to maintain compatability in the future, unless any big changes occur.
jfa-go currently works on Jellyfin 10.11.0, the latest version as of 21/10/25. 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.

255
activitysort.go Normal file
View File

@@ -0,0 +1,255 @@
package main
import (
"fmt"
"strings"
"time"
"github.com/hrfee/mediabrowser"
"github.com/timshannon/badgerhold/v4"
)
const (
ACTIVITY_DEFAULT_SORT_FIELD = "Time"
// This will be default anyway, as the default value of a bool field is false.
// ACTIVITY_DEFAULT_SORT_ASCENDING = false
)
func activityDTONameToField(field string) string {
// Only "ID" and "Time" of these are actually searched by the UI.
// We support the rest though for other consumers of the API.
switch field {
case "id":
return "ID"
case "type":
return "Type"
case "user_id":
return "UserID"
case "username":
return "Username"
case "source_type":
return "SourceType"
case "source":
return "Source"
case "source_username":
return "SourceUsername"
case "invite_code":
return "InviteCode"
case "value":
return "Value"
case "time":
return "Time"
case "ip":
return "IP"
}
return "unknown"
}
func activityTypeGetterNameToType(getter string) ActivityType {
switch getter {
case "accountCreation":
return ActivityCreation
case "accountDeletion":
return ActivityDeletion
case "accountDisabled":
return ActivityDisabled
case "accountEnabled":
return ActivityEnabled
case "contactLinked":
return ActivityContactLinked
case "contactUnlinked":
return ActivityContactUnlinked
case "passwordChange":
return ActivityChangePassword
case "passwordReset":
return ActivityResetPassword
case "inviteCreated":
return ActivityCreateInvite
case "inviteDeleted":
return ActivityDeleteInvite
}
return ActivityUnknown
}
// andField appends to the existing query if not nil, and otherwise creates a new one.
func andField(q *badgerhold.Query, field string) *badgerhold.Criterion {
if q == nil {
return badgerhold.Where(field)
}
return q.And(field)
}
// AsDBQuery returns a mutated "query" filtering for the conditions in "q".
func (q QueryDTO) AsDBQuery(query *badgerhold.Query) *badgerhold.Query {
// Special case for activity type:
// In the app, there isn't an "activity:<fieldname>" query, but rather "<~fieldname>:true/false" queries.
// For other API consumers, we also handle the former later.
activityType := activityTypeGetterNameToType(q.Field)
if activityType != ActivityUnknown {
criterion := andField(query, "Type")
if q.Operator != EqualOperator {
panic(fmt.Errorf("impossible operator for activity type: %v", q.Operator))
}
if q.Value.(bool) == true {
query = criterion.Eq(activityType)
} else {
query = criterion.Ne(activityType)
}
return query
}
fieldName := activityDTONameToField(q.Field)
// Fail if unrecognized, or recognized as time (we handle this with DateAttempt.Compare separately).
if fieldName == "unknown" || fieldName == "Time" {
// Caller is expected to fall back to ActivityDBQueryFromSpecialField after this.
return nil
}
criterion := andField(query, fieldName)
switch q.Operator {
case LesserOperator:
query = criterion.Lt(q.Value)
case EqualOperator:
query = criterion.Eq(q.Value)
case GreaterOperator:
query = criterion.Gt(q.Value)
}
return query
}
// ActivityMatchesSearchAsDBBaseQuery returns a base query (which you should then apply other mutations to) matching the search "term" to Activities by searching all fields. Does not search the generated title like the web app.
func ActivityMatchesSearchAsDBBaseQuery(terms []string) *badgerhold.Query {
var baseQuery *badgerhold.Query = nil
// I don't believe you can just do Where("*"), so instead run for each field.
// FIXME: Match username and source_username and source_type and type
for _, fieldName := range []string{"ID", "UserID", "Source", "InviteCode", "Value", "IP"} {
criterion := badgerhold.Where(fieldName)
// No case-insentive Contains method, so we use MatchFunc instead
f := criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
field := ra.Field()
// _, ok := field.(string)
// if !ok {
// return false, fmt.Errorf("field not string: %s", fieldName)
// }
lower := strings.ToLower(field.(string))
for _, term := range terms {
if strings.Contains(lower, term) {
return true, nil
}
}
return false, nil
})
if baseQuery == nil {
baseQuery = f
} else {
baseQuery = baseQuery.Or(f)
}
}
return baseQuery
}
func (act Activity) SourceIsUser() bool {
return (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != ""
}
func (act Activity) MustGetUsername(jf *mediabrowser.MediaBrowser) string {
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
return act.Value
}
if act.UserID == "" {
return ""
}
// Don't care abt errors, user.Name will be blank in that case anyway
user, _ := jf.UserByID(act.UserID, false)
return user.Name
}
func (act Activity) MustGetSourceUsername(jf *mediabrowser.MediaBrowser) string {
if !act.SourceIsUser() {
return ""
}
// Don't care abt errors, user.Name will be blank in that case anyway
user, _ := jf.UserByID(act.Source, false)
return user.Name
}
func ActivityDBQueryFromSpecialField(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query {
switch q.Field {
case "mentionedUsers":
query = matchMentionedUsersAsQuery(jf, query, q)
case "actor":
query = matchActorAsQuery(jf, query, q)
case "referrer":
query = matchReferrerAsQuery(jf, query, q)
case "time":
query = matchTimeAsQuery(query, q)
default:
panic(fmt.Errorf("unknown activity query field %s", q.Field))
}
return query
}
// matchMentionedUsersAsQuery is a custom match function for the "mentionedUsers" getter/query type.
func matchMentionedUsersAsQuery(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query {
criterion := andField(query, "UserID")
query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
act := ra.Record().(*Activity)
usernames := act.MustGetUsername(jf) + " " + act.MustGetSourceUsername(jf)
return strings.Contains(strings.ToLower(usernames), strings.ToLower(q.Value.(string))), nil
})
return query
}
// matchActorAsQuery is a custom match function for the "actor" getter/query type.
func matchActorAsQuery(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query {
criterion := andField(query, "SourceType")
query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
act := ra.Record().(*Activity)
matchString := activitySourceToString(act.SourceType)
if act.SourceType == ActivityAdmin || act.SourceType == ActivityUser && act.SourceIsUser() {
matchString += " " + act.MustGetSourceUsername(jf)
}
return strings.Contains(strings.ToLower(matchString), strings.ToLower(q.Value.(string))), nil
})
return query
}
// matchReferrerAsQuery is a custom match function for the "referrer" getter/query type.
func matchReferrerAsQuery(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query {
criterion := andField(query, "Type")
query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
act := ra.Record().(*Activity)
if act.Type != ActivityCreation || act.SourceType != ActivityUser || !act.SourceIsUser() {
return false, nil
}
sourceUsername := act.MustGetSourceUsername(jf)
if q.Class == BoolQuery {
val := sourceUsername != ""
if q.Value.(bool) == false {
val = !val
}
return val, nil
}
return strings.Contains(strings.ToLower(sourceUsername), strings.ToLower(q.Value.(string))), nil
})
return query
}
// mathcTimeAsQuery is a custom match function for the "time" getter/query type. Roughly matches the same way as the web app, and in usercache.go.
func matchTimeAsQuery(query *badgerhold.Query, q QueryDTO) *badgerhold.Query {
operator := Equal
switch q.Operator {
case LesserOperator:
operator = Lesser
case EqualOperator:
operator = Equal
case GreaterOperator:
operator = Greater
}
criterion := andField(query, "Time")
query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
return q.Value.(DateAttempt).CompareWithOperator(ra.Field().(time.Time), operator), nil
})
return query
}

View File

@@ -6,32 +6,6 @@ import (
"github.com/timshannon/badgerhold/v4"
)
func stringToActivityType(v string) ActivityType {
switch v {
case "creation":
return ActivityCreation
case "deletion":
return ActivityDeletion
case "disabled":
return ActivityDisabled
case "enabled":
return ActivityEnabled
case "contactLinked":
return ActivityContactLinked
case "contactUnlinked":
return ActivityContactUnlinked
case "changePassword":
return ActivityChangePassword
case "resetPassword":
return ActivityResetPassword
case "createInvite":
return ActivityCreateInvite
case "deleteInvite":
return ActivityDeleteInvite
}
return ActivityUnknown
}
func activityTypeToString(v ActivityType) string {
switch v {
case ActivityCreation:
@@ -58,6 +32,32 @@ func activityTypeToString(v ActivityType) string {
return "unknown"
}
func stringToActivityType(v string) ActivityType {
switch v {
case "creation":
return ActivityCreation
case "deletion":
return ActivityDeletion
case "disabled":
return ActivityDisabled
case "enabled":
return ActivityEnabled
case "contactLinked":
return ActivityContactLinked
case "contactUnlinked":
return ActivityContactUnlinked
case "changePassword":
return ActivityChangePassword
case "resetPassword":
return ActivityResetPassword
case "createInvite":
return ActivityCreateInvite
case "deleteInvite":
return ActivityDeleteInvite
}
return ActivityUnknown
}
func stringToActivitySource(v string) ActivitySource {
switch v {
case "user":
@@ -86,73 +86,82 @@ func activitySourceToString(v ActivitySource) string {
return "anon"
}
// generateActivitiesQuery generates a badgerhold query from QueryDTOs and search terms, which can then be searched, counted, or whatever you want.
func (app *appContext) generateActivitiesQuery(req ServerFilterReqDTO) *badgerhold.Query {
var query *badgerhold.Query
if len(req.SearchTerms) != 0 {
query = ActivityMatchesSearchAsDBBaseQuery(req.SearchTerms)
} else {
query = nil
}
for _, q := range req.Queries {
nq := q.AsDBQuery(query)
if nq == nil {
nq = ActivityDBQueryFromSpecialField(app.jf, query, q)
}
query = nq
}
if query == nil {
query = &badgerhold.Query{}
}
return query
}
// @Summary Get the requested set of activities, Paginated, filtered and sorted. Is a POST because of some issues I was having, ideally should be a GET.
// @Produce json
// @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters"
// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search parameters"
// @Success 200 {object} GetActivitiesRespDTO
// @Router /activity [post]
// @Security Bearer
// @tags Activity
// @tags Activity,Statistics
func (app *appContext) GetActivities(gc *gin.Context) {
req := GetActivitiesDTO{}
req := ServerSearchReqDTO{}
gc.BindJSON(&req)
query := &badgerhold.Query{}
activityTypes := make([]interface{}, len(req.Type))
for i, v := range req.Type {
activityTypes[i] = stringToActivityType(v)
}
if len(activityTypes) != 0 {
query = badgerhold.Where("Type").In(activityTypes...)
if req.SortByField == "" {
req.SortByField = USER_DEFAULT_SORT_FIELD
} else {
req.SortByField = activityDTONameToField(req.SortByField)
}
query := app.generateActivitiesQuery(req.ServerFilterReqDTO)
query = query.SortBy(req.SortByField)
if !req.Ascending {
query = query.Reverse()
}
query = query.SortBy("Time")
if req.Limit == 0 {
req.Limit = 10
}
query = query.Skip(req.Page * req.Limit).Limit(req.Limit)
var results []Activity
err := app.storage.db.Find(&results, query)
if err != nil {
app.err.Printf(lm.FailedDBReadActivities, err)
}
resp := GetActivitiesRespDTO{
Activities: make([]ActivityDTO, len(results)),
LastPage: len(results) != req.Limit,
}
resp.LastPage = len(results) != req.Limit
for i, act := range results {
resp.Activities[i] = ActivityDTO{
ID: act.ID,
Type: activityTypeToString(act.Type),
UserID: act.UserID,
SourceType: activitySourceToString(act.SourceType),
Source: act.Source,
InviteCode: act.InviteCode,
Value: act.Value,
Time: act.Time.Unix(),
IP: act.IP,
ID: act.ID,
Type: activityTypeToString(act.Type),
UserID: act.UserID,
SourceType: activitySourceToString(act.SourceType),
Source: act.Source,
InviteCode: act.InviteCode,
Value: act.Value,
Time: act.Time.Unix(),
IP: act.IP,
Username: act.MustGetUsername(app.jf),
SourceUsername: act.MustGetSourceUsername(app.jf),
}
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
resp.Activities[i].Username = act.Value
// Username would've been in here, clear it to avoid confusion to the consumer
resp.Activities[i].Value = ""
} else if user, err := app.jf.UserByID(act.UserID, false); err == nil {
resp.Activities[i].Username = user.Name
}
if (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != "" {
user, err := app.jf.UserByID(act.Source, false)
if err == nil {
resp.Activities[i].SourceUsername = user.Name
}
}
}
@@ -173,12 +182,12 @@ func (app *appContext) DeleteActivity(gc *gin.Context) {
// @Summary Returns the total number of activities stored in the database.
// @Produce json
// @Success 200 {object} GetActivityCountDTO
// @Success 200 {object} PageCountDTO
// @Router /activity/count [get]
// @Security Bearer
// @tags Activity
// @tags Activity,Statistics
func (app *appContext) GetActivityCount(gc *gin.Context) {
resp := GetActivityCountDTO{}
resp := PageCountDTO{}
var err error
resp.Count, err = app.storage.db.Count(&Activity{}, &badgerhold.Query{})
if err != nil {
@@ -186,3 +195,26 @@ func (app *appContext) GetActivityCount(gc *gin.Context) {
}
gc.JSON(200, resp)
}
// @Summary Returns the total number of activities matching the given filtering. Fails silently.
// @Produce json
// @Param ServerFilterReqDTO body ServerFilterReqDTO true "search parameters"
// @Success 200 {object} PageCountDTO
// @Router /activity/count [post]
// @Security Bearer
// @tags Activity,Statistics
func (app *appContext) GetFilteredActivityCount(gc *gin.Context) {
resp := PageCountDTO{}
req := ServerFilterReqDTO{}
gc.BindJSON(&req)
query := app.generateActivitiesQuery(req)
var err error
resp.Count, err = app.storage.db.Count(&Activity{}, query)
if err != nil {
// app.err.Printf(lm.FailedDBReadActivities, err)
resp.Count = 0
}
gc.JSON(200, resp)
}

View File

@@ -1,6 +1,7 @@
package main
import (
"errors"
"fmt"
"strconv"
"strings"
@@ -11,6 +12,7 @@ import (
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/itchyny/timefmt-go"
"github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4"
)
const (
@@ -102,6 +104,7 @@ func (app *appContext) deleteExpiredInvite(data Invite) {
if ok {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(data.ReferrerJellyfinID, user)
app.InvalidateWebUserCache()
}
}
wait := app.sendAdminExpiryNotification(data)
@@ -122,7 +125,7 @@ func (app *appContext) deleteExpiredInvite(data Invite) {
func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup {
notify := data.Notify
if !emailEnabled || !app.config.Section("notifications").Key("enabled").MustBool(false) || len(notify) != 0 {
if !emailEnabled || !app.config.Section("notifications").Key("enabled").MustBool(false) || len(notify) == 0 {
return nil
}
var wait sync.WaitGroup
@@ -133,7 +136,7 @@ func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup
wait.Add(1)
go func(addr string) {
defer wait.Done()
msg, err := app.email.constructExpiry(data.Code, data, app, false)
msg, err := app.email.constructExpiry(data, false)
if err != nil {
app.err.Printf(lm.FailedConstructExpiryAdmin, data.Code, err)
} else {
@@ -194,43 +197,47 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
invite.UserMinutes = req.UserMinutes
}
invite.ValidTill = validTill
if req.SendTo != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
addressValid := false
discord := ""
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
users := app.discord.GetUsers(req.SendTo)
if len(users) == 0 {
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipNoUser, req.SendTo)
} else if len(users) > 1 {
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipMultiUser, req.SendTo)
} else {
invite.SendTo = req.SendTo
addressValid = true
discord = users[0].User.ID
}
} else if emailEnabled {
addressValid = true
invite.SendTo = req.SendTo
}
if addressValid {
msg, err := app.email.constructInvite(invite.Code, invite, app, false)
if err != nil {
// Slight misuse of the template
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, req.SendTo, err)
app.err.Printf(lm.FailedConstructInviteMessage, invite.Code, err)
} else {
var err error
if discord != "" {
err = app.discord.SendDM(msg, discord)
if req.SendTo != "" {
if !(app.config.Section("invite_emails").Key("enabled").MustBool(false)) {
app.err.Printf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, errors.New(lm.InviteMessagesDisabled))
} else {
addressValid := false
discord := ""
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
users := app.discord.GetUsers(req.SendTo)
if len(users) == 0 {
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipNoUser, req.SendTo)
} else if len(users) > 1 {
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipMultiUser, req.SendTo)
} else {
err = app.email.send(msg, req.SendTo)
invite.SendTo = req.SendTo
addressValid = true
discord = users[0].User.ID
}
} else if emailEnabled {
addressValid = true
invite.SendTo = req.SendTo
}
if addressValid {
msg, err := app.email.constructInvite(invite, false)
if err != nil {
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, err)
app.err.Println(invite.SendTo)
// Slight misuse of the template
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, req.SendTo, err)
app.err.Printf(lm.FailedConstructInviteMessage, invite.Code, err)
} else {
app.info.Printf(lm.SentInviteMessage, invite.Code, req.SendTo)
var err error
if discord != "" {
err = app.discord.SendDM(msg, discord)
} else {
err = app.email.send(msg, req.SendTo)
}
if err != nil {
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, err)
app.err.Println(invite.SendTo)
} else {
app.info.Printf(lm.SentInviteMessage, invite.Code, req.SendTo)
}
}
}
}
@@ -258,12 +265,52 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
respondBool(200, true, gc)
}
// @Summary Get the number of invites stored in the database.
// @Produce json
// @Success 200 {object} PageCountDTO
// @Router /invites/count [get]
// @Security Bearer
// @tags Invites,Statistics
func (app *appContext) GetInviteCount(gc *gin.Context) {
resp := PageCountDTO{}
var err error
resp.Count, err = app.storage.db.Count(&Invite{}, badgerhold.Where("IsReferral").Eq(false))
if err != nil {
resp.Count = 0
}
gc.JSON(200, resp)
}
// @Summary Get the number of invites stored in the database that have been used (but are still valid).
// @Produce json
// @Success 200 {object} PageCountDTO
// @Router /invites/count/used [get]
// @Security Bearer
// @tags Invites,Statistics
func (app *appContext) GetInviteUsedCount(gc *gin.Context) {
resp := PageCountDTO{}
var err error
resp.Count, err = app.storage.db.Count(&Invite{}, badgerhold.Where("IsReferral").Eq(false).And("UsedBy").MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
field := ra.Field()
switch usedBy := field.(type) {
case [][]string:
return len(usedBy) > 0, nil
default:
return false, nil
}
}))
if err != nil {
resp.Count = 0
}
gc.JSON(200, resp)
}
// @Summary Get invites.
// @Produce json
// @Success 200 {object} getInvitesDTO
// @Router /invites [get]
// @Security Bearer
// @tags Invites
// @tags Invites,Statistics
func (app *appContext) GetInvites(gc *gin.Context) {
currentTime := time.Now()
app.checkInvites()
@@ -297,7 +344,7 @@ func (app *appContext) GetInvites(gc *gin.Context) {
// These used to be stored formatted instead of as a unix timestamp.
unix, err := strconv.ParseInt(pair[1], 10, 64)
if err != nil {
date, err := timefmt.Parse(pair[1], app.datePattern+" "+app.timePattern)
date, err := timefmt.Parse(pair[1], datePattern+" "+timePattern)
if err != nil {
app.err.Printf(lm.FailedParseTime, err)
}

View File

@@ -6,6 +6,7 @@ import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/common"
"github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
)
@@ -61,7 +62,7 @@ func (app *appContext) SetJellyseerrProfile(gc *gin.Context) {
}
u, err := app.js.UserByID(jellyseerrID)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyseerr, err)
app.err.Printf(lm.FailedGetUser, strconv.FormatInt(jellyseerrID, 10), lm.Jellyseerr, err)
respond(500, "Couldn't get user", gc)
return
}
@@ -111,6 +112,9 @@ func (js *JellyseerrWrapper) ImportUser(jellyfinID string, req newUserDTO, profi
return
}
ok = true
if !profile.Jellyseerr.Enabled {
return
}
err = js.ApplyTemplateToUser(jellyfinID, profile.Jellyseerr.User)
if err != nil {
err = fmt.Errorf(lm.FailedApplyTemplate, "user", lm.Jellyseerr, jellyfinID, err)
@@ -124,29 +128,62 @@ func (js *JellyseerrWrapper) ImportUser(jellyfinID string, req newUserDTO, profi
return
}
func (js *JellyseerrWrapper) AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (err error) {
func (js *JellyseerrWrapper) SetContactMethods(jellyfinID string, email *string, discord *DiscordUser, telegram *TelegramUser, contactPrefs *common.ContactPreferences) (err error) {
_, err = js.MustGetUser(jellyfinID)
if err != nil {
return
}
contactMethods := map[jellyseerr.NotificationsField]any{}
if emailEnabled {
err = js.ModifyMainUserSettings(jellyfinID, jellyseerr.MainUserSettings{Email: req.Email})
if err != nil {
// FIXME: This is a little ugly, considering all other errors are unformatted
err = fmt.Errorf(lm.FailedSetEmailAddress, lm.Jellyseerr, jellyfinID, err)
return
} else {
contactMethods[jellyseerr.FieldEmailEnabled] = req.EmailContact
if contactPrefs == nil {
contactPrefs = &common.ContactPreferences{
Email: nil,
Discord: nil,
Telegram: nil,
Matrix: nil,
}
}
if discordEnabled && discord != nil {
contactMethods[jellyseerr.FieldDiscord] = discord.ID
contactMethods[jellyseerr.FieldDiscordEnabled] = req.DiscordContact
contactMethods := map[jellyseerr.NotificationsField]any{}
if emailEnabled {
if contactPrefs.Email != nil {
contactMethods[jellyseerr.FieldEmailEnabled] = *(contactPrefs.Email)
} else if email != nil && *email != "" {
contactMethods[jellyseerr.FieldEmailEnabled] = true
}
if email != nil {
err = js.ModifyMainUserSettings(jellyfinID, jellyseerr.MainUserSettings{Email: *email})
if err != nil {
// FIXME: This is a little ugly, considering all other errors are unformatted
err = fmt.Errorf(lm.FailedSetEmailAddress, lm.Jellyseerr, jellyfinID, err)
return
}
}
}
if telegramEnabled && discord != nil {
contactMethods[jellyseerr.FieldTelegram] = telegram.ChatID
contactMethods[jellyseerr.FieldTelegramEnabled] = req.TelegramContact
if discordEnabled {
if contactPrefs.Discord != nil {
contactMethods[jellyseerr.FieldDiscordEnabled] = *(contactPrefs.Discord)
} else if discord != nil && discord.ID != "" {
contactMethods[jellyseerr.FieldDiscordEnabled] = true
}
if discord != nil {
contactMethods[jellyseerr.FieldDiscord] = discord.ID
// Whether this is still necessary or not, i don't know.
if discord.ID == "" {
contactMethods[jellyseerr.FieldDiscord] = jellyseerr.BogusIdentifier
}
}
}
if telegramEnabled {
if contactPrefs.Telegram != nil {
contactMethods[jellyseerr.FieldTelegramEnabled] = *(contactPrefs.Telegram)
} else if telegram != nil && telegram.ChatID != 0 {
contactMethods[jellyseerr.FieldTelegramEnabled] = true
}
if telegram != nil {
contactMethods[jellyseerr.FieldTelegram] = strconv.FormatInt(telegram.ChatID, 10)
// Whether this is still necessary or not, i don't know.
if telegram.ChatID == 0 {
contactMethods[jellyseerr.FieldTelegram] = jellyseerr.BogusIdentifier
}
}
}
if len(contactMethods) > 0 {
err = js.ModifyNotifications(jellyfinID, contactMethods)

View File

@@ -1,11 +1,10 @@
package main
import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/jellyseerr"
"github.com/hrfee/jfa-go/common"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
@@ -23,25 +22,16 @@ func (app *appContext) GetCustomContent(gc *gin.Context) {
if _, ok := app.storage.lang.Email[lang]; !ok {
lang = app.storage.lang.chosenEmailLang
}
adminLang := lang
if _, ok := app.storage.lang.Admin[lang]; !ok {
adminLang = app.storage.lang.chosenAdminLang
}
list := emailListDTO{
"UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.MustGetCustomContentKey("UserCreated").Enabled},
"InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.MustGetCustomContentKey("InviteExpiry").Enabled},
"PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.MustGetCustomContentKey("PasswordReset").Enabled},
"UserDeleted": {Name: app.storage.lang.Email[lang].UserDeleted["name"], Enabled: app.storage.MustGetCustomContentKey("UserDeleted").Enabled},
"UserDisabled": {Name: app.storage.lang.Email[lang].UserDisabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserDisabled").Enabled},
"UserEnabled": {Name: app.storage.lang.Email[lang].UserEnabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserEnabled").Enabled},
"UserExpiryAdjusted": {Name: app.storage.lang.Email[lang].UserExpiryAdjusted["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpiryAdjusted").Enabled},
"InviteEmail": {Name: app.storage.lang.Email[lang].InviteEmail["name"], Enabled: app.storage.MustGetCustomContentKey("InviteEmail").Enabled},
"WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.MustGetCustomContentKey("WelcomeEmail").Enabled},
"EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.MustGetCustomContentKey("EmailConfirmation").Enabled},
"UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpired").Enabled},
"UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.MustGetCustomContentKey("UserLogin").Enabled},
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("UserPage").Enabled},
"PostSignupCard": {Name: app.storage.lang.Admin[adminLang].Strings["postSignupCard"], Enabled: app.storage.MustGetCustomContentKey("PostSignupCard").Enabled, Description: app.storage.lang.Admin[adminLang].Strings["postSignupCardDescription"]},
list := emailListDTO{}
for _, cc := range customContent {
if cc.ContentType == CustomTemplate {
continue
}
ccDescription := emailListEl{Name: cc.DisplayName(&app.storage.lang, lang), Enabled: app.storage.MustGetCustomContentKey(cc.Name).Enabled}
if cc.Description != nil {
ccDescription.Description = cc.Description(&app.storage.lang, lang)
}
list[cc.Name] = ccDescription
}
filter := gc.Query("filter")
@@ -73,11 +63,12 @@ func (app *appContext) SetCustomMessage(gc *gin.Context) {
respondBool(400, false, gc)
return
}
message, ok := app.storage.GetCustomContentKey(id)
_, ok := customContent[id]
if !ok {
respondBool(400, false, gc)
return
}
message, ok := app.storage.GetCustomContentKey(id)
message.Content = req.Content
message.Enabled = true
app.storage.SetCustomContentKey(id, message)
@@ -123,146 +114,91 @@ func (app *appContext) SetCustomMessageState(gc *gin.Context) {
// @Security Bearer
// @tags Configuration
func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
lang := app.storage.lang.chosenEmailLang
id := gc.Param("id")
var content string
var err error
var msg *Message
var variables []string
var conditionals []string
var values map[string]interface{}
username := app.storage.lang.Email[lang].Strings.get("username")
emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress")
customMessage, ok := app.storage.GetCustomContentKey(id)
contentInfo, ok := customContent[id]
// FIXME: Add announcement to customContent
if !ok && id != "Announcement" {
app.err.Printf(lm.FailedGetCustomMessage, id)
respondBool(400, false, gc)
return
}
if id == "WelcomeEmail" {
conditionals = []string{"{yourAccountWillExpire}"}
customMessage.Conditionals = conditionals
} else if id == "UserPage" {
variables = []string{"{username}"}
customMessage.Variables = variables
} else if id == "UserLogin" {
variables = []string{}
customMessage.Variables = variables
} else if id == "PostSignupCard" {
variables = []string{"{username}", "{myAccountURL}"}
customMessage.Variables = variables
content, ok := app.storage.GetCustomContentKey(id)
if contentInfo.Variables == nil {
contentInfo.Variables = []string{}
}
if contentInfo.Conditionals == nil {
contentInfo.Conditionals = []string{}
}
if contentInfo.Placeholders == nil {
contentInfo.Placeholders = map[string]any{}
}
content = customMessage.Content
noContent := content == ""
if !noContent {
variables = customMessage.Variables
// Generate content from real email, if the user hasn't already customised this message.
if content.Content == "" {
var msg *Message
switch id {
// FIXME: Add announcement to customContent
case "UserCreated":
msg, err = app.email.constructCreated("", "", time.Time{}, Invite{}, true)
case "InviteExpiry":
msg, err = app.email.constructExpiry(Invite{}, true)
case "PasswordReset":
msg, err = app.email.constructReset(PasswordReset{}, true)
case "UserDeleted":
msg, err = app.email.constructDeleted("", "", true)
case "UserDisabled":
msg, err = app.email.constructDisabled("", "", true)
case "UserEnabled":
msg, err = app.email.constructEnabled("", "", true)
case "UserExpiryAdjusted":
msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", true)
case "ExpiryReminder":
msg, err = app.email.constructExpiryReminder("", time.Now().AddDate(0, 0, 3), true)
case "InviteEmail":
msg, err = app.email.constructInvite(Invite{Code: ""}, true)
case "WelcomeEmail":
msg, err = app.email.constructWelcome("", time.Time{}, true)
case "EmailConfirmation":
msg, err = app.email.constructConfirmation("", "", "", true)
case "UserExpired":
msg, err = app.email.constructUserExpired("", true)
case "Announcement":
case "UserPage":
case "UserLogin":
case "PostSignupCard":
// These don't have any example content
msg = nil
}
if err != nil {
respondBool(500, false, gc)
return
}
if msg != nil {
content.Content = msg.Text
}
}
switch id {
case "Announcement":
// Just send the email html
content = ""
case "UserCreated":
if noContent {
msg, err = app.email.constructCreated("", "", "", Invite{}, app, true)
}
values = app.email.createdValues("xxxxxx", username, emailAddress, Invite{}, app, false)
case "InviteExpiry":
if noContent {
msg, err = app.email.constructExpiry("", Invite{}, app, true)
}
values = app.email.expiryValues("xxxxxx", Invite{}, app, false)
case "PasswordReset":
if noContent {
msg, err = app.email.constructReset(PasswordReset{}, app, true)
}
values = app.email.resetValues(PasswordReset{Pin: "12-34-56", Username: username}, app, false)
case "UserDeleted":
if noContent {
msg, err = app.email.constructDeleted("", app, true)
}
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
case "UserDisabled":
if noContent {
msg, err = app.email.constructDisabled("", app, true)
}
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
case "UserEnabled":
if noContent {
msg, err = app.email.constructEnabled("", app, true)
}
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
case "UserExpiryAdjusted":
if noContent {
msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", app, true)
}
values = app.email.expiryAdjustedValues(username, time.Now(), app.storage.lang.Email[lang].Strings.get("reason"), app, false, true)
case "InviteEmail":
if noContent {
msg, err = app.email.constructInvite("", Invite{}, app, true)
}
values = app.email.inviteValues("xxxxxx", Invite{}, app, false)
case "WelcomeEmail":
if noContent {
msg, err = app.email.constructWelcome("", time.Time{}, app, true)
}
values = app.email.welcomeValues(username, time.Now(), app, false, true)
case "EmailConfirmation":
if noContent {
msg, err = app.email.constructConfirmation("", "", "", app, true)
}
values = app.email.confirmationValues("xxxxxx", username, "xxxxxx", app, false)
case "UserExpired":
if noContent {
msg, err = app.email.constructUserExpired(app, true)
}
values = app.email.userExpiredValues(app, false)
case "UserLogin", "UserPage", "PostSignupCard":
values = map[string]interface{}{}
}
if err != nil {
respondBool(500, false, gc)
return
}
if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" && id != "PostSignupCard" {
content = msg.Text
variables = make([]string, strings.Count(content, "{"))
i := 0
found := false
buf := ""
for _, c := range content {
if !found && c != '{' && c != '}' {
continue
}
found = true
buf += string(c)
if c == '}' {
found = false
variables[i] = buf
buf = ""
i++
}
}
customMessage.Variables = variables
}
if variables == nil {
variables = []string{}
}
app.storage.SetCustomContentKey(id, customMessage)
var mail *Message
if id != "UserLogin" && id != "UserPage" && id != "PostSignupCard" {
mail, err = app.email.constructTemplate("", "<div class=\"preview-content\"></div>", app)
var mail *Message = nil
if contentInfo.ContentType == CustomMessage {
mail, err = app.email.construct(EmptyCustomContent, CustomContent{
Name: EmptyCustomContent.Name,
Enabled: true,
Content: "<div class=\"preview-content\"></div>",
}, map[string]any{})
if err != nil {
respondBool(500, false, gc)
return
}
} else if id == "PostSignupCard" {
// Jankiness follows.
// Specific workaround for the currently-unique "Post signup card".
// 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 content.Content == "" {
content.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{
content.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})",
})
}
@@ -271,13 +207,15 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
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 {
} else if contentInfo.ContentType == CustomCard {
mail = &Message{
HTML: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
}
mail.Markdown = mail.HTML
} else {
app.err.Printf("unknown custom content type %d", contentInfo.ContentType)
}
gc.JSON(200, customEmailDTO{Content: content, Variables: variables, Conditionals: conditionals, Values: values, HTML: mail.HTML, Plaintext: mail.Text})
gc.JSON(200, customEmailDTO{Content: content.Content, Variables: contentInfo.Variables, Conditionals: contentInfo.Conditionals, Values: contentInfo.Placeholders, HTML: mail.HTML, Plaintext: mail.Text})
}
// @Summary Returns a new Telegram verification PIN, and the bot username.
@@ -316,29 +254,32 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
return
}
tgUser := TelegramUser{
ChatID: tgToken.ChatID,
Username: tgToken.Username,
Contact: true,
TelegramVerifiedToken: TelegramVerifiedToken{
ChatID: tgToken.ChatID,
Username: tgToken.Username,
},
Contact: true,
}
if lang, ok := app.telegram.languages[tgToken.ChatID]; ok {
tgUser.Lang = lang
}
app.storage.SetTelegramKey(req.ID, tgUser)
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldTelegram: tgUser.ChatID,
jellyseerr.FieldTelegramEnabled: tgUser.Contact,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
for _, tps := range app.thirdPartyServices {
if err := tps.SetContactMethods(req.ID, nil, nil, &tgUser, &common.ContactPreferences{
Telegram: &tgUser.Contact,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
}
}
linkExistingOmbiDiscordTelegram(app)
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
// @Summary Sets whether to notify a user through telegram/discord/matrix/email or not.
// @Produce json
// @Param SetContactMethodsDTO body SetContactMethodsDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
// @Param SetContactPreferencesDTO body SetContactPreferencesDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
// @Success 200 {object} boolResponse
// @Success 400 {object} boolResponse
// @Success 500 {object} boolResponse
@@ -346,24 +287,24 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
// @Security Bearer
// @tags Other
func (app *appContext) SetContactMethods(gc *gin.Context) {
var req SetContactMethodsDTO
var req SetContactPreferencesDTO
gc.BindJSON(&req)
if req.ID == "" {
respondBool(400, false, gc)
return
}
app.setContactMethods(req, gc)
app.setContactPreferences(req, gc)
}
func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Context) {
jsPrefs := map[jellyseerr.NotificationsField]any{}
func (app *appContext) setContactPreferences(req SetContactPreferencesDTO, gc *gin.Context) {
contactPrefs := common.ContactPreferences{}
if tgUser, ok := app.storage.GetTelegramKey(req.ID); ok {
change := tgUser.Contact != req.Telegram
tgUser.Contact = req.Telegram
app.storage.SetTelegramKey(req.ID, tgUser)
if change {
app.debug.Printf(lm.SetContactPrefForService, lm.Telegram, tgUser.Username, req.Telegram)
jsPrefs[jellyseerr.FieldTelegramEnabled] = req.Telegram
contactPrefs.Telegram = &req.Telegram
}
}
if dcUser, ok := app.storage.GetDiscordKey(req.ID); ok {
@@ -372,7 +313,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
app.storage.SetDiscordKey(req.ID, dcUser)
if change {
app.debug.Printf(lm.SetContactPrefForService, lm.Discord, dcUser.Username, req.Discord)
jsPrefs[jellyseerr.FieldDiscordEnabled] = req.Discord
contactPrefs.Discord = &req.Discord
}
}
if mxUser, ok := app.storage.GetMatrixKey(req.ID); ok {
@@ -381,6 +322,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
app.storage.SetMatrixKey(req.ID, mxUser)
if change {
app.debug.Printf(lm.SetContactPrefForService, lm.Matrix, mxUser.UserID, req.Matrix)
contactPrefs.Matrix = &req.Matrix
}
}
if email, ok := app.storage.GetEmailsKey(req.ID); ok {
@@ -389,15 +331,16 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
app.storage.SetEmailsKey(req.ID, email)
if change {
app.debug.Printf(lm.SetContactPrefForService, lm.Email, email.Addr, req.Email)
jsPrefs[jellyseerr.FieldEmailEnabled] = req.Email
contactPrefs.Email = &req.Email
}
}
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
err := app.js.ModifyNotifications(req.ID, jsPrefs)
if err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
for _, tps := range app.thirdPartyServices {
if err := tps.SetContactMethods(req.ID, nil, nil, nil, &contactPrefs); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
}
}
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -626,6 +569,7 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
Lang: "en-us",
Contact: true,
})
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -680,11 +624,12 @@ 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(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
for _, tps := range app.thirdPartyServices {
if err := tps.SetContactMethods(req.JellyfinID, nil, &user, nil, &common.ContactPreferences{
Discord: &user.Contact,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
}
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
@@ -697,6 +642,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
}, gc, false)
linkExistingOmbiDiscordTelegram(app)
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -717,12 +663,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(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
contact := false
for _, tps := range app.thirdPartyServices {
if err := tps.SetContactMethods(req.ID, nil, EmptyDiscordUser(), nil, &common.ContactPreferences{
Discord: &contact,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
}
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
@@ -734,6 +682,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
Time: time.Now(),
}, gc, false)
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -754,11 +703,14 @@ 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(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
contact := false
for _, tps := range app.thirdPartyServices {
if err := tps.SetContactMethods(req.ID, nil, nil, EmptyTelegramUser(), &common.ContactPreferences{
Telegram: &contact,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
}
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
@@ -770,6 +722,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
Time: time.Now(),
}, gc, false)
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -799,5 +752,6 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) {
Time: time.Now(),
}, gc, false)
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}

View File

@@ -8,7 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/common"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/jfa-go/ombi"
ombiLib "github.com/hrfee/jfa-go/ombi"
"github.com/hrfee/mediabrowser"
)
@@ -147,7 +147,8 @@ func (app *appContext) DeleteOmbiProfile(gc *gin.Context) {
}
type OmbiWrapper struct {
*ombi.Ombi
OmbiUserByJfID func(jfID string) (map[string]interface{}, error)
*ombiLib.Ombi
}
func (ombi *OmbiWrapper) applyProfile(user map[string]interface{}, profile map[string]interface{}) (err error) {
@@ -189,23 +190,69 @@ func (ombi *OmbiWrapper) ImportUser(jellyfinID string, req newUserDTO, profile P
return
}
func (ombi *OmbiWrapper) AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (err error) {
var ombiUser map[string]interface{}
ombiUser, err = ombi.getUser(req.Username, req.Email)
func (ombi *OmbiWrapper) SetContactMethods(jellyfinID string, email *string, discord *DiscordUser, telegram *TelegramUser, contactPrefs *common.ContactPreferences) (err error) {
ombiUser, err := ombi.OmbiUserByJfID(jellyfinID)
if err != nil {
return
}
if discordEnabled || telegramEnabled {
dID := ""
tUser := ""
if contactPrefs == nil {
contactPrefs = &common.ContactPreferences{
Email: nil,
Discord: nil,
Telegram: nil,
Matrix: nil,
}
}
if emailEnabled && email != nil {
ombiUser["emailAddress"] = *email
err = ombi.ModifyUser(ombiUser)
if err != nil {
// FIXME: This is a little ugly, considering all other errors are unformatted
err = fmt.Errorf(lm.FailedSetEmailAddress, lm.Ombi, jellyfinID, err)
return
}
}
data := make([]ombiLib.NotificationPref, 0, 2)
if discordEnabled {
pref := ombiLib.NotificationPref{
Agent: ombiLib.NotifAgentDiscord,
UserID: ombiUser["id"].(string),
}
valid := false
if contactPrefs.Discord != nil {
pref.Enabled = *(contactPrefs.Discord)
valid = true
} else if discord != nil && discord.ID != "" {
pref.Enabled = true
valid = true
}
if discord != nil {
dID = discord.ID
pref.Value = discord.ID
valid = true
}
if valid {
data = append(data, pref)
}
}
if telegramEnabled && telegram != nil {
pref := ombiLib.NotificationPref{
Agent: ombiLib.NotifAgentTelegram,
UserID: ombiUser["id"].(string),
}
if contactPrefs.Telegram != nil {
pref.Enabled = *(contactPrefs.Telegram)
} else if telegram != nil && telegram.Username != "" {
pref.Enabled = true
}
if telegram != nil {
tUser = telegram.Username
pref.Value = telegram.Username
}
data = append(data, pref)
}
if len(data) > 0 {
var resp string
resp, err = ombi.SetNotificationPrefs(ombiUser, dID, tUser)
resp, err = ombi.SetNotificationPrefs(ombiUser, data)
if err != nil {
if resp != "" {
err = fmt.Errorf("%v, %s", err, resp)

View File

@@ -2,6 +2,9 @@ package main
import (
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"github.com/gin-gonic/gin"
@@ -69,6 +72,68 @@ func (app *appContext) GetProfiles(gc *gin.Context) {
gc.JSON(200, out)
}
// @Summary Get the raw values stored in a profile (Configuration, Policy, Jellyseerr/Ombi if applicable, etc.).
// @Produce json
// @Success 200 {object} ProfileDTO
// @Failure 400 {object} boolResponse
// @Param name path string true "name of profile (url encoded if necessary)"
// @Router /profiles/raw/{name} [get]
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) GetRawProfile(gc *gin.Context) {
escapedName := gc.Param("name")
name, err := url.QueryUnescape(escapedName)
if err != nil {
respondBool(400, false, gc)
return
}
if profile, ok := app.storage.GetProfileKey(name); ok {
gc.JSON(200, profile.ProfileDTO)
return
}
respondBool(400, false, gc)
}
// @Summary Update the raw data of a profile (Configuration, Policy, Jellyseerr/Ombi if applicable, etc.).
// @Produce json
// @Param ProfileDTO body ProfileDTO true "Raw profile data (all of it, do not omit anything)"
// @Success 204 {object} boolResponse
// @Success 201 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Router /profiles/raw/{name} [put]
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) ReplaceRawProfile(gc *gin.Context) {
escapedName := gc.Param("name")
name, err := url.QueryUnescape(escapedName)
if err != nil {
respondBool(400, false, gc)
return
}
existingProfile, ok := app.storage.GetProfileKey(name)
if !ok {
respondBool(400, false, gc)
return
}
var req ProfileDTO
gc.BindJSON(&req)
existingProfile.ProfileDTO = req
if req.Name == "" {
req.Name = name
}
status := http.StatusNoContent
app.storage.SetProfileKey(req.Name, existingProfile)
if req.Name != name {
// Name change
app.storage.DeleteProfileKey(name)
if discordEnabled {
app.discord.UpdateCommands()
}
status = http.StatusCreated
}
respondBool(status, true, gc)
}
// @Summary Set the default profile to use.
// @Produce json
// @Param profileChangeDTO body profileChangeDTO true "Default profile object"
@@ -110,7 +175,7 @@ func (app *appContext) SetDefaultProfile(gc *gin.Context) {
func (app *appContext) CreateProfile(gc *gin.Context) {
var req newProfileDTO
gc.BindJSON(&req)
app.jf.CacheExpiry = time.Now()
app.InvalidateJellyfinCache()
user, err := app.jf.UserByID(req.ID, false)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
@@ -119,7 +184,7 @@ func (app *appContext) CreateProfile(gc *gin.Context) {
}
profile := Profile{
FromUser: user.Name,
Policy: user.Policy,
ProfileDTO: ProfileDTO{Policy: user.Policy},
Homescreen: req.Homescreen,
}
app.debug.Printf(lm.CreateProfileFromUser, user.Name)
@@ -132,6 +197,21 @@ func (app *appContext) CreateProfile(gc *gin.Context) {
return
}
}
if req.Jellyseerr && app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
user, err := app.js.MustGetUser(req.ID)
if err != nil {
app.err.Printf(lm.FailedGetUser, user.Name(), lm.Jellyseerr, err)
} else {
profile.Jellyseerr.User = user.UserTemplate
n, err := app.js.GetNotificationPreferencesByID(user.ID)
if err != nil {
app.err.Printf(lm.FailedGetJellyseerrNotificationPrefs, strconv.FormatInt(user.ID, 10), err)
} else {
profile.Jellyseerr.Notifications = n.NotificationsTemplate
profile.Jellyseerr.Enabled = true
}
}
}
app.storage.SetProfileKey(req.Name, profile)
// Refresh discord bots, profile list
if discordEnabled {

View File

@@ -107,7 +107,7 @@ func (app *appContext) MyDetails(gc *gin.Context) {
// @Summary Sets whether to notify yourself through telegram/discord/matrix/email or not.
// @Produce json
// @Param SetContactMethodsDTO body SetContactMethodsDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
// @Param SetContactPreferencesDTO body SetContactPreferencesDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
// @Success 200 {object} boolResponse
// @Success 400 {object} boolResponse
// @Success 500 {object} boolResponse
@@ -115,14 +115,14 @@ func (app *appContext) MyDetails(gc *gin.Context) {
// @Security Bearer
// @tags User Page
func (app *appContext) SetMyContactMethods(gc *gin.Context) {
var req SetContactMethodsDTO
var req SetContactPreferencesDTO
gc.BindJSON(&req)
req.ID = gc.GetString("jfId")
if req.ID == "" {
respondBool(400, false, gc)
return
}
app.setContactMethods(req, gc)
app.setContactPreferences(req, gc)
}
// @Summary Logout by deleting refresh token from cookies.
@@ -264,7 +264,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
}
app.debug.Printf(lm.EmailConfirmationRequired, id)
respond(401, "confirmEmail", gc)
msg, err := app.email.constructConfirmation("", name, key, app, false)
msg, err := app.email.constructConfirmation("", name, key, false)
if err != nil {
app.err.Printf(lm.FailedConstructConfirmationEmail, id, err)
} else if err := app.email.send(msg, req.Email); err != nil {
@@ -394,9 +394,11 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
return
}
tgUser := TelegramUser{
ChatID: token.ChatID,
Username: token.Username,
Contact: true,
TelegramVerifiedToken: TelegramVerifiedToken{
ChatID: token.ChatID,
Username: token.Username,
},
Contact: true,
}
if lang, ok := app.telegram.languages[tgUser.ChatID]; ok {
tgUser.Lang = lang
@@ -643,7 +645,7 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
Username: pwr.Username,
Expiry: pwr.Expiry,
Internal: true,
}, app, false,
}, false,
)
if err != nil {
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
@@ -796,6 +798,7 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
app.storage.SetInvitesKey(inv.Code, inv)
}
app.InvalidateWebUserCache()
gc.JSON(200, GetMyReferralRespDTO{
Code: inv.Code,
RemainingUses: inv.RemainingUses,

View File

@@ -4,12 +4,13 @@ import (
"fmt"
"net/url"
"os"
"slices"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
"github.com/hrfee/jfa-go/jellyseerr"
"github.com/hrfee/jfa-go/common"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
@@ -53,12 +54,29 @@ func (app *appContext) NewUserFromAdmin(gc *gin.Context) {
nu.Log()
}
var emailStore *EmailAddress = nil
if emailEnabled && req.Email != "" {
emailStore := EmailAddress{
emailStore = &EmailAddress{
Addr: req.Email,
Contact: true,
}
app.storage.SetEmailsKey(nu.User.ID, emailStore)
app.storage.SetEmailsKey(nu.User.ID, *emailStore)
}
for _, tps := range app.thirdPartyServices {
if !tps.Enabled(app, &profile) {
continue
}
// We only have email
if emailStore == nil {
continue
}
err := tps.SetContactMethods(nu.User.ID, &req.Email, nil, nil, &common.ContactPreferences{
Email: &(emailStore.Contact),
})
if err != nil {
app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
}
}
welcomeMessageSentIfNecessary := true
@@ -188,7 +206,7 @@ func (app *appContext) NewUserFromInvite(gc *gin.Context) {
app.debug.Printf(lm.EmailConfirmationRequired, req.Username)
respond(401, "confirmEmail", gc)
msg, err := app.email.constructConfirmation(req.Code, req.Username, key, app, false)
msg, err := app.email.constructConfirmation(req.Code, req.Username, key, false)
if err != nil {
app.err.Printf(lm.FailedConstructConfirmationEmail, req.Code, err)
} else if err := app.email.send(msg, req.Email); err != nil {
@@ -261,18 +279,20 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
}
app.contactMethods[i].DeleteVerifiedToken(c.PIN)
c.User.SetJellyfin(nu.User.ID)
c.User.Store(&(app.storage))
c.User.Store(app.storage)
}
}
referralsEnabled := profile != nil && profile.ReferralTemplateKey != "" && app.config.Section("user_page").Key("enabled").MustBool(false) && app.config.Section("user_page").Key("referrals").MustBool(false)
contactPrefs := common.ContactPreferences{}
if (emailEnabled && req.Email != "") || invite.UserLabel != "" || referralsEnabled {
emailStore := EmailAddress{
Addr: req.Email,
Contact: (req.Email != ""),
Label: invite.UserLabel,
}
contactPrefs.Email = &(emailStore.Contact)
if profile != nil {
profile.ReferralTemplateKey = profile.ReferralTemplateKey
}
@@ -289,7 +309,7 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
continue
}
go func(addr string) {
msg, err := app.email.constructCreated(req.Code, req.Username, req.Email, invite, app, false)
msg, err := app.email.constructCreated(req.Username, req.Email, time.Now(), invite, false)
if err != nil {
app.err.Printf(lm.FailedConstructCreationAdmin, req.Code, err)
} else {
@@ -333,18 +353,22 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
var discordUser *DiscordUser = nil
var telegramUser *TelegramUser = nil
// FIXME: Make sure its okay to, then change this check to len(app.tps) != 0 && (for loop of tps.Enabled )
if app.ombi.Enabled(app, profile) || app.js.Enabled(app, profile) {
// FIXME: figure these out in a nicer way? this relies on the current ordering,
// which may not be fixed.
if discordEnabled {
if req.completeContactMethods[0].User != nil {
discordUser = req.completeContactMethods[0].User.(*DiscordUser)
if req.completeContactMethods[0].User != nil {
discordUser = req.completeContactMethods[0].User.(*DiscordUser)
contactPrefs.Discord = &discordUser.Contact
}
if telegramEnabled && req.completeContactMethods[1].User != nil {
telegramUser = req.completeContactMethods[1].User.(*TelegramUser)
contactPrefs.Telegram = &telegramUser.Contact
}
} else if telegramEnabled && req.completeContactMethods[0].User != nil {
telegramUser = req.completeContactMethods[0].User.(*TelegramUser)
contactPrefs.Telegram = &telegramUser.Contact
}
}
@@ -353,7 +377,7 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
continue
}
// User already created, now we can link contact methods
err := tps.AddContactMethods(nu.User.ID, req.newUserDTO, discordUser, telegramUser)
err := tps.SetContactMethods(nu.User.ID, &(req.Email), discordUser, telegramUser, &contactPrefs)
if err != nil {
app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
}
@@ -379,19 +403,6 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
"SetPolicy": map[string]string{},
}
sendMail := messagesEnabled
var msg *Message
var err error
if sendMail {
if req.Enabled {
msg, err = app.email.constructEnabled(req.Reason, app, false)
} else {
msg, err = app.email.constructDisabled(req.Reason, app, false)
}
if err != nil {
app.err.Printf(lm.FailedConstructEnableDisableMessage, "?", err)
sendMail = false
}
}
activityType := ActivityDisabled
if req.Enabled {
activityType = ActivityEnabled
@@ -403,6 +414,18 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
app.err.Printf(lm.FailedGetUser, user.ID, lm.Jellyfin, err)
continue
}
var msg *Message
if sendMail {
if req.Enabled {
msg, err = app.email.constructEnabled(user.Name, req.Reason, false)
} else {
msg, err = app.email.constructDisabled(user.Name, req.Reason, false)
}
if err != nil {
app.err.Printf(lm.FailedConstructEnableDisableMessage, "?", err)
sendMail = false
}
}
err, _, _ = app.SetUserDisabled(user, !req.Enabled)
if err != nil {
errors["SetPolicy"][user.ID] = err.Error()
@@ -426,7 +449,7 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
}
}
}
app.jf.CacheExpiry = time.Now()
app.InvalidateUserCaches()
if len(errors["GetUser"]) != 0 || len(errors["SetPolicy"]) != 0 {
gc.JSON(500, errors)
return
@@ -448,15 +471,6 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
gc.BindJSON(&req)
errors := map[string]string{}
sendMail := messagesEnabled
var msg *Message
var err error
if sendMail {
msg, err = app.email.constructDeleted(req.Reason, app, false)
if err != nil {
app.err.Printf(lm.FailedConstructDeletionMessage, "?", err)
sendMail = false
}
}
for _, userID := range req.Users {
user, err := app.jf.UserByID(userID, false)
if err != nil {
@@ -464,6 +478,15 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
errors[userID] = err.Error()
}
var msg *Message = nil
if sendMail {
msg, err = app.email.constructDeleted(user.Name, req.Reason, false)
if err != nil {
app.err.Printf(lm.FailedConstructDeletionMessage, "?", err)
sendMail = false
}
}
deleted := false
err, deleted = app.DeleteUser(user)
if err != nil {
@@ -494,7 +517,7 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
}
}
}
app.jf.CacheExpiry = time.Now()
app.InvalidateUserCaches()
if len(errors) == len(req.Users) {
respondBool(500, false, gc)
app.err.Printf(lm.FailedDeleteUsers, lm.Jellyfin, errors[req.Users[0]])
@@ -525,6 +548,24 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
base := time.Now()
if expiry, ok := app.storage.GetUserExpiryKey(id); ok {
base = expiry.Expiry
app.debug.Printf(lm.FoundExistingExpiry)
} else if req.TryExtendFromPreviousExpiry {
var acts []Activity
app.storage.db.Find(&acts, badgerhold.Where("Type").Eq(ActivityDisabled).And("UserID").Eq(id).SortBy("Time").Reverse().Limit(1))
if len(acts) != 0 {
// Only do it if the most recent reason for disabling was expiry
if acts[0].SourceType == ActivityDaemon {
app.debug.Printf(lm.FoundPreviousExpiryLog, acts[0].Time)
newExpiry := acts[0].Time.AddDate(0, req.Months, req.Days).Add(time.Duration(((60 * req.Hours) + req.Minutes)) * time.Minute)
if newExpiry.After(base) {
base = acts[0].Time
} else {
app.debug.Printf(lm.ExpiryWouldBeInPast)
}
} else {
app.debug.Printf(lm.PreviousExpiryNotExpiry)
}
}
}
app.debug.Printf(lm.ExtendCreateExpiry, id)
expiry := UserExpiry{}
@@ -540,7 +581,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
if err != nil {
return
}
msg, err := app.email.constructExpiryAdjusted(user.Name, exp, req.Reason, app, false)
msg, err := app.email.constructExpiryAdjusted(user.Name, exp, req.Reason, false)
if err != nil {
app.err.Printf(lm.FailedConstructExpiryAdjustmentMessage, uid, err)
return
@@ -551,6 +592,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
}(id, expiry.Expiry)
}
}
app.InvalidateWebUserCache()
respondBool(204, true, gc)
}
@@ -562,6 +604,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
// @tags Users
func (app *appContext) RemoveExpiry(gc *gin.Context) {
app.storage.DeleteUserExpiryKey(gc.Param("id"))
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -623,6 +666,7 @@ func (app *appContext) EnableReferralForUsers(gc *gin.Context) {
inv.UseReferralExpiry = useExpiry
app.storage.SetInvitesKey(inv.Code, inv)
}
app.InvalidateWebUserCache()
}
// @Summary Disable referrals for the given user(s).
@@ -646,6 +690,7 @@ func (app *appContext) DisableReferralForUsers(gc *gin.Context) {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(u, user)
}
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -674,7 +719,10 @@ func (app *appContext) Announce(gc *gin.Context) {
app.err.Printf(lm.FailedGetUser, userID, lm.Jellyfin, err)
continue
}
msg, err := app.email.constructTemplate(req.Subject, req.Message, app, user.Name)
msg, err := app.email.construct(AnnouncementCustomContent(req.Subject), CustomContent{
Enabled: true,
Content: req.Message,
}, map[string]any{"username": user.Name})
if err != nil {
app.err.Printf(lm.FailedConstructAnnouncementMessage, userID, err)
respondBool(500, false, gc)
@@ -687,7 +735,10 @@ func (app *appContext) Announce(gc *gin.Context) {
}
// app.info.Printf(lm.SentAnnouncementMessage, "*", "?")
} else {
msg, err := app.email.constructTemplate(req.Subject, req.Message, app)
msg, err := app.email.construct(AnnouncementCustomContent(req.Subject), CustomContent{
Enabled: true,
Content: req.Message,
}, map[string]any{"username": ""})
if err != nil {
app.err.Printf(lm.FailedConstructAnnouncementMessage, "*", err)
respondBool(500, false, gc)
@@ -807,7 +858,7 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) {
app.internalPWRs[pwr.PIN] = pwr
sendAddress := app.getAddressOrName(id)
if sendAddress == "" || len(req.Users) == 1 {
resp.Link, err = app.GenResetLink(pwr.PIN)
resp.Link, err = GenResetLink(pwr.PIN)
linkCount++
if sendAddress == "" {
resp.Manual = true
@@ -820,7 +871,7 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) {
Username: pwr.Username,
Expiry: pwr.Expiry,
Internal: true,
}, app, false,
}, false,
)
if err != nil {
app.err.Printf(lm.FailedConstructPWRMessage, id, err)
@@ -840,6 +891,8 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) {
respondBool(204, true, gc)
}
// userSummary generates a respUser for to be displayed to the user, or sorted/filtered.
// also, consider it a source of which data fields/struct modifications need to trigger a cache invalidation.
func (app *appContext) userSummary(jfUser mediabrowser.User) respUser {
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
allowAll := app.config.Section("ui").Key("allow_all").MustBool(false)
@@ -894,7 +947,40 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser {
}
// @Summary Get a list of Jellyfin users.
// @Summary Returns the total number of Jellyfin users.
// @Produce json
// @Success 200 {object} PageCountDTO
// @Router /users/count [get]
// @Security Bearer
// @tags Users,Statistics
func (app *appContext) GetUserCount(gc *gin.Context) {
resp := PageCountDTO{}
users, err := app.jf.GetUsers(false)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Couldn't get users", gc)
return
}
resp.Count = uint64(len(users))
gc.JSON(200, resp)
}
// @Summary Returns the list of all labels on accounts.
// @Produce json
// @Success 200 {object} LabelsDTO
// @Router /users/labels [get]
// @Security Bearer
// @tags Users,Statistics
func (app *appContext) GetLabels(gc *gin.Context) {
if err := app.userCache.MaybeSync(app); err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Couldn't get users", gc)
return
}
gc.JSON(200, LabelsDTO{Labels: app.userCache.Labels})
}
// @Summary Get a list of -all- Jellyfin users.
// @Produce json
// @Success 200 {object} getUsersDTO
// @Failure 500 {object} stringResponse
@@ -903,19 +989,93 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser {
// @tags Users
func (app *appContext) GetUsers(gc *gin.Context) {
var resp getUsersDTO
users, err := app.jf.GetUsers(false)
resp.UserList = make([]respUser, len(users))
// We're sending all users, so this is always true
resp.LastPage = true
var err error
resp.UserList, err = app.userCache.GetUserDTOs(app, true)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Couldn't get users", gc)
return
}
i := 0
for _, jfUser := range users {
user := app.userSummary(jfUser)
resp.UserList[i] = user
i++
gc.JSON(200, resp)
}
// @Summary Get a paginated, searchable list of Jellyfin users.
// @Produce json
// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search / pagination parameters"
// @Success 200 {object} getUsersDTO
// @Failure 500 {object} stringResponse
// @Router /users [post]
// @Security Bearer
// @tags Users,Statistics
func (app *appContext) SearchUsers(gc *gin.Context) {
req := ServerSearchReqDTO{}
gc.BindJSON(&req)
if req.SortByField == "" {
req.SortByField = USER_DEFAULT_SORT_FIELD
}
var resp getUsersDTO
userList, err := app.userCache.GetUserDTOs(app, req.SortByField == USER_DEFAULT_SORT_FIELD)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Couldn't get users", gc)
return
}
var filtered []*respUser
if len(req.SearchTerms) != 0 || len(req.Queries) != 0 {
filtered = app.userCache.Filter(userList, req.SearchTerms, req.Queries)
} else {
filtered = slices.Clone(userList)
}
if req.SortByField == USER_DEFAULT_SORT_FIELD {
if req.Ascending != USER_DEFAULT_SORT_ASCENDING {
slices.Reverse(filtered)
}
} else {
app.userCache.Sort(filtered, req.SortByField, req.Ascending)
}
startIndex := (req.Page * req.Limit)
if startIndex < len(filtered) {
endIndex := min(startIndex+req.Limit, len(filtered))
resp.UserList = filtered[startIndex:endIndex]
}
resp.LastPage = len(resp.UserList) != req.Limit
gc.JSON(200, resp)
}
// @Summary Get a count of users matching the search provided
// @Produce json
// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search / pagination parameters"
// @Success 200 {object} PageCountDTO
// @Failure 500 {object} stringResponse
// @Router /users/count [post]
// @Security Bearer
// @tags Users,Statistics
func (app *appContext) GetFilteredUserCount(gc *gin.Context) {
req := ServerSearchReqDTO{}
gc.BindJSON(&req)
if req.SortByField == "" {
req.SortByField = USER_DEFAULT_SORT_FIELD
}
var resp PageCountDTO
// No need to sort
userList, err := app.userCache.GetUserDTOs(app, false)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Couldn't get users", gc)
return
}
if len(req.SearchTerms) != 0 || len(req.Queries) != 0 {
resp.Count = uint64(len(app.userCache.Filter(userList, req.SearchTerms, req.Queries)))
} else {
resp.Count = uint64(len(userList))
}
gc.JSON(200, resp)
}
@@ -948,6 +1108,7 @@ func (app *appContext) SetAccountsAdmin(gc *gin.Context) {
app.info.Printf(lm.UserAdminAdjusted, id, admin)
}
}
app.InvalidateWebUserCache()
respondBool(204, true, gc)
}
@@ -980,45 +1141,29 @@ func (app *appContext) ModifyLabels(gc *gin.Context) {
app.storage.SetEmailsKey(id, emailStore)
}
}
app.InvalidateWebUserCache()
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, err := app.getOmbiUser(jfID)
if err == nil {
ombiUser["emailAddress"] = addr
err = app.ombi.ModifyUser(ombiUser)
if err != nil {
app.err.Printf(lm.FailedSetEmailAddress, lm.Ombi, jfID, 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(lm.FailedSetEmailAddress, lm.Jellyseerr, jfID, err)
} else if contactPrefChanged {
contactMethods := map[jellyseerr.NotificationsField]any{
jellyseerr.FieldEmailEnabled: true,
}
err := app.js.ModifyNotifications(jfID, contactMethods)
if err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
for _, tps := range app.thirdPartyServices {
if err := tps.SetContactMethods(jfID, &addr, nil, nil, &common.ContactPreferences{
Email: &(emailStore.Contact),
}); err != nil {
app.err.Printf(lm.FailedSetEmailAddress, tps.Name(), jfID, err)
}
}
app.InvalidateWebUserCache()
}
// @Summary Modify user's email addresses.
@@ -1118,7 +1263,7 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
} else if req.From == "user" {
applyingFromType = lm.User
app.jf.CacheExpiry = time.Now()
app.InvalidateJellyfinCache()
user, err := app.jf.UserByID(req.ID, false)
if err != nil {
app.err.Printf(lm.FailedGetUser, req.ID, lm.Jellyfin, err)
@@ -1229,5 +1374,6 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
if len(errors["policy"]) == len(req.ApplyTo) || len(errors["homescreen"]) == len(req.ApplyTo) {
code = 500
}
app.InvalidateUserCaches()
gc.JSON(code, errors)
}

21
api.go
View File

@@ -36,23 +36,14 @@ func respondBool(code int, val bool, gc *gin.Context) {
gc.Abort()
}
func (app *appContext) loadStrftime() {
app.datePattern = app.config.Section("messages").Key("date_format").String()
app.timePattern = `%H:%M`
if val, _ := app.config.Section("messages").Key("use_24h").Bool(); !val {
app.timePattern = `%I:%M %p`
}
func prettyTime(dt time.Time) (date, time string) {
date = timefmt.Format(dt, datePattern)
time = timefmt.Format(dt, timePattern)
return
}
func (app *appContext) prettyTime(dt time.Time) (date, time string) {
date = timefmt.Format(dt, app.datePattern)
time = timefmt.Format(dt, app.timePattern)
return
}
func (app *appContext) formatDatetime(dt time.Time) string {
d, t := app.prettyTime(dt)
func formatDatetime(dt time.Time) string {
d, t := prettyTime(dt)
return d + " " + t
}
@@ -310,7 +301,7 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
if req["restart-program"] != nil && req["restart-program"].(bool) {
app.Restart()
}
app.loadConfig()
app.ReloadConfig()
// Patch new settings for next GetConfig
app.PatchConfigBase()
// Reinitialize password validator on config change, as opposed to every applicable request like in python.

18
args.go
View File

@@ -8,6 +8,8 @@ import (
"os"
"path/filepath"
"strings"
lm "github.com/hrfee/jfa-go/logmessages"
)
func (app *appContext) loadArgs(firstCall bool) {
@@ -28,6 +30,9 @@ func (app *appContext) loadArgs(firstCall bool) {
PPROF = flag.Bool("pprof", false, "Exposes pprof profiler on /debug/pprof.")
SWAGGER = flag.Bool("swagger", false, "Enable swagger at /swagger/index.html")
flag.BoolVar(&NO_API_AUTH_DO_NOT_USE, "disable-api-auth-do-not-use", false, "Disables API authentication. DO NOT USE!")
flag.StringVar(&NO_API_AUTH_FORCE_JFID, "disable-api-auth-force-jf-id", "", "Assume given JFID when API auth is disabled.")
flag.Parse()
if *help {
flag.Usage()
@@ -45,6 +50,19 @@ func (app *appContext) loadArgs(firstCall bool) {
if *_LOADBAK != "" {
LOADBAK = *_LOADBAK
}
if NO_API_AUTH_DO_NOT_USE && *DEBUG {
NO_API_AUTH_DO_NOT_USE = false
forceJfID := NO_API_AUTH_FORCE_JFID
NO_API_AUTH_FORCE_JFID = ""
buf := bufio.NewReader(os.Stdin)
app.err.Print(lm.NoAPIAuthPrompt)
sentence, err := buf.ReadBytes('\n')
if err == nil && strings.ContainsRune(string(sentence), 'y') {
NO_API_AUTH_DO_NOT_USE = true
NO_API_AUTH_FORCE_JFID = forceJfID
}
}
}
if os.Getenv("SWAGGER") == "1" {

61
auth.go
View File

@@ -40,7 +40,11 @@ func (app *appContext) logIpErr(gc *gin.Context, user bool, out string) {
}
func (app *appContext) webAuth() gin.HandlerFunc {
return app.authenticate
if NO_API_AUTH_DO_NOT_USE {
return app.bogusAuthenticate
} else {
return app.authenticate
}
}
func (app *appContext) authLog(v any) { app.debug.PrintfCustomLevel(4, lm.FailedAuthRequest, v) }
@@ -138,6 +142,13 @@ func (app *appContext) authenticate(gc *gin.Context) {
gc.Next()
}
// bogusAuthenticate is for use with NO_API_AUTH_DO_NOT_USE, it sets the jfId/userId value from NO_API_AUTH_FORCE_JF_ID.
func (app *appContext) bogusAuthenticate(gc *gin.Context) {
gc.Set("jfId", NO_API_AUTH_FORCE_JFID)
gc.Set("userId", NO_API_AUTH_FORCE_JFID)
gc.Next()
}
func checkToken(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method %v", token.Header["alg"])
@@ -165,6 +176,31 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool)
return
}
func (app *appContext) canAccessAdminPage(user mediabrowser.User, emailStore EmailAddress) bool {
// 1. "Allow all" is enabled, so simply being a user implies access.
if app.config.Section("ui").Key("allow_all").MustBool(false) && user.ID != "" {
return true
}
// 2. You've been made an "accounts admin" from the accounts tab.
if emailStore.Admin {
return true
}
// 3. (Jellyfin) "Admins only" is enabled, and you're one.
if app.config.Section("ui").Key("admin_only").MustBool(true) && user.ID != "" && user.Policy.IsAdministrator {
return true
}
return false
}
func (app *appContext) canAccessAdminPageByID(jfID string) bool {
user, err := app.jf.UserByID(jfID, false)
if err != nil {
return false
}
emailStore, _ := app.storage.GetEmailsKey(jfID)
return app.canAccessAdminPage(user, emailStore)
}
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context, userpage bool) (user mediabrowser.User, ok bool) {
ok = false
user, err := app.authJf.Authenticate(username, password)
@@ -220,18 +256,12 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
return
}
jfID = user.ID
if !app.config.Section("ui").Key("allow_all").MustBool(false) {
accountsAdmin := false
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
if emailStore, ok := app.storage.GetEmailsKey(jfID); ok {
accountsAdmin = emailStore.Admin
}
accountsAdmin = accountsAdmin || (adminOnly && user.Policy.IsAdministrator)
if !accountsAdmin {
app.authLog(fmt.Sprintf(lm.NonAdminUser, username))
respond(401, "Unauthorized", gc)
return
}
emailStore, _ := app.storage.GetEmailsKey(jfID)
accountsAdmin := app.canAccessAdminPage(user, emailStore)
if !accountsAdmin {
app.authLog(fmt.Sprintf(lm.NonAdminUser, username))
respond(401, "Unauthorized", gc)
return
}
// New users are only added when using jellyfinLogin.
userID = shortuuid.New()
@@ -247,8 +277,7 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
respond(500, "Couldn't generate token", gc)
return
}
// host := gc.Request.URL.Hostname()
host := app.ExternalDomain
host := app.ExternalDomainNoPort(gc)
// Before you think this is broken: the first "true" arg is for "secure", i.e. only HTTPS!
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
@@ -310,7 +339,7 @@ func (app *appContext) getTokenRefresh(gc *gin.Context) {
return
}
// host := gc.Request.URL.Hostname()
host := app.ExternalDomain
host := app.ExternalDomainNoPort(gc)
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
gc.JSON(200, getTokenDTO{jwt})
}

View File

@@ -14,6 +14,7 @@ import (
const (
BACKUP_PREFIX = "jfa-go-db"
BACKUP_PREFIX_OLD = "jfa-go-db-"
BACKUP_COMMIT_PREFIX = "-c-"
BACKUP_DATE_PREFIX = "-d-"
BACKUP_UPLOAD_PREFIX = "upload-"
@@ -33,7 +34,7 @@ func (b Backup) Equals(a Backup) bool {
return a.Date.Equal(b.Date) && a.Commit == b.Commit && a.Upload == b.Upload
}
// Pre 21/03/25 format: "{BACKUP_PREFIX}{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-2006-01-02T15-04-05.bak"
// Pre 21/03/25 format: "{BACKUP_PREFIX_OLD}{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-2006-01-02T15-04-05.bak"
// Post 21/03/25 format: "{BACKUP_PREFIX}-c-{commit}-d-{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-c-0b92060-d-2006-01-02T15-04-05.bak"
func (b Backup) String() string {
@@ -213,7 +214,6 @@ func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) {
count += 1
backupsByCommit[b.Commit] = count
}
fmt.Printf("remaining:%+v\n", backupsByCommit)
}
// fmt.Printf("toDelete: %d, backCount: %d, keep: %d, length: %d\n", toDelete, backups.count, toKeep, len(backups.files))
if toDelete > 0 && toDelete <= backups.count {
@@ -274,8 +274,10 @@ func (app *appContext) loadPendingBackup() {
}
app.info.Printf(lm.MoveOldDB, oldPath)
app.ConnectDB()
defer app.storage.db.Close()
if err := app.storage.Connect(app.config); err != nil {
app.err.Fatalf(lm.FailedConnectDB, app.storage.db_path, err)
}
defer app.storage.Close()
f, err := os.Open(LOADBAK)
if err != nil {

View File

@@ -17,13 +17,13 @@ func testBackupParse(f string, a Backup, t *testing.T) {
}
func TestBackupParserOld(t *testing.T) {
Q1 := BACKUP_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX
Q1 := BACKUP_PREFIX_OLD + "2023-12-21T21-08-00" + BACKUP_SUFFIX
A1 := Backup{}
A1.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
testBackupParse(Q1, A1, t)
}
func TestBackupParserOldUpload(t *testing.T) {
Q2 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX
Q2 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX_OLD + "2023-12-21T21-08-00" + BACKUP_SUFFIX
A2 := Backup{
Upload: true,
}

View File

@@ -11,10 +11,21 @@ import (
"net/http"
"net/url"
"strings"
"time"
lm "github.com/hrfee/jfa-go/logmessages"
)
const (
BogusIdentifier = "123412341234123456"
)
// ContactPreferences holds whether or not a user should be contacted through each of the available
// methods. If nil, leave setting alone.
type ContactPreferences struct {
Email, Discord, Telegram, Matrix *bool
}
// TimeoutHandler recovers from an http timeout or panic.
type TimeoutHandler func()
@@ -155,3 +166,11 @@ func decodeResp(resp *http.Response) (string, error) {
}
return buf.String(), nil
}
// MustAuthenticateOptions is used to control the behaviour of the MustAuthenticate-like methods.
type MustAuthenticateOptions struct {
RetryCount int // Number of Retries before failure.
RetryGap time.Duration // Duration to wait between tries.
LogFailures bool // Whether or not to print failures to the log.
Counter int // The current retry count.
}

View File

@@ -1,13 +1,14 @@
package common
type SectionMeta struct {
Name string `json:"name" yaml:"name" example:"My Section"` // friendly name of the section
Description string `json:"description" yaml:"description"`
Advanced bool `json:"advanced,omitempty" yaml:"advanced,omitempty"`
Disabled bool `json:"disabled,omitempty" yaml:"disabled,omitempty"`
DependsTrue string `json:"depends_true,omitempty" yaml:"depends_true,omitempty"`
DependsFalse string `json:"depends_false,omitempty" yaml:"depends_false,omitempty"`
WikiLink string `json:"wiki_link,omitempty" yaml:"wiki_link,omitempty"`
Name string `json:"name" yaml:"name" example:"My Section"` // friendly name of the section
Description string `json:"description" yaml:"description"`
Advanced bool `json:"advanced,omitempty" yaml:"advanced,omitempty"`
Disabled bool `json:"disabled,omitempty" yaml:"disabled,omitempty"`
DependsTrue string `json:"depends_true,omitempty" yaml:"depends_true,omitempty"`
DependsFalse string `json:"depends_false,omitempty" yaml:"depends_false,omitempty"`
WikiLink string `json:"wiki_link,omitempty" yaml:"wiki_link,omitempty"`
Aliases []string `json:"aliases,omitempty" yaml:"aliases,omitempty"`
}
type Option [2]string
@@ -40,6 +41,7 @@ type Setting struct {
Style string `json:"style,omitempty" yaml:"style,omitempty"`
Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"`
WikiLink string `json:"wiki_link,omitempty" yaml:"wiki_link,omitempty"`
Aliases []string `json:"aliases,omitempty" yaml:"aliases,omitempty"`
}
type Section struct {
@@ -48,8 +50,25 @@ type Section struct {
Settings []Setting `json:"settings" yaml:"settings"`
}
// Member is a member of a group, and can either reference a Section or another Group, hence the two fields.
type Member struct {
Group string `json:"group,omitempty", yaml:"group,omitempty"`
Section string `json:"section,omitempty", yaml:"section,omitempty"`
}
type Group struct {
Group string `json:"group" yaml:"group" example:"messaging_providers"`
Name string `json:"name" yaml:"name" example:"Messaging Providers"`
Description string `json:"description" yaml:"description" example:"Options for setting up messaging providers."`
Members []Member `json:"members" yaml:"members"`
}
type Config struct {
Sections []Section `json:"sections" yaml:"sections"`
Groups []Group `json:"groups" yaml:"groups"`
// Optional order, which can interleave sections and groups.
// If unset, falls back to sections in order, then groups in order.
Order []Member `json:"order,omitempty" yaml:"order,omitempty"`
}
func (c *Config) removeSection(section string) {

432
config.go
View File

@@ -3,6 +3,8 @@ package main
import (
"fmt"
"io/fs"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
@@ -10,23 +12,31 @@ import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/common"
"github.com/hrfee/jfa-go/easyproxy"
lm "github.com/hrfee/jfa-go/logmessages"
"gopkg.in/ini.v1"
)
type Config struct {
*ini.File
proxyTransport *http.Transport
proxyConfig *easyproxy.ProxyConfig
}
var emailEnabled = false
var messagesEnabled = false
var telegramEnabled = false
var discordEnabled = false
var matrixEnabled = false
// URL subpaths. Ignore the "Current" field.
// URL subpaths. Ignore the "Current" field, it's populated when in copies of the struct used for page templating.
// IMPORTANT: When linking straight to a page, rather than appending further to the URL (like accessing an API route), append a /.
var PAGES = PagePaths{}
func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
val := app.config.Section(sect).Key(key).MustString("")
func (config *Config) GetPath(sect, key string) (fs.FS, string) {
val := config.Section(sect).Key(key).MustString("")
if strings.HasPrefix(val, "jfa-go:") {
return localFS, strings.TrimPrefix(val, "jfa-go:")
}
@@ -34,182 +44,289 @@ func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
return os.DirFS(dir), file
}
func (app *appContext) MustSetValue(section, key, val string) {
app.config.Section(section).Key(key).SetValue(app.config.Section(section).Key(key).MustString(val))
func (config *Config) MustSetValue(section, key, val string) {
config.Section(section).Key(key).SetValue(config.Section(section).Key(key).MustString(val))
}
func (app *appContext) MustSetURLPath(section, key, val string) {
func (config *Config) MustSetURLPath(section, key, val string) {
if !strings.HasPrefix(val, "/") && val != "" {
val = "/" + val
}
app.MustSetValue(section, key, val)
config.MustSetValue(section, key, val)
}
func FormatSubpath(path string) string {
func FixFullURL(v string) string {
// Keep relative paths relative
if strings.HasPrefix(v, "/") {
return v
}
if !strings.HasPrefix(v, "http://") && !strings.HasPrefix(v, "https://") {
v = "http://" + v
}
return v
}
func FormatSubpath(path string, removeSingleSlash bool) string {
if path == "/" {
return ""
if removeSingleSlash {
return ""
}
return path
}
return strings.TrimSuffix(path, "/")
}
func (app *appContext) loadConfig() error {
var err error
app.config, err = ini.ShadowLoad(app.configPath)
func (config *Config) MustCorrectURL(section, key, value string) {
v := config.Section(section).Key(key).String()
if v == "" {
v = value
}
v = FixFullURL(v)
config.Section(section).Key(key).SetValue(v)
}
// ExternalDomain returns the Host for the request, using the fixed externalDomain value unless UseProxyHost is true.
func ExternalDomain(gc *gin.Context) string {
if !UseProxyHost || gc.Request.Host == "" {
return externalDomain
}
return gc.Request.Host
}
// ExternalDomainNoPort attempts to return ExternalDomain() with the port removed. If the internally-used method fails, it is assumed the domain has no port anyway.
func (app *appContext) ExternalDomainNoPort(gc *gin.Context) string {
domain := ExternalDomain(gc)
host, _, err := net.SplitHostPort(domain)
if err != nil {
return err
return domain
}
return host
}
// ExternalURI returns the External URI of jfa-go's root directory (by default, where the admin page is), using the fixed externalURI value unless UseProxyHost is true and gc is not nil.
// When nil is passed, externalURI is returned.
func ExternalURI(gc *gin.Context) string {
if gc == nil {
return externalURI
}
var proto string
if gc.Request.TLS != nil || gc.Request.Header.Get("X-Forwarded-Proto") == "https" || gc.Request.Header.Get("X-Forwarded-Protocol") == "https" {
proto = "https://"
} else {
proto = "http://"
}
// app.debug.Printf("Request: %+v\n", gc.Request)
if UseProxyHost && gc.Request.Host != "" {
return proto + gc.Request.Host + PAGES.Base
}
return externalURI
}
func (app *appContext) EvaluateRelativePath(gc *gin.Context, path string) string {
if !strings.HasPrefix(path, "/") {
return path
}
var proto string
if gc.Request.TLS != nil || gc.Request.Header.Get("X-Forwarded-Proto") == "https" || gc.Request.Header.Get("X-Forwarded-Protocol") == "https" {
proto = "https://"
} else {
proto = "http://"
}
return proto + ExternalDomain(gc) + path
}
// NewConfig reads and patches a config file for use. Passed loggers are used only once. Some dependencies can be reloaded after this is called with ReloadDependents(app).
func NewConfig(configPathOrContents any, dataPath string, logs LoggerSet) (*Config, error) {
var err error
config := &Config{}
config.File, err = ini.ShadowLoad(configPathOrContents)
if err != nil {
return config, err
}
// URLs
app.MustSetURLPath("ui", "url_base", "")
app.MustSetURLPath("url_paths", "admin", "")
app.MustSetURLPath("url_paths", "user_page", "/my/account")
app.MustSetURLPath("url_paths", "form", "/invite")
PAGES.Base = FormatSubpath(app.config.Section("ui").Key("url_base").String())
PAGES.Admin = FormatSubpath(app.config.Section("url_paths").Key("admin").String())
PAGES.MyAccount = FormatSubpath(app.config.Section("url_paths").Key("user_page").String())
PAGES.Form = FormatSubpath(app.config.Section("url_paths").Key("form").String())
if !(app.config.Section("user_page").Key("enabled").MustBool(true)) {
config.MustSetURLPath("ui", "url_base", "")
config.MustSetURLPath("url_paths", "admin", "")
config.MustSetURLPath("url_paths", "user_page", "/my/account")
config.MustSetURLPath("url_paths", "form", "/invite")
PAGES.Base = FormatSubpath(config.Section("ui").Key("url_base").String(), true)
PAGES.Admin = FormatSubpath(config.Section("url_paths").Key("admin").String(), true)
PAGES.MyAccount = FormatSubpath(config.Section("url_paths").Key("user_page").String(), true)
PAGES.Form = FormatSubpath(config.Section("url_paths").Key("form").String(), true)
if !(config.Section("user_page").Key("enabled").MustBool(true)) {
PAGES.MyAccount = "disabled"
}
if PAGES.Base == PAGES.Form || PAGES.Base == "/accounts" || PAGES.Base == "/settings" || PAGES.Base == "/activity" {
app.err.Printf(lm.BadURLBase, PAGES.Base)
logs.err.Printf(lm.BadURLBase, PAGES.Base)
}
app.info.Printf(lm.SubpathBlockMessage, PAGES.Base, PAGES.Admin, PAGES.MyAccount, PAGES.Form)
app.MustSetValue("jellyfin", "public_server", app.config.Section("jellyfin").Key("server").String())
app.MustSetValue("ui", "redirect_url", app.config.Section("jellyfin").Key("public_server").String())
logs.info.Printf(lm.SubpathBlockMessage, PAGES.Base, PAGES.Admin, PAGES.MyAccount, PAGES.Form)
for _, key := range app.config.Section("files").Keys() {
config.MustCorrectURL("jellyfin", "server", "")
config.MustCorrectURL("jellyfin", "public_server", config.Section("jellyfin").Key("server").String())
config.MustCorrectURL("ui", "redirect_url", config.Section("jellyfin").Key("public_server").String())
for _, key := range config.Section("files").Keys() {
if name := key.Name(); name != "html_templates" && name != "lang_files" {
key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json"))))
key.SetValue(key.MustString(filepath.Join(dataPath, (key.Name() + ".json"))))
}
}
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users", "announcements", "custom_user_page_content"} {
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json"))))
config.Section("files").Key(key).SetValue(config.Section("files").Key(key).MustString(filepath.Join(dataPath, (key + ".json"))))
}
for _, key := range []string{"matrix_sql"} {
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".db"))))
config.Section("files").Key(key).SetValue(config.Section("files").Key(key).MustString(filepath.Join(dataPath, (key + ".db"))))
}
app.ExternalURI = strings.TrimSuffix(strings.TrimSuffix(app.config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/")
if !strings.HasSuffix(app.ExternalURI, PAGES.Base) {
app.err.Println(lm.NoURLSuffix)
// If true, ExternalDomain() will return one based on the reported Host (ideally reported in "Host" or "X-Forwarded-Host" by the reverse proxy), falling back to externalDomain if not set.
UseProxyHost = config.Section("ui").Key("use_proxy_host").MustBool(false)
externalURI = strings.TrimSuffix(strings.TrimSuffix(config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/")
if !strings.HasSuffix(externalURI, PAGES.Base) {
logs.err.Println(lm.NoURLSuffix)
}
if app.ExternalURI == "" {
app.err.Println(lm.NoExternalHost + lm.LoginWontSave)
if externalURI == "" {
if UseProxyHost {
logs.err.Println(lm.NoExternalHost + lm.LoginWontSave + lm.SetExternalHostDespiteUseProxyHost)
} else {
logs.err.Println(lm.NoExternalHost + lm.LoginWontSave)
}
}
u, err := url.Parse(app.ExternalURI)
u, err := url.Parse(externalURI)
if err == nil {
app.ExternalDomain = u.Hostname()
externalDomain = u.Hostname()
}
app.config.Section("email").Key("no_username").SetValue(strconv.FormatBool(app.config.Section("email").Key("no_username").MustBool(false)))
config.Section("email").Key("no_username").SetValue(strconv.FormatBool(config.Section("email").Key("no_username").MustBool(false)))
app.MustSetValue("password_resets", "email_html", "jfa-go:"+"email.html")
app.MustSetValue("password_resets", "email_text", "jfa-go:"+"email.txt")
// FIXME: Remove all these, eventually
// config.MustSetValue("password_resets", "email_html", "jfa-go:"+"email.html")
// config.MustSetValue("password_resets", "email_text", "jfa-go:"+"email.txt")
app.MustSetValue("invite_emails", "email_html", "jfa-go:"+"invite-email.html")
app.MustSetValue("invite_emails", "email_text", "jfa-go:"+"invite-email.txt")
// config.MustSetValue("invite_emails", "email_html", "jfa-go:"+"invite-email.html")
// config.MustSetValue("invite_emails", "email_text", "jfa-go:"+"invite-email.txt")
app.MustSetValue("email_confirmation", "email_html", "jfa-go:"+"confirmation.html")
app.MustSetValue("email_confirmation", "email_text", "jfa-go:"+"confirmation.txt")
// config.MustSetValue("email_confirmation", "email_html", "jfa-go:"+"confirmation.html")
// config.MustSetValue("email_confirmation", "email_text", "jfa-go:"+"confirmation.txt")
app.MustSetValue("notifications", "expiry_html", "jfa-go:"+"expired.html")
app.MustSetValue("notifications", "expiry_text", "jfa-go:"+"expired.txt")
// config.MustSetValue("notifications", "expiry_html", "jfa-go:"+"expired.html")
// config.MustSetValue("notifications", "expiry_text", "jfa-go:"+"expired.txt")
app.MustSetValue("notifications", "created_html", "jfa-go:"+"created.html")
app.MustSetValue("notifications", "created_text", "jfa-go:"+"created.txt")
// config.MustSetValue("notifications", "created_html", "jfa-go:"+"created.html")
// config.MustSetValue("notifications", "created_text", "jfa-go:"+"created.txt")
app.MustSetValue("deletion", "email_html", "jfa-go:"+"deleted.html")
app.MustSetValue("deletion", "email_text", "jfa-go:"+"deleted.txt")
app.MustSetValue("smtp", "hello_hostname", "localhost")
app.MustSetValue("smtp", "cert_validation", "true")
app.MustSetValue("smtp", "auth_type", "4")
app.MustSetValue("smtp", "port", "465")
app.MustSetValue("activity_log", "keep_n_records", "1000")
app.MustSetValue("activity_log", "delete_after_days", "90")
sc := app.config.Section("discord").Key("start_command").MustString("start")
app.config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
jfUrl := app.config.Section("jellyfin").Key("server").String()
if !(strings.HasPrefix(jfUrl, "http://") || strings.HasPrefix(jfUrl, "https://")) {
app.config.Section("jellyfin").Key("server").SetValue("http://" + jfUrl)
}
// config.MustSetValue("deletion", "email_html", "jfa-go:"+"deleted.html")
// config.MustSetValue("deletion", "email_text", "jfa-go:"+"deleted.txt")
// Deletion template is good enough for these as well.
app.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html")
app.MustSetValue("disable_enable", "disabled_text", "jfa-go:"+"deleted.txt")
app.MustSetValue("disable_enable", "enabled_html", "jfa-go:"+"deleted.html")
app.MustSetValue("disable_enable", "enabled_text", "jfa-go:"+"deleted.txt")
// config.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html")
// config.MustSetValue("disable_enable", "disabled_text", "jfa-go:"+"deleted.txt")
// config.MustSetValue("disable_enable", "enabled_html", "jfa-go:"+"deleted.html")
// config.MustSetValue("disable_enable", "enabled_text", "jfa-go:"+"deleted.txt")
app.MustSetValue("welcome_email", "email_html", "jfa-go:"+"welcome.html")
app.MustSetValue("welcome_email", "email_text", "jfa-go:"+"welcome.txt")
// config.MustSetValue("welcome_email", "email_html", "jfa-go:"+"welcome.html")
// config.MustSetValue("welcome_email", "email_text", "jfa-go:"+"welcome.txt")
app.MustSetValue("template_email", "email_html", "jfa-go:"+"template.html")
app.MustSetValue("template_email", "email_text", "jfa-go:"+"template.txt")
// config.MustSetValue("template_email", "email_html", "jfa-go:"+"template.html")
// config.MustSetValue("template_email", "email_text", "jfa-go:"+"template.txt")
app.MustSetValue("user_expiry", "behaviour", "disable_user")
app.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html")
app.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt")
config.MustSetValue("user_expiry", "behaviour", "disable_user")
// config.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html")
// config.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")
// config.MustSetValue("user_expiry", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html")
// config.MustSetValue("user_expiry", "adjustment_email_text", "jfa-go:"+"expiry-adjusted.txt")
app.MustSetValue("email", "collect", "true")
// config.MustSetValue("user_expiry", "reminder_email_html", "jfa-go:"+"expiry-reminder.html")
// config.MustSetValue("user_expiry", "reminder_email_text", "jfa-go:"+"expiry-reminder.txt")
app.MustSetValue("matrix", "topic", "Jellyfin notifications")
app.MustSetValue("matrix", "show_on_reg", "true")
fnameSettingSuffix := []string{"html", "text"}
fnameExtension := []string{"html", "txt"}
app.MustSetValue("discord", "show_on_reg", "true")
for _, cc := range customContent {
if cc.SourceFile.DefaultValue == "" {
continue
}
for i := range fnameSettingSuffix {
config.MustSetValue(cc.SourceFile.Section, cc.SourceFile.SettingPrefix+fnameSettingSuffix[i], "jfa-go:"+cc.SourceFile.DefaultValue+"."+fnameExtension[i])
}
}
app.MustSetValue("telegram", "show_on_reg", "true")
config.MustSetValue("smtp", "hello_hostname", "localhost")
config.MustSetValue("smtp", "cert_validation", "true")
config.MustSetValue("smtp", "auth_type", "4")
config.MustSetValue("smtp", "port", "465")
app.MustSetValue("backups", "every_n_minutes", "1440")
app.MustSetValue("backups", "path", filepath.Join(app.dataPath, "backups"))
app.MustSetValue("backups", "keep_n_backups", "20")
app.MustSetValue("backups", "keep_previous_version_backup", "true")
config.MustSetValue("activity_log", "keep_n_records", "1000")
config.MustSetValue("activity_log", "delete_after_days", "90")
app.config.Section("jellyfin").Key("version").SetValue(version)
app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
sc := config.Section("discord").Key("start_command").MustString("start")
config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
LOGIP = app.config.Section("advanced").Key("log_ips").MustBool(false)
LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false)
config.MustSetValue("email", "collect", "true")
collect := config.Section("email").Key("collect").MustBool(true)
required := config.Section("email").Key("required").MustBool(false) && collect
config.Section("email").Key("required").SetValue(strconv.FormatBool(required))
unique := config.Section("email").Key("require_unique").MustBool(false) && collect
config.Section("email").Key("require_unique").SetValue(strconv.FormatBool(unique))
app.MustSetValue("advanced", "auth_retry_count", "6")
app.MustSetValue("advanced", "auth_retry_gap", "10")
config.MustSetValue("matrix", "topic", "Jellyfin notifications")
config.MustSetValue("matrix", "show_on_reg", "true")
app.MustSetValue("ui", "port", "8056")
app.MustSetValue("advanced", "tls_port", "8057")
config.MustSetValue("discord", "show_on_reg", "true")
app.MustSetValue("advanced", "value_log_size", "512")
config.MustSetValue("telegram", "show_on_reg", "true")
config.MustSetValue("backups", "every_n_minutes", "1440")
config.MustSetValue("backups", "path", filepath.Join(dataPath, "backups"))
config.MustSetValue("backups", "keep_n_backups", "20")
config.MustSetValue("backups", "keep_previous_version_backup", "true")
config.Section("jellyfin").Key("version").SetValue(version)
config.Section("jellyfin").Key("device").SetValue("jfa-go")
config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
config.MustSetValue("jellyfin", "cache_timeout", "30")
config.MustSetValue("jellyfin", "web_cache_async_timeout", "1")
config.MustSetValue("jellyfin", "web_cache_sync_timeout", "10")
LOGIP = config.Section("advanced").Key("log_ips").MustBool(false)
LOGIPU = config.Section("advanced").Key("log_ips_users").MustBool(false)
config.MustSetValue("advanced", "auth_retry_count", "6")
config.MustSetValue("advanced", "auth_retry_gap", "10")
config.MustSetValue("ui", "port", "8056")
config.MustSetValue("advanced", "tls_port", "8057")
config.MustSetValue("advanced", "value_log_size", "512")
pwrMethods := []string{"allow_pwr_username", "allow_pwr_email", "allow_pwr_contact_method"}
allDisabled := true
for _, v := range pwrMethods {
if app.config.Section("user_page").Key(v).MustBool(true) {
if config.Section("user_page").Key(v).MustBool(true) {
allDisabled = false
}
}
if allDisabled {
app.info.Println(lm.EnableAllPWRMethods)
logs.info.Println(lm.EnableAllPWRMethods)
for _, v := range pwrMethods {
app.config.Section("user_page").Key(v).SetValue("true")
config.Section("user_page").Key(v).SetValue("true")
}
}
messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false)
telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false)
discordEnabled = app.config.Section("discord").Key("enabled").MustBool(false)
matrixEnabled = app.config.Section("matrix").Key("enabled").MustBool(false)
messagesEnabled = config.Section("messages").Key("enabled").MustBool(false)
telegramEnabled = config.Section("telegram").Key("enabled").MustBool(false)
discordEnabled = config.Section("discord").Key("enabled").MustBool(false)
matrixEnabled = config.Section("matrix").Key("enabled").MustBool(false)
if !messagesEnabled {
emailEnabled = false
telegramEnabled = false
discordEnabled = false
matrixEnabled = false
} else if app.config.Section("email").Key("method").MustString("") == "" {
} else if config.Section("email").Key("method").MustString("") == "" {
emailEnabled = false
} else {
emailEnabled = true
@@ -218,31 +335,64 @@ func (app *appContext) loadConfig() error {
messagesEnabled = false
}
if app.proxyEnabled = app.config.Section("advanced").Key("proxy").MustBool(false); app.proxyEnabled {
app.proxyConfig = easyproxy.ProxyConfig{}
app.proxyConfig.Protocol = easyproxy.HTTP
if strings.Contains(app.config.Section("advanced").Key("proxy_protocol").MustString("http"), "socks") {
app.proxyConfig.Protocol = easyproxy.SOCKS5
if proxyEnabled := config.Section("advanced").Key("proxy").MustBool(false); proxyEnabled {
config.proxyConfig = &easyproxy.ProxyConfig{}
config.proxyConfig.Protocol = easyproxy.HTTP
if strings.Contains(config.Section("advanced").Key("proxy_protocol").MustString("http"), "socks") {
config.proxyConfig.Protocol = easyproxy.SOCKS5
}
app.proxyConfig.Addr = app.config.Section("advanced").Key("proxy_address").MustString("")
app.proxyConfig.User = app.config.Section("advanced").Key("proxy_user").MustString("")
app.proxyConfig.Password = app.config.Section("advanced").Key("proxy_password").MustString("")
app.proxyTransport, err = easyproxy.NewTransport(app.proxyConfig)
config.proxyConfig.Addr = config.Section("advanced").Key("proxy_address").MustString("")
config.proxyConfig.User = config.Section("advanced").Key("proxy_user").MustString("")
config.proxyConfig.Password = config.Section("advanced").Key("proxy_password").MustString("")
config.proxyTransport, err = easyproxy.NewTransport(*(config.proxyConfig))
if err != nil {
app.err.Printf(lm.FailedInitProxy, app.proxyConfig.Addr, err)
logs.err.Printf(lm.FailedInitProxy, config.proxyConfig.Addr, err)
// As explained in lm.FailedInitProxy, sleep here might grab the admin's attention,
// Since we don't crash on this failing.
time.Sleep(15 * time.Second)
app.proxyEnabled = false
config.proxyConfig = nil
config.proxyTransport = nil
} else {
app.proxyEnabled = true
app.info.Printf(lm.InitProxy, app.proxyConfig.Addr)
logs.info.Printf(lm.InitProxy, config.proxyConfig.Addr)
}
}
app.MustSetValue("updates", "enabled", "true")
releaseChannel := app.config.Section("updates").Key("channel").String()
if app.config.Section("updates").Key("enabled").MustBool(false) {
config.MustSetValue("updates", "enabled", "true")
substituteStrings = config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("")
if substituteStrings != "" {
v := config.Section("ui").Key("success_message")
v.SetValue(strings.ReplaceAll(v.String(), "Jellyfin", substituteStrings))
}
datePattern = config.Section("messages").Key("date_format").String()
timePattern = `%H:%M`
if !(config.Section("messages").Key("use_24h").MustBool(true)) {
timePattern = `%I:%M %p`
}
return config, nil
}
// ReloadDependents re-initialises or applies changes to components of the app which can be reconfigured without restarting.
func (config *Config) ReloadDependents(app *appContext) {
oldFormLang := config.Section("ui").Key("language").MustString("")
if oldFormLang != "" {
app.storage.lang.chosenUserLang = oldFormLang
}
newFormLang := config.Section("ui").Key("language-form").MustString("")
if newFormLang != "" {
app.storage.lang.chosenUserLang = newFormLang
}
app.storage.lang.chosenAdminLang = config.Section("ui").Key("language-admin").MustString("en-us")
app.storage.lang.chosenEmailLang = config.Section("email").Key("language").MustString("en-us")
app.storage.lang.chosenPWRLang = config.Section("password_resets").Key("language").MustString("en-us")
app.storage.lang.chosenTelegramLang = config.Section("telegram").Key("language").MustString("en-us")
releaseChannel := config.Section("updates").Key("channel").String()
if config.Section("updates").Key("enabled").MustBool(false) {
v := version
if releaseChannel == "stable" {
if version == "git" {
@@ -251,9 +401,9 @@ func (app *appContext) loadConfig() error {
} else if releaseChannel == "unstable" {
v = "git"
}
app.updater = newUpdater(baseURL, namespace, repo, v, commit, updater)
if app.proxyEnabled {
app.updater.SetTransport(app.proxyTransport)
app.updater = NewUpdater(baseURL, namespace, repo, v, commit, updater)
if config.proxyTransport != nil {
app.updater.SetTransport(config.proxyTransport)
}
}
if releaseChannel == "" {
@@ -262,32 +412,22 @@ func (app *appContext) loadConfig() error {
} else {
releaseChannel = "stable"
}
app.MustSetValue("updates", "channel", releaseChannel)
config.MustSetValue("updates", "channel", releaseChannel)
}
substituteStrings = app.config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("")
app.email = NewEmailer(config, app.storage, app.LoggerSet)
if substituteStrings != "" {
v := app.config.Section("ui").Key("success_message")
v.SetValue(strings.ReplaceAll(v.String(), "Jellyfin", substituteStrings))
}
func (app *appContext) ReloadConfig() {
var err error = nil
app.config, err = NewConfig(app.configPath, app.dataPath, app.LoggerSet)
if err != nil {
app.err.Fatalf(lm.FailedLoadConfig, app.configPath, err)
}
oldFormLang := app.config.Section("ui").Key("language").MustString("")
if oldFormLang != "" {
app.storage.lang.chosenUserLang = oldFormLang
}
newFormLang := app.config.Section("ui").Key("language-form").MustString("")
if newFormLang != "" {
app.storage.lang.chosenUserLang = newFormLang
}
app.storage.lang.chosenAdminLang = app.config.Section("ui").Key("language-admin").MustString("en-us")
app.storage.lang.chosenEmailLang = app.config.Section("email").Key("language").MustString("en-us")
app.storage.lang.chosenPWRLang = app.config.Section("password_resets").Key("language").MustString("en-us")
app.storage.lang.chosenTelegramLang = app.config.Section("telegram").Key("language").MustString("en-us")
app.email = NewEmailer(app)
return nil
app.config.ReloadDependents(app)
app.info.Printf(lm.LoadConfig, app.configPath)
}
func (app *appContext) PatchConfigBase() {

View File

@@ -1,3 +1,60 @@
order:
- section: ui
- section: advanced
- section: jellyfin
- group: sign_up
- group: accounts
- section: messages
- group: external_services
- section: activity_log
- section: backups
- section: updates
- section: url_paths
- section: template_email
- section: files
groups:
- group: external_services
name: "Integrations"
description: "Integrations with external services."
members:
- group: email
- group: chatbots
- section: ombi
- section: jellyseerr
- section: webhooks
- group: email
name: "Email"
description: "Options for sending emails through jfa-go."
members:
- section: email
- section: smtp
- section: mailgun
- section: email_confirmation
- group: chatbots
name: "Chatbots"
description: "Options for messaging through chat services."
members:
- section: discord
- section: telegram
- section: matrix
- group: sign_up
name: "Invites & Referrals"
description: "Settings relating to invites, the sign up page and referrals."
members:
- section: captcha
- section: password_validation
- section: invite_emails
- section: notifications
- section: welcome_email
- group: accounts
name: "Accounts"
description: "Settings relating to account management."
members:
- section: user_page
- section: password_resets
- section: user_expiry
- section: disable_enable
- section: deletion
sections:
- section: updates
meta:
@@ -65,6 +122,20 @@ sections:
type: number
value: 30
description: Timeout of user cache in minutes. Set to 0 to disable.
- setting: web_cache_async_timeout
name: User search cache asynchronous timeout (minutes)
requires_restart: true
advanced: true
type: number
value: 1
description: "Synchronise after cache is this old, but don't wait for it: The accounts tab will load quickly but show old results until the next request."
- setting: web_cache_sync_timeout
name: User search cache synchronous timeout (minutes)
requires_restart: true
advanced: true
type: number
value: 10
description: "Synchronise after cache is this old, and wait for it: The accounts tab may take a little longer to load while it does."
- setting: type
name: Server type
requires_restart: true
@@ -201,12 +272,15 @@ sections:
- setting: jfa_url
name: External jfa-go URL
required: true
depends_true: enabled
type: text
value: http://accounts.jellyf.in:8056
description: The URL at which the jfa-go root (usually the admin page) is accessible, including
the subfolder if you use one. This is necessary because using a reverse proxy
means the program has no way of knowing the URL itself.
the subfolder if you use one. While your reverse proxy should report this anyway, server-side actions like sending invite messages don't receive such wisdom.
- setting: use_proxy_host
name: Use reverse-proxy reported "Host" when possible
type: bool
value: false
description: If enabled, the "Host" reported by your reverse proxy will be used in the web app, rather than the "External jfa-go URL" value. Useful if you regularly access jfa-go from more than one host/domain. Also, make sure your proxy passes X-Forwarded-Proto/X-Forwarded-Protocol.
- setting: url_base
name: Reverse Proxy subfolder
requires_restart: true
@@ -499,7 +573,7 @@ sections:
meta:
name: Captcha
description: Settings related to user creation CAPTCHAs.
wiki_link: https://wiki.jfa-go.com/docs/captcha/
wiki_link: https://wiki.jfa-go.com/docs/external-services/captcha/
settings:
- setting: enabled
name: Enabled
@@ -653,7 +727,7 @@ sections:
meta:
name: Messages/Notifications
description: General settings for emails/messages.
wiki_link: https://wiki.jfa-go.com/docs/emails/
wiki_link: https://wiki.jfa-go.com/docs/customization/emails/
settings:
- setting: enabled
name: Enabled
@@ -702,9 +776,27 @@ sections:
- ["en-us", "English (US)"]
value: en-us
description: Default email language. Submit a PR on github if you'd like to translate.
- setting: collect
name: Collect on sign-up
type: bool
value: true
description: Ask for an email address on the sign-up form.
- setting: required
name: Require on sign-up
depends_true: collect
type: bool
value: false
description: Require an email address on sign-up.
- setting: require_unique
name: Require unique address
requires_restart: true
depends_true: method
type: bool
value: false
description: Disables using the same address on multiple accounts.
- setting: no_username
name: Use email addresses as username
depends_true: method
depends_true: collect
type: bool
value: false
description: Use email address from invite form as username on Jellyfin.
@@ -716,6 +808,7 @@ sections:
- ["smtp", "SMTP"]
- ["mailgun", "Mailgun"]
value: smtp
depends_true: messages|enabled
description: Method of sending email to use.
- setting: address
name: Sent from (address)
@@ -736,25 +829,6 @@ sections:
type: bool
value: false
description: Send emails as plain text instead of HTML.
- setting: collect
name: Collect on sign-up
depends_true: method
type: bool
value: true
description: Ask for an email address on the sign-up form.
- setting: required
name: Require on sign-up
depends_true: collect
type: bool
value: false
description: Require an email address on sign-up.
- setting: require_unique
name: Require unique address
requires_restart: true
depends_true: method
type: bool
value: false
description: Disables using the same address on multiple accounts.
- setting: test_note
name: 'Test your settings:'
type: note
@@ -763,7 +837,7 @@ sections:
description: Go over to the accounts tab, select your user (ensuring you've assigned it an email address) and send yourself an announcement.
- section: mailgun
meta:
name: Mailgun (Email)
name: Mailgun
description: Mailgun API connection settings
depends_true: email|method
settings:
@@ -777,7 +851,7 @@ sections:
value: your api key
- section: smtp
meta:
name: SMTP (Email)
name: SMTP
description: SMTP Server connection settings.
depends_true: email|method
settings:
@@ -791,6 +865,7 @@ sections:
options:
- ["ssl_tls", "SSL/TLS"]
- ["starttls", "STARTTLS"]
- ["none", "None (only use locally!)"]
value: starttls
description: Your email provider should provide different ports for each encryption
method. Generally 465 for ssl_tls, 587 for starttls.
@@ -842,7 +917,7 @@ sections:
meta:
name: Discord
description: Settings for Discord invites/signup/notifications
wiki_link: https://wiki.jfa-go.com/docs/bots/discord/
wiki_link: https://wiki.jfa-go.com/docs/external-services/bots/discord/
settings:
- setting: enabled
name: Enabled
@@ -906,7 +981,7 @@ sections:
requires_restart: true
depends_true: provide_invite
type: text
description: Channel to invite new users to.
description: Name of channel to invite new users to.
- setting: apply_role
name: Apply Role on connection
requires_restart: true
@@ -937,7 +1012,7 @@ sections:
name: Telegram
description: Settings for Telegram signup/notifications. See the jfa-go wiki for
info on setting this up.
wiki_link: https://wiki.jfa-go.com/docs/bots/telegram/
wiki_link: https://wiki.jfa-go.com/docs/external-services/bots/telegram/
settings:
- setting: enabled
name: Enabled
@@ -986,7 +1061,7 @@ sections:
name: Matrix
description: Settings for Matrix invites/signup/notifications. See the jfa-go
wiki for info on setting this up.
wiki_link: https://wiki.jfa-go.com/docs/bots/matrix/
wiki_link: https://wiki.jfa-go.com/docs/external-services/bots/matrix/
settings:
- setting: enabled
name: Enabled
@@ -1123,7 +1198,7 @@ sections:
type: note
depends_true: link_reset
required: false
description: Set the "External jfa-go URL" in General so that links to jfa-go can be made.
description: Set the "External jfa-go URL" value in General so that links to jfa-go can be made.
- setting: language
name: Default reset link language
requires_restart: true
@@ -1152,9 +1227,9 @@ sections:
description: Subject of password reset emails.
- section: invite_emails
meta:
name: Invite emails
name: Invite Messages
description: Settings for sending invites directly to users.
depends_true: email|method
depends_true: messages|enabled
settings:
- setting: enabled
name: Enabled
@@ -1218,7 +1293,7 @@ sections:
description: Path to custom email text template for announcements/custom messages.
- section: notifications
meta:
name: Admin invite notifications
name: Admin notifications
description: Allows toggling "user created" and "invite expired" notifications
to be sent to the admin per-invite.
depends_true: messages|enabled
@@ -1258,13 +1333,13 @@ sections:
description: Path to user creation notification email in plaintext.
- section: ombi
meta:
name: Ombi Integration
name: Ombi
description: Connect to Ombi to automatically create both Ombi and Jellyfin accounts
for new users. You'll need to add a ombi template to an existing User Profile
for accounts to be created, which you can do by refreshing then checking Settings
> User Profiles. To handle password resets for Ombi & Jellyfin, enable "Use
reset link instead of PIN".
wiki_link: https://wiki.jfa-go.com/docs/ombi/
wiki_link: https://wiki.jfa-go.com/docs/external-services/ombi/
settings:
- setting: enabled
name: Enabled
@@ -1287,11 +1362,16 @@ sections:
description: API Key. Get this from the first tab in Ombi settings.
- section: jellyseerr
meta:
name: Jellyseerr Integration
name: Jellyseerr
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.
wiki_link: https://wiki.jfa-go.com/docs/external-services/jellyseerr/
aliases:
- Jellyseerr
- Overseerr
- Seerr
settings:
- setting: enabled
name: Enabled
@@ -1318,7 +1398,7 @@ sections:
requires_restart: true
type: text
depends_true: enabled
description: API Key. Get this from the first tab in Jellyseerr's settings.
description: API Key. Get this from the first tab in Jellyseerr's settings (NOT the "Jellyfin" tab!)
- setting: import_existing
name: Import existing users to Jellyseerr
requires_restart: true
@@ -1327,6 +1407,7 @@ sections:
depends_true: enabled
description: Existing users (and those created outside jfa-go) will have their
contact info imported to Jellyseerr.
deprecated: true
- setting: constraints_note
name: 'Unique Emails:'
type: note
@@ -1406,7 +1487,7 @@ sections:
name: Email confirmation
description: If enabled, a user will be sent an email confirmation link to ensure
their password is right before they can make an account.
depends_true: email|method
depends_true: email|collect
settings:
- setting: enabled
name: Enabled
@@ -1429,7 +1510,7 @@ sections:
description: Path to custom email in plain text
- section: user_expiry
meta:
name: User Expiry
name: Account Expiry
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.
@@ -1450,11 +1531,15 @@ sections:
description: When set, user accounts will be deleted this many days after expiring
(if "Behaviour" is "Disable user"). Set to 0 to disable.
- setting: send_email
name: Send email
name: Send message
type: bool
value: true
depends_true: messages|enabled
description: Send an email when a user's account expires.
- setting: send_reminder_n_days_before
name: Send message N days before expiry
type: list
description: Send users a message N days before their account is due to expire. Multiple can be set.
- setting: subject
name: Email subject
depends_true: messages|enabled
@@ -1490,6 +1575,23 @@ sections:
depends_true: messages|enabled
type: text
description: Path to custom email in plain text
- setting: reminder_subject
name: 'Reminder: email subject'
depends_true: messages|enabled
type: text
description: Subject of expiry reminder emails.
- setting: reminder_email_html
name: 'Reminder: Custom email (HTML)'
advanced: true
depends_true: messages|enabled
type: text
description: Path to custom email html
- setting: reminder_email_text
name: 'Reminder: Custom email (plaintext)'
advanced: true
depends_true: messages|enabled
type: text
description: Path to custom email in plain text
- section: disable_enable
meta:
name: Account Disabling/Enabling
@@ -1550,7 +1652,7 @@ sections:
description: jfa-go will send a POST request to these URLs when an event occurs,
with relevant information. Request information is logged when debug logging
is enabled.
wiki_link: https://wiki.jfa-go.com/docs/webhooks/
wiki_link: https://wiki.jfa-go.com/docs/dev/webhooks/
settings:
- setting: created
name: User Created
@@ -1568,30 +1670,36 @@ sections:
requires_restart: true
type: text
description: Location of stored invites (json).
deprecated: true
- setting: password_resets
name: Password Resets
requires_restart: true
type: text
description: Location of stored non-Jellyfin password resets (json).
deprecated: true
- setting: emails
name: Email Addresses
requires_restart: true
type: text
description: Location of stored email addresses (json).
deprecated: true
- setting: users
name: User storage
type: text
description: Stores users temporarily when a user expiry is set.
deprecated: true
- setting: ombi_template
name: Ombi user template
type: text
description: Location of stored Ombi user template.
deprecated: true
- setting: user_profiles
name: User Profiles
requires_restart: true
type: text
description: Location of stored user profiles (encompasses template and configuration
and displayprefs) (json)
deprecated: true
- setting: html_templates
name: Custom HTML Template Directory
requires_restart: true
@@ -1609,19 +1717,23 @@ sections:
type: text
description: JSON file generated by program in settings, different from email_html/email_text.
See wiki for more info.
deprecated: true
- setting: custom_user_page_content
name: Custom user page content
type: text
description: JSON file generated by program in settings, containing user page
messages. See wiki for more info.
deprecated: true
- setting: telegram_users
name: Telegram users
type: text
description: Stores telegram user IDs and language preferences.
deprecated: true
- setting: matrix_users
name: Matrix users
type: text
description: Stores matrix user IDs and language preferences.
deprecated: true
- setting: matrix_sql
name: Matrix encryption DB
type: text
@@ -1630,7 +1742,9 @@ sections:
name: Discord users
type: text
description: Stores discord user IDs and language preferences.
deprecated: true
- setting: announcements
name: Announcement templates
type: text
description: Stores custom announcement templates.
deprecated: true

View File

@@ -221,15 +221,8 @@ sup.\~critical, .text-critical {
padding-bottom: 0.1rem;
}
.settings-section-button {
width: 100%;
height: 2.5rem;
}
.settings-section-button:hover, .settings-section-button:focus {
box-sizing: border-box;
width: 100%;
height: 2.5rem;
background-color: var(--color-neutral-normal-fill);
filter: brightness(var(--settings-section-button-filter)) !important;
}
@@ -242,7 +235,7 @@ sup.\~critical, .text-critical {
margin-bottom: 0.25rem;
}
.textarea {
.textarea:not(code-input *) {
resize: vertical;
}
@@ -254,7 +247,7 @@ sup.\~critical, .text-critical {
overflow-y: visible;
}
select, textarea {
select, textarea:not(code-input *) {
color: inherit;
border: 0 solid var(--color-neutral-300);
appearance: none;
@@ -262,7 +255,7 @@ select, textarea {
-moz-appearance: none;
}
html.dark textarea {
html.dark textarea:not(code-input *) {
background-color: #202020
}
@@ -320,7 +313,7 @@ p.top {
bottom: 115%;
}
pre {
pre:not(code-input *) {
white-space: pre-wrap; /* css-3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */
@@ -466,7 +459,23 @@ section.section:not(.\~neutral) {
@layer components {
.switch {
@apply flex flex-row gap-1 items-center;
@apply flex flex-row gap-2 items-center;
}
}
:root {
/* seems to be the sweet spot */
--inside-input-base: -2.6rem;
/* thought --spacing would do the trick but apparently not */
--tailwind-spacing: 0.25rem;
}
/* places buttons inside a sibling input element (hopefully), based on the flex gap of the parent. */
.gap-1 > .button.inside-input {
margin-left: calc(var(--inside-input-base) - 1.0*var(--tailwind-spacing));
}
.gap-2 > .button.inside-input {
margin-left: calc(var(--inside-input-base) - 2.0*var(--tailwind-spacing));
}

18
css/colors.js Normal file
View File

@@ -0,0 +1,18 @@
const colors = require("tailwindcss/colors");
const dark = require("../css/dark");
export const colorSet = {
neutral: colors.slate,
positive: colors.green,
urge: colors.violet,
warning: colors.yellow,
info: colors.blue,
critical: colors.red,
d_neutral: dark.d_neutral,
d_positive: dark.d_positive,
d_urge: dark.d_urge,
d_warning: dark.d_warning,
d_info: dark.d_info,
d_critical: dark.d_critical,
discord: "#5865F2"
};

View File

@@ -6,7 +6,7 @@
.tooltip .content {
visibility: hidden;
opacity: 0;
max-width: 10rem;
max-width: 16rem;
min-width: 6rem;
background-color: rgba(0, 0, 0, 0.6);
color: #fff;
@@ -22,9 +22,18 @@
}
.tooltip.below .content {
top: 2.5rem;
left: 0;
top: calc(100% + 0.125rem);
left: 50%;
right: 0;
transform: translateX(-50%);
}
.tooltip.above .content {
top: unset;
bottom: calc(100% + 0.125rem);
left: 50%;
right: 0;
transform: translateX(-50%);
}
.tooltip.darker .content {

403
customcontent.go Normal file
View File

@@ -0,0 +1,403 @@
package main
import (
"fmt"
"maps"
"slices"
)
func defaultVars(vars ...string) []string {
return slices.Concat(vars, []string{
"username",
})
}
func defaultVals(vals map[string]any) map[string]any {
maps.Copy(vals, map[string]any{
"username": "Username",
})
return vals
}
func vendorHeader(config *Config, lang *emailLang) string { return "jfa-go" }
func serverHeader(config *Config, lang *emailLang) string {
if substituteStrings == "" {
return "Jellyfin"
} else {
return substituteStrings
}
}
func messageFooter(config *Config, lang *emailLang) string {
return config.Section("messages").Key("message").String()
}
var customContent = map[string]CustomContentInfo{
"EmailConfirmation": {
Name: "EmailConfirmation",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].EmailConfirmation["name"] },
Subject: func(config *Config, lang *emailLang) string {
return config.Section("email_confirmation").Key("subject").MustString(lang.EmailConfirmation.get("title"))
},
Variables: defaultVars(
"confirmationURL",
),
Placeholders: defaultVals(map[string]any{
"confirmationURL": "https://sub2.test.url/invite/xxxxxx?key=xxxxxx",
}),
SourceFile: ContentSourceFileInfo{
Section: "email_confirmation",
SettingPrefix: "email_",
DefaultValue: "confirmation",
},
},
"ExpiryReminder": {
Name: "ExpiryReminder",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].ExpiryReminder["name"] },
Subject: func(config *Config, lang *emailLang) string {
return config.Section("user_expiry").Key("reminder_subject").MustString(lang.ExpiryReminder.get("title"))
},
Variables: defaultVars(
"expiresIn",
"date",
"time",
),
Placeholders: defaultVals(map[string]any{
"expiresIn": "3d 4h 32m",
"date": "20/08/25",
"time": "14:19",
}),
SourceFile: ContentSourceFileInfo{
Section: "user_expiry",
SettingPrefix: "reminder_email_",
DefaultValue: "expiry-reminder",
},
},
"InviteEmail": {
Name: "InviteEmail",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].InviteEmail["name"] },
Subject: func(config *Config, lang *emailLang) string {
return config.Section("invite_emails").Key("subject").MustString(lang.InviteEmail.get("title"))
},
Variables: []string{
"date",
"time",
"expiresInMinutes",
"inviteURL",
},
Placeholders: defaultVals(map[string]any{
"date": "01/01/01",
"time": "00:00",
"expiresInMinutes": "16d 13h 19m",
"inviteURL": "https://sub2.test.url/invite/xxxxxx",
}),
SourceFile: ContentSourceFileInfo{
Section: "invite_emails",
SettingPrefix: "email_",
DefaultValue: "invite-email",
},
},
"InviteExpiry": {
Name: "InviteExpiry",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].InviteExpiry["name"] },
Subject: func(config *Config, lang *emailLang) string {
return lang.InviteExpiry.get("title")
},
HeaderText: vendorHeader,
FooterText: func(config *Config, lang *emailLang) string {
return lang.InviteExpiry.get("notificationNotice")
},
Variables: []string{
"code",
"time",
},
Placeholders: map[string]any{
"code": "\"xxxxxx\"",
"time": "01/01/01 00:00",
},
SourceFile: ContentSourceFileInfo{
Section: "notifications",
SettingPrefix: "expiry_",
DefaultValue: "expired",
},
},
"PasswordReset": {
Name: "PasswordReset",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].PasswordReset["name"] },
Subject: func(config *Config, lang *emailLang) string {
return config.Section("password_resets").Key("subject").MustString(lang.PasswordReset.get("title"))
},
Variables: defaultVars(
"date",
"time",
"expiresInMinutes",
"pin",
),
Placeholders: defaultVals(map[string]any{
"date": "01/01/01",
"time": "00:00",
"expiresInMinutes": "16d 13h 19m",
"pin": "12-34-56",
}),
SourceFile: ContentSourceFileInfo{
Section: "password_resets",
SettingPrefix: "email_",
// This was the first email type added, hence the undescriptive filename.
DefaultValue: "password-reset",
},
},
"UserCreated": {
Name: "UserCreated",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserCreated["name"] },
Subject: func(config *Config, lang *emailLang) string {
return lang.UserCreated.get("title")
},
HeaderText: vendorHeader,
FooterText: func(config *Config, lang *emailLang) string {
return lang.UserCreated.get("notificationNotice")
},
Variables: []string{
"code",
"name",
"address",
"time",
},
Placeholders: map[string]any{
"name": "Subject Username",
"code": "\"xxxxxx\"",
"address": "Email Address",
"time": "01/01/01 00:00",
},
SourceFile: ContentSourceFileInfo{
Section: "notifications",
SettingPrefix: "created_",
DefaultValue: "created",
},
},
"UserDeleted": {
Name: "UserDeleted",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserDeleted["name"] },
Subject: func(config *Config, lang *emailLang) string {
return config.Section("deletion").Key("subject").MustString(lang.UserDeleted.get("title"))
},
Variables: defaultVars(
"reason",
),
Placeholders: defaultVals(map[string]any{
"reason": "Reason",
}),
SourceFile: ContentSourceFileInfo{
Section: "deletion",
SettingPrefix: "email_",
DefaultValue: "deleted",
},
},
"UserDisabled": {
Name: "UserDisabled",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserDisabled["name"] },
Subject: func(config *Config, lang *emailLang) string {
return config.Section("disable_enable").Key("subject_disabled").MustString(lang.UserDisabled.get("title"))
},
Variables: defaultVars(
"reason",
),
Placeholders: defaultVals(map[string]any{
"reason": "Reason",
}),
SourceFile: ContentSourceFileInfo{
Section: "disable_enable",
SettingPrefix: "disabled_",
// Template is shared between deletion enabling and disabling.
DefaultValue: "deleted",
},
},
"UserEnabled": {
Name: "UserEnabled",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserEnabled["name"] },
Subject: func(config *Config, lang *emailLang) string {
return config.Section("disable_enable").Key("subject_enabled").MustString(lang.UserEnabled.get("title"))
},
Variables: defaultVars(
"reason",
),
Placeholders: defaultVals(map[string]any{
"reason": "Reason",
}),
SourceFile: ContentSourceFileInfo{
Section: "disable_enable",
SettingPrefix: "enabled_",
// Template is shared between deletion enabling and disabling.
DefaultValue: "deleted",
},
},
"UserExpired": {
Name: "UserExpired",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserExpired["name"] },
Subject: func(config *Config, lang *emailLang) string {
return config.Section("user_expiry").Key("subject").MustString(lang.UserExpired.get("title"))
},
Variables: defaultVars(),
Placeholders: defaultVals(map[string]any{}),
SourceFile: ContentSourceFileInfo{
Section: "user_expiry",
SettingPrefix: "email_",
DefaultValue: "user-expired",
},
},
"UserExpiryAdjusted": {
Name: "UserExpiryAdjusted",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserExpiryAdjusted["name"] },
Subject: func(config *Config, lang *emailLang) string {
return config.Section("user_expiry").Key("adjustment_subject").MustString(lang.UserExpiryAdjusted.get("title"))
},
Variables: defaultVars(
"newExpiry",
"reason",
),
Placeholders: defaultVals(map[string]any{
"newExpiry": "01/01/01 00:00",
"reason": "Reason",
}),
SourceFile: ContentSourceFileInfo{
Section: "user_expiry",
SettingPrefix: "adjustment_email_",
DefaultValue: "expiry-adjusted",
},
},
"WelcomeEmail": {
Name: "WelcomeEmail",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].WelcomeEmail["name"] },
Subject: func(config *Config, lang *emailLang) string {
return config.Section("welcome_email").Key("subject").MustString(lang.WelcomeEmail.get("title"))
},
Variables: defaultVars(
"jellyfinURL",
"yourAccountWillExpire",
),
Conditionals: []string{
"yourAccountWillExpire",
},
Placeholders: defaultVals(map[string]any{
"jellyfinURL": "https://example.io",
"yourAccountWillExpire": "17/08/25 14:19",
}),
SourceFile: ContentSourceFileInfo{
Section: "welcome_email",
SettingPrefix: "email_",
DefaultValue: "welcome",
},
},
"TemplateEmail": {
Name: "TemplateEmail",
DisplayName: func(dict *Lang, lang string) string {
return "EmptyCustomContent"
},
ContentType: CustomTemplate,
SourceFile: ContentSourceFileInfo{
Section: "template_email",
SettingPrefix: "email_",
DefaultValue: "template",
},
},
"UserLogin": {
Name: "UserLogin",
ContentType: CustomCard,
DisplayName: func(dict *Lang, lang string) string {
if _, ok := dict.Admin[lang]; !ok {
lang = dict.chosenAdminLang
}
return dict.Admin[lang].Strings["userPageLogin"]
},
Variables: []string{},
},
"UserPage": {
Name: "UserPage",
ContentType: CustomCard,
DisplayName: func(dict *Lang, lang string) string {
if _, ok := dict.Admin[lang]; !ok {
lang = dict.chosenAdminLang
}
return dict.Admin[lang].Strings["userPagePage"]
},
Variables: defaultVars(),
Placeholders: defaultVals(map[string]any{}),
},
"PostSignupCard": {
Name: "PostSignupCard",
ContentType: CustomCard,
DisplayName: func(dict *Lang, lang string) string {
if _, ok := dict.Admin[lang]; !ok {
lang = dict.chosenAdminLang
}
return dict.Admin[lang].Strings["postSignupCard"]
},
Description: func(dict *Lang, lang string) string {
if _, ok := dict.Admin[lang]; !ok {
lang = dict.chosenAdminLang
}
return dict.Admin[lang].Strings["postSignupCardDescription"]
},
Variables: defaultVars(
"myAccountURL",
),
Placeholders: defaultVals(map[string]any{
"myAccountURL": "https://sub2.test.url/my/account",
}),
},
}
var EmptyCustomContent = CustomContentInfo{
Name: "EmptyCustomContent",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string {
return "EmptyCustomContent"
},
Subject: func(config *Config, lang *emailLang) string {
return "EmptyCustomContent"
},
HeaderText: serverHeader,
FooterText: messageFooter,
Description: nil,
Variables: []string{},
Placeholders: map[string]any{},
}
var AnnouncementCustomContent = func(subject string) CustomContentInfo {
cci := EmptyCustomContent
cci.Subject = func(config *Config, lang *emailLang) string { return subject }
cci.Variables = defaultVars()
cci.Placeholders = defaultVals(map[string]any{})
return cci
}
// Validates customContent and sets default fields if needed.
var _runtimeValidation = func() bool {
for name, cc := range customContent {
if name != cc.Name {
panic(fmt.Errorf("customContent key and name not matching: %s != %s", name, cc.Name))
}
if cc.DisplayName == nil {
panic(fmt.Errorf("no customContent[%s] DisplayName set", name))
}
if cc.HeaderText == nil {
cc.HeaderText = serverHeader
customContent[name] = cc
}
if cc.FooterText == nil {
cc.FooterText = messageFooter
customContent[name] = cc
}
}
return true
}()

View File

@@ -1,12 +1,14 @@
package main
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
dg "github.com/bwmarrin/discordgo"
"github.com/hrfee/jfa-go/common"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/timshannon/badgerhold/v4"
)
@@ -27,6 +29,18 @@ type DiscordDaemon struct {
commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string)
commandIDs []string
commandDescriptions []*dg.ApplicationCommand
retryOpts *common.MustAuthenticateOptions
}
func EmptyDiscordUser() *DiscordUser {
return &DiscordUser{
ID: "",
Username: "",
Discriminator: "",
Lang: "",
Contact: false,
JellyfinID: "",
}
}
func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
@@ -58,6 +72,16 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
dd.users[user.ID] = user
}
dd.retryOpts = &common.MustAuthenticateOptions{
RetryCount: app.config.Section("advanced").Key("auth_retry_count").MustInt(6),
RetryGap: time.Duration(app.config.Section("advanced").Key("auth_retry_gap").MustInt(10)) * time.Second,
LogFailures: true,
}
dd.bot.AddHandler(dd.commandHandler)
dd.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers | dg.IntentsGuildInvites
return dd, nil
}
@@ -98,13 +122,27 @@ func (d *DiscordDaemon) MustGetUser(channelID, userID, discrim, username string)
return d.NewUnknownUser(channelID, userID, discrim, username)
}
func (d *DiscordDaemon) run() {
d.bot.AddHandler(d.commandHandler)
func (d *DiscordDaemon) Run() {
ro := common.MustAuthenticateOptions{}
ro = *d.retryOpts
ro.Counter = 0
d.run(&ro)
}
d.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers | dg.IntentsGuildInvites
func (d *DiscordDaemon) run(retry *common.MustAuthenticateOptions) {
if err := d.bot.Open(); err != nil {
d.app.err.Printf(lm.FailedStartDaemon, lm.Discord, err)
return
if retry == nil || retry.LogFailures {
d.app.err.Printf(lm.FailedStartDaemon, lm.Discord, err)
}
if retry != nil {
retry.Counter += 1
if retry.Counter >= retry.RetryCount {
return
}
time.Sleep(retry.RetryGap)
d.run(retry)
return
}
}
// Wait for everything to populate, it's slow sometimes.
for d.bot.State == nil {
@@ -134,15 +172,18 @@ func (d *DiscordDaemon) run() {
d.InviteChannel.Name = invChannel
}
}
err = d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start"))
d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start"))
defer d.deregisterCommands()
defer d.bot.Close()
go d.registerCommands()
ro := common.MustAuthenticateOptions{}
ro = *(d.retryOpts)
ro.Counter = 0
go d.registerCommands(&ro)
<-d.ShutdownChannel
d.ShutdownChannel <- "Down"
return
}
// ListRoles returns a list of available (excluding bot and @everyone) roles in a guild as a list of containing an array of the guild ID and its name.
@@ -332,7 +373,7 @@ func (d *DiscordDaemon) Shutdown() {
close(d.ShutdownChannel)
}
func (d *DiscordDaemon) registerCommands() {
func (d *DiscordDaemon) registerCommands(retry *common.MustAuthenticateOptions) {
d.commandDescriptions = []*dg.ApplicationCommand{
{
Name: d.app.config.Section("discord").Key("start_command").MustString("start"),
@@ -429,7 +470,27 @@ func (d *DiscordDaemon) registerCommands() {
// if err != nil {
// d.app.err.Printf("Discord: Cannot create commands: %v", err)
// }
for i, cmd := range d.commandDescriptions {
cCommands, err := d.bot.ApplicationCommandBulkOverwrite(d.bot.State.User.ID, d.guildID, d.commandDescriptions)
if err != nil {
if retry == nil || retry.LogFailures {
d.app.err.Printf(lm.FailedRegisterDiscordCommand, "*", err)
}
if retry != nil {
retry.Counter += 1
if retry.Counter >= retry.RetryCount {
return
}
time.Sleep(retry.RetryGap)
d.registerCommands(retry)
}
} else {
for i := range len(d.commandDescriptions) {
d.commandIDs[i] = cCommands[i].ID
}
d.app.debug.Printf(lm.RegisterDiscordCommand, "*")
}
/* for i, cmd := range d.commandDescriptions {
command, err := d.bot.ApplicationCommandCreate(d.bot.State.User.ID, d.guildID, cmd)
if err != nil {
d.app.err.Printf(lm.FailedRegisterDiscordCommand, cmd.Name, err)
@@ -437,7 +498,7 @@ func (d *DiscordDaemon) registerCommands() {
d.app.debug.Printf(lm.RegisterDiscordCommand, cmd.Name)
d.commandIDs[i] = command.ID
}
}
} */
}
func (d *DiscordDaemon) deregisterCommands() {
@@ -605,6 +666,21 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
requester := d.MustGetUser(channel.ID, i.Interaction.Member.User.ID, i.Interaction.Member.User.Discriminator, i.Interaction.Member.User.Username)
d.users[i.Interaction.Member.User.ID] = requester
recipient := i.ApplicationCommandData().Options[0].UserValue(s)
// We don't reveal much in the message response itself so we can re-use this easily.
sendResponse := func(langKey string) {
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
Content: d.app.storage.lang.Telegram[lang].Strings.get(langKey),
Flags: 64, // Ephemeral
},
})
if err != nil {
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
}
}
// d.app.debug.Println(invuser)
//label := i.ApplicationCommandData().Options[2].StringValue()
//profile := i.ApplicationCommandData().Options[3].StringValue()
@@ -612,11 +688,10 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
//if mins > 0 {
// expmin = mins
//}
// Check whether requestor is linked to the admin account
requesterEmail, ok := d.app.storage.GetEmailsKey(requester.JellyfinID)
if !(ok && requesterEmail.Admin) {
// We want the same criteria for running this command as accessing the admin page (i.e. an "admin" of some sort)
if !(d.app.canAccessAdminPageByID(requester.JellyfinID)) {
d.app.err.Printf(lm.FailedGenerateInvite, fmt.Sprintf(lm.NonAdminUser, requester.JellyfinID))
// FIXME: add response message
sendResponse("noPermission")
return
}
@@ -658,54 +733,43 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
}
}
if recipient != nil && d.app.config.Section("invite_emails").Key("enabled").MustBool(false) {
invname, err := d.bot.GuildMember(d.guildID, recipient.ID)
if recipient != nil {
err = nil
var invname *dg.Member = nil
invname, err = d.bot.GuildMember(d.guildID, recipient.ID)
invite.SendTo = invname.User.Username
msg, err := d.app.email.constructInvite(invite.Code, invite, d.app, false)
if err != nil {
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, invite.Code, err)
d.app.err.Println(invite.SendTo)
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInviteFailure"),
Flags: 64, // Ephemeral
},
})
if err == nil && !(d.app.config.Section("invite_emails").Key("enabled").MustBool(false)) {
err = errors.New(lm.InviteMessagesDisabled)
}
var msg *Message
if err == nil {
msg, err = d.app.email.constructInvite(invite, false)
if err != nil {
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
}
} else {
var err error
err = d.app.discord.SendDM(msg, recipient.ID)
if err != nil {
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, RenderDiscordUsername(recipient), err)
// Print extra message, ideally we'd just print this, or get rid of it though.
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, invite.Code, err)
d.app.err.Println(invite.SendTo)
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInviteFailure"),
Flags: 64, // Ephemeral
},
})
if err != nil {
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
}
} else {
d.app.info.Printf(lm.SentInviteMessage, invite.Code, RenderDiscordUsername(recipient))
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInvite"),
Flags: 64, // Ephemeral
},
})
if err != nil {
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
}
}
}
if err == nil {
err = d.app.discord.SendDM(msg, recipient.ID)
}
if err == nil {
d.app.info.Printf(lm.SentInviteMessage, invite.Code, RenderDiscordUsername(recipient))
sendResponse("sentInvite")
}
if err != nil {
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, RenderDiscordUsername(recipient), err)
d.app.err.Println(invite.SendTo)
sendResponse("sentInviteFailure")
}
}
//if profile != "" {
d.app.storage.SetInvitesKey(invite.Code, invite)
}

View File

@@ -1,7 +1,9 @@
module github.com/hrfee/jfa-go/easyproxy
go 1.18
go 1.23.0
require golang.org/x/net v0.36.0
toolchain go1.24.5
require golang.org/x/net v0.42.0
require github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b

View File

@@ -2,3 +2,5 @@ github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b h1:xZ59n
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b/go.mod h1:uDd4sYVYsqcxAB8j+Q7uhL6IJCs/r1kxib1HV4bgOMg=
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=

810
email.go
View File

@@ -10,6 +10,7 @@ import (
"html/template"
"io"
"io/fs"
"maps"
"net/http"
"net/url"
"os"
@@ -41,6 +42,9 @@ type Emailer struct {
fromAddr, fromName string
lang emailLang
sender EmailClient
config *Config
storage *Storage
LoggerSet
}
// Message stores content.
@@ -51,7 +55,7 @@ type Message struct {
Markdown string `json:"markdown"`
}
func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool, datePattern, timePattern string) (d, t, expiresIn string) {
func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool) (d, t, expiresIn string) {
d = timefmt.Format(expiry, datePattern)
t = timefmt.Format(expiry, timePattern)
currentTime := time.Now()
@@ -73,34 +77,38 @@ func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool, datePattern
}
// NewEmailer configures and returns a new emailer.
func NewEmailer(app *appContext) *Emailer {
func NewEmailer(config *Config, storage *Storage, logs LoggerSet) *Emailer {
emailer := &Emailer{
fromAddr: app.config.Section("email").Key("address").String(),
fromName: app.config.Section("email").Key("from").String(),
lang: app.storage.lang.Email[app.storage.lang.chosenEmailLang],
fromAddr: config.Section("email").Key("address").String(),
fromName: config.Section("email").Key("from").String(),
lang: storage.lang.Email[storage.lang.chosenEmailLang],
LoggerSet: logs,
config: config,
storage: storage,
}
method := app.config.Section("email").Key("method").String()
method := emailer.config.Section("email").Key("method").String()
if method == "smtp" {
sslTLS := false
if app.config.Section("smtp").Key("encryption").String() == "ssl_tls" {
sslTLS = true
enc := sMail.EncryptionSTARTTLS
switch emailer.config.Section("smtp").Key("encryption").String() {
case "ssl_tls":
enc = sMail.EncryptionSSLTLS
case "starttls":
enc = sMail.EncryptionSTARTTLS
case "none":
enc = sMail.EncryptionNone
}
username := app.config.Section("smtp").Key("username").MustString("")
password := app.config.Section("smtp").Key("password").String()
username := emailer.config.Section("smtp").Key("username").MustString("")
password := emailer.config.Section("smtp").Key("password").String()
if username == "" && password != "" {
username = emailer.fromAddr
}
var proxyConf *easyproxy.ProxyConfig = nil
if app.proxyEnabled {
proxyConf = &app.proxyConfig
}
authType := sMail.AuthType(app.config.Section("smtp").Key("auth_type").MustInt(4))
err := emailer.NewSMTP(app.config.Section("smtp").Key("server").String(), app.config.Section("smtp").Key("port").MustInt(465), username, password, sslTLS, app.config.Section("smtp").Key("ssl_cert").MustString(""), app.config.Section("smtp").Key("hello_hostname").String(), app.config.Section("smtp").Key("cert_validation").MustBool(true), authType, proxyConf)
authType := sMail.AuthType(emailer.config.Section("smtp").Key("auth_type").MustInt(4))
err := emailer.NewSMTP(emailer.config.Section("smtp").Key("server").String(), emailer.config.Section("smtp").Key("port").MustInt(465), username, password, enc, emailer.config.Section("smtp").Key("ssl_cert").MustString(""), emailer.config.Section("smtp").Key("hello_hostname").String(), emailer.config.Section("smtp").Key("cert_validation").MustBool(true), authType, emailer.config.proxyConfig)
if err != nil {
app.err.Printf(lm.FailedInitSMTP, err)
emailer.err.Printf(lm.FailedInitSMTP, err)
}
} else if method == "mailgun" {
emailer.NewMailgun(app.config.Section("mailgun").Key("api_url").String(), app.config.Section("mailgun").Key("api_key").String(), app.proxyTransport)
emailer.NewMailgun(emailer.config.Section("mailgun").Key("api_url").String(), emailer.config.Section("mailgun").Key("api_key").String(), emailer.config.proxyTransport)
} else if method == "dummy" {
emailer.sender = &DummyClient{}
}
@@ -121,14 +129,10 @@ type SMTP struct {
}
// NewSMTP returns an SMTP emailClient.
func (emailer *Emailer) NewSMTP(server string, port int, username, password string, sslTLS bool, certPath string, helloHostname string, validateCertificate bool, authType sMail.AuthType, proxy *easyproxy.ProxyConfig) (err error) {
func (emailer *Emailer) NewSMTP(server string, port int, username, password string, encryption sMail.Encryption, certPath string, helloHostname string, validateCertificate bool, authType sMail.AuthType, proxy *easyproxy.ProxyConfig) (err error) {
sender := &SMTP{}
sender.Client = sMail.NewSMTPClient()
if sslTLS {
sender.Client.Encryption = sMail.EncryptionSSLTLS
} else {
sender.Client.Encryption = sMail.EncryptionSTARTTLS
}
sender.Client.Encryption = encryption
if username != "" || password != "" {
sender.Client.Authentication = authType
sender.Client.Username = username
@@ -160,7 +164,7 @@ func (emailer *Emailer) NewSMTP(server string, port int, username, password stri
var cert []byte
cert, err = os.ReadFile(certPath)
if rootCAs.AppendCertsFromPEM(cert) == false {
err = errors.New("Failed to append cert to pool")
err = errors.New("failed to append cert to pool")
}
}
sender.Client.TLSConfig = &tls.Config{
@@ -242,22 +246,48 @@ type templ interface {
Execute(wr io.Writer, data interface{}) error
}
func (emailer *Emailer) construct(app *appContext, section, keyFragment string, data map[string]interface{}) (html, text, markdown string, err error) {
var tpl templ
if substituteStrings == "" {
data["jellyfin"] = "Jellyfin"
} else {
data["jellyfin"] = substituteStrings
func (emailer *Emailer) construct(contentInfo CustomContentInfo, cc CustomContent, data map[string]any) (*Message, error) {
msg := &Message{
Subject: contentInfo.Subject(emailer.config, &emailer.lang),
}
// Template the subject for bonus points
if subject, err := templateEmail(msg.Subject, contentInfo.Variables, contentInfo.Conditionals, data); err == nil {
msg.Subject = subject
}
if cc.Enabled {
// Use template email, rather than the built-in's email file.
contentInfo.SourceFile = customContent["TemplateEmail"].SourceFile
content, err := templateEmail(cc.Content, contentInfo.Variables, contentInfo.Conditionals, data)
if err != nil {
emailer.err.Printf(lm.FailedConstructCustomContent, msg.Subject, err)
return msg, err
}
html := markdown.ToHTML([]byte(content), nil, markdownRenderer)
text := stripMarkdown(content)
templateData := map[string]interface{}{
"text": template.HTML(html),
"plaintext": text,
"md": content,
}
data = templateData
}
var err error = nil
var tpl templ
msg.Text = ""
msg.Markdown = ""
msg.HTML = ""
data["header"] = contentInfo.HeaderText(emailer.config, &emailer.lang)
data["footer"] = contentInfo.FooterText(emailer.config, &emailer.lang)
var keys []string
plaintext := app.config.Section("email").Key("plaintext").MustBool(false)
plaintext := emailer.config.Section("email").Key("plaintext").MustBool(false)
if plaintext {
if telegramEnabled || discordEnabled {
keys = []string{"text"}
text, markdown = "", ""
msg.Text, msg.Markdown = "", ""
} else {
keys = []string{"text"}
text = ""
msg.Text = ""
}
} else {
if telegramEnabled || discordEnabled {
@@ -270,9 +300,9 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string,
var filesystem fs.FS
var fpath string
if key == "markdown" {
filesystem, fpath = app.GetPath(section, keyFragment+"text")
filesystem, fpath = emailer.config.GetPath(contentInfo.SourceFile.Section, contentInfo.SourceFile.SettingPrefix+"text")
} else {
filesystem, fpath = app.GetPath(section, keyFragment+key)
filesystem, fpath = emailer.config.GetPath(contentInfo.SourceFile.Section, contentInfo.SourceFile.SettingPrefix+key)
}
if key == "html" {
tpl, err = template.ParseFS(filesystem, fpath)
@@ -280,7 +310,7 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string,
tpl, err = textTemplate.ParseFS(filesystem, fpath)
}
if err != nil {
return
return msg, fmt.Errorf("error reading from fs path \"%s\": %v", fpath, err)
}
// For constructTemplate, if "md" is found in data it's used in stead of "text".
foundMarkdown := false
@@ -293,616 +323,284 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string,
var tplData bytes.Buffer
err = tpl.Execute(&tplData, data)
if err != nil {
return
return msg, err
}
if foundMarkdown {
data["plaintext"], data["md"] = data["md"], data["plaintext"]
}
if key == "html" {
html = tplData.String()
msg.HTML = tplData.String()
} else if key == "text" {
text = tplData.String()
msg.Text = tplData.String()
} else {
markdown = tplData.String()
msg.Markdown = tplData.String()
}
}
return
return msg, nil
}
func (emailer *Emailer) confirmationValues(code, username, key string, app *appContext, noSub bool) map[string]interface{} {
template := map[string]interface{}{
func (emailer *Emailer) baseValues(name string, username string, placeholders bool, values map[string]any) (CustomContentInfo, map[string]any) {
contentInfo := customContent[name]
template := map[string]any{
"username": username,
}
maps.Copy(template, values)
// When generating a version for the user to customise, we'll replace "variable" with "{variable}", so the templater used for custom content understands them.
if placeholders {
for _, v := range contentInfo.Variables {
template[v] = "{" + v + "}"
}
}
return contentInfo, template
}
func (emailer *Emailer) constructConfirmation(code, username, key string, placeholders bool) (*Message, error) {
if placeholders {
username = "{username}"
}
contentInfo, template := emailer.baseValues("EmailConfirmation", username, placeholders, map[string]any{
"helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
"clickBelow": emailer.lang.EmailConfirmation.get("clickBelow"),
"ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"),
"confirmEmail": emailer.lang.EmailConfirmation.get("confirmEmail"),
"message": "",
"username": username,
}
if noSub {
template["helloUser"] = emailer.lang.Strings.get("helloUser")
empty := []string{"confirmationURL"}
for _, v := range empty {
template[v] = "{" + v + "}"
}
} else {
message := app.config.Section("messages").Key("message").String()
inviteLink := app.ExternalURI
})
if !placeholders {
inviteLink := ExternalURI(nil)
if code == "" { // Personal email change
inviteLink = fmt.Sprintf("%s/my/confirm/%s", inviteLink, url.PathEscape(key))
} else { // Invite email confirmation
inviteLink = fmt.Sprintf("%s%s/%s?key=%s", inviteLink, PAGES.Form, code, url.PathEscape(key))
}
template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username})
template["confirmationURL"] = inviteLink
template["message"] = message
}
return template
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
return emailer.construct(contentInfo, cc, template)
}
func (emailer *Emailer) constructConfirmation(code, username, key string, app *appContext, noSub bool) (*Message, error) {
email := &Message{
Subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.EmailConfirmation.get("title")),
}
var err error
template := emailer.confirmationValues(code, username, key, app, noSub)
message := app.storage.MustGetCustomContentKey("EmailConfirmation")
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, "email_confirmation", "email_", template)
}
if err != nil {
return nil, err
}
return email, nil
}
// username is optional, but should only be passed once.
func (emailer *Emailer) constructTemplate(subject, md string, app *appContext, username ...string) (*Message, error) {
if len(username) != 0 {
md = templateEmail(md, []string{"{username}"}, nil, map[string]interface{}{"username": username[0]})
subject = templateEmail(subject, []string{"{username}"}, nil, map[string]interface{}{"username": username[0]})
}
email := &Message{Subject: subject}
html := markdown.ToHTML([]byte(md), nil, markdownRenderer)
text := stripMarkdown(md)
message := app.config.Section("messages").Key("message").String()
var err error
data := map[string]interface{}{
"text": template.HTML(html),
"plaintext": text,
"message": message,
"md": md,
}
if len(username) != 0 {
data["username"] = username[0]
}
email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "template_email", "email_", data)
if err != nil {
return nil, err
}
return email, nil
}
func (emailer *Emailer) inviteValues(code string, invite Invite, app *appContext, noSub bool) map[string]interface{} {
func (emailer *Emailer) constructInvite(invite Invite, placeholders bool) (*Message, error) {
expiry := invite.ValidTill
d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern)
message := app.config.Section("messages").Key("message").String()
inviteLink := fmt.Sprintf("%s%s/%s", app.ExternalURI, PAGES.Form, code)
template := map[string]interface{}{
d, t, expiresIn := emailer.formatExpiry(expiry, false)
inviteLink := fmt.Sprintf("%s%s/%s", ExternalURI(nil), PAGES.Form, invite.Code)
contentInfo, template := emailer.baseValues("InviteEmail", "", placeholders, map[string]any{
"hello": emailer.lang.InviteEmail.get("hello"),
"youHaveBeenInvited": emailer.lang.InviteEmail.get("youHaveBeenInvited"),
"toJoin": emailer.lang.InviteEmail.get("toJoin"),
"linkButton": emailer.lang.InviteEmail.get("linkButton"),
"message": "",
"date": d,
"time": t,
"expiresInMinutes": expiresIn,
"inviteURL": inviteLink,
"inviteExpiry": emailer.lang.InviteEmail.get("inviteExpiry"),
})
if !placeholders {
template["inviteExpiry"] = emailer.lang.InviteEmail.template("inviteExpiry", template)
}
if noSub {
template["inviteExpiry"] = emailer.lang.InviteEmail.get("inviteExpiry")
empty := []string{"inviteURL"}
for _, v := range empty {
template[v] = "{" + v + "}"
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
return emailer.construct(contentInfo, cc, template)
}
func (emailer *Emailer) constructExpiry(invite Invite, placeholders bool) (*Message, error) {
expiry := formatDatetime(invite.ValidTill)
contentInfo, template := emailer.baseValues("InviteExpiry", "", placeholders, map[string]any{
"inviteExpired": emailer.lang.InviteExpiry.get("inviteExpired"),
"expiredAt": emailer.lang.InviteExpiry.get("expiredAt"),
"code": "\"" + invite.Code + "\"",
"time": expiry,
})
if !placeholders {
template["expiredAt"] = emailer.lang.InviteExpiry.template("expiredAt", template)
}
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
return emailer.construct(contentInfo, cc, template)
}
func (emailer *Emailer) constructCreated(username, address string, when time.Time, invite Invite, placeholders bool) (*Message, error) {
// NOTE: This was previously invite.Created, not sure why.
created := formatDatetime(when)
contentInfo, template := emailer.baseValues("UserCreated", username, placeholders, map[string]any{
"aUserWasCreated": emailer.lang.UserCreated.get("aUserWasCreated"),
"nameString": emailer.lang.Strings.get("name"),
"addressString": emailer.lang.Strings.get("emailAddress"),
"timeString": emailer.lang.UserCreated.get("time"),
"code": "\"" + invite.Code + "\"",
"name": username,
"time": created,
"address": address,
})
if !placeholders {
template["aUserWasCreated"] = emailer.lang.UserCreated.template("aUserWasCreated", template)
if emailer.config.Section("email").Key("no_username").MustBool(false) {
template["address"] = "n/a"
}
} else {
template["inviteExpiry"] = emailer.lang.InviteEmail.template("inviteExpiry", tmpl{"date": d, "time": t, "expiresInMinutes": expiresIn})
template["inviteURL"] = inviteLink
template["message"] = message
}
return template
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
return emailer.construct(contentInfo, cc, template)
}
func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext, noSub bool) (*Message, error) {
email := &Message{
Subject: app.config.Section("invite_emails").Key("subject").MustString(emailer.lang.InviteEmail.get("title")),
func (emailer *Emailer) constructReset(pwr PasswordReset, placeholders bool) (*Message, error) {
if placeholders {
pwr.Username = "{username}"
}
template := emailer.inviteValues(code, invite, app, noSub)
var err error
message := app.storage.MustGetCustomContentKey("InviteEmail")
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, "invite_emails", "email_", template)
}
if err != nil {
return nil, err
}
return email, nil
}
func (emailer *Emailer) expiryValues(code string, invite Invite, app *appContext, noSub bool) map[string]interface{} {
expiry := app.formatDatetime(invite.ValidTill)
template := map[string]interface{}{
"inviteExpired": emailer.lang.InviteExpiry.get("inviteExpired"),
"notificationNotice": emailer.lang.InviteExpiry.get("notificationNotice"),
"code": "\"" + code + "\"",
"time": expiry,
}
if noSub {
template["expiredAt"] = emailer.lang.InviteExpiry.get("expiredAt")
} else {
template["expiredAt"] = emailer.lang.InviteExpiry.template("expiredAt", tmpl{"code": template["code"].(string), "time": template["time"].(string)})
}
return template
}
func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appContext, noSub bool) (*Message, error) {
email := &Message{
Subject: emailer.lang.InviteExpiry.get("title"),
}
var err error
template := emailer.expiryValues(code, invite, app, noSub)
message := app.storage.MustGetCustomContentKey("InviteExpiry")
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, "notifications", "expiry_", template)
}
if err != nil {
return nil, err
}
return email, nil
}
func (emailer *Emailer) createdValues(code, username, address string, invite Invite, app *appContext, noSub bool) map[string]interface{} {
template := map[string]interface{}{
"nameString": emailer.lang.Strings.get("name"),
"addressString": emailer.lang.Strings.get("emailAddress"),
"timeString": emailer.lang.UserCreated.get("time"),
"notificationNotice": "",
"code": "\"" + code + "\"",
}
if noSub {
template["aUserWasCreated"] = emailer.lang.UserCreated.get("aUserWasCreated")
empty := []string{"name", "address", "time"}
for _, v := range empty {
template[v] = "{" + v + "}"
}
} else {
created := app.formatDatetime(invite.Created)
var tplAddress string
if app.config.Section("email").Key("no_username").MustBool(false) {
tplAddress = "n/a"
} else {
tplAddress = address
}
template["aUserWasCreated"] = emailer.lang.UserCreated.template("aUserWasCreated", tmpl{"code": template["code"].(string)})
template["name"] = username
template["address"] = tplAddress
template["time"] = created
template["notificationNotice"] = emailer.lang.UserCreated.get("notificationNotice")
}
return template
}
func (emailer *Emailer) constructCreated(code, username, address string, invite Invite, app *appContext, noSub bool) (*Message, error) {
email := &Message{
Subject: emailer.lang.UserCreated.get("title"),
}
template := emailer.createdValues(code, username, address, invite, app, noSub)
var err error
message := app.storage.MustGetCustomContentKey("UserCreated")
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, "notifications", "created_", template)
}
if err != nil {
return nil, err
}
return email, nil
}
func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bool) map[string]interface{} {
d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern)
message := app.config.Section("messages").Key("message").String()
template := map[string]interface{}{
d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true)
linkResetEnabled := emailer.config.Section("password_resets").Key("link_reset").MustBool(false)
contentInfo, template := emailer.baseValues("PasswordReset", pwr.Username, placeholders, map[string]any{
"helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": pwr.Username}),
"someoneHasRequestedReset": emailer.lang.PasswordReset.get("someoneHasRequestedReset"),
"ifItWasYou": emailer.lang.PasswordReset.get("ifItWasYou"),
"ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"),
"pinString": emailer.lang.PasswordReset.get("pin"),
"link_reset": false,
"message": "",
"username": pwr.Username,
"codeExpiry": emailer.lang.PasswordReset.get("codeExpiry"),
"link_reset": linkResetEnabled && !placeholders,
"date": d,
"time": t,
"expiresInMinutes": expiresIn,
}
linkResetEnabled := app.config.Section("password_resets").Key("link_reset").MustBool(false)
"pin": pwr.Pin,
})
if linkResetEnabled {
template["ifItWasYou"] = emailer.lang.PasswordReset.get("ifItWasYouLink")
} else {
template["ifItWasYou"] = emailer.lang.PasswordReset.get("ifItWasYou")
}
if noSub {
template["helloUser"] = emailer.lang.Strings.get("helloUser")
template["codeExpiry"] = emailer.lang.PasswordReset.get("codeExpiry")
empty := []string{"pin"}
for _, v := range empty {
template[v] = "{" + v + "}"
}
} else {
template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": pwr.Username})
template["codeExpiry"] = emailer.lang.PasswordReset.template("codeExpiry", tmpl{"date": d, "time": t, "expiresInMinutes": expiresIn})
if !placeholders {
template["codeExpiry"] = emailer.lang.PasswordReset.template("codeExpiry", template)
if linkResetEnabled {
pinLink, err := app.GenResetLink(pwr.Pin)
if err == nil {
// Strip /invite form end of this URL, ik its ugly.
template["link_reset"] = true
pinLink, err := GenResetLink(pwr.Pin)
if err != nil {
template["link_reset"] = false
emailer.info.Printf(lm.FailedGeneratePWRLink, err)
} else {
template["pin"] = pinLink
// Only used in html email.
template["pin_code"] = pwr.Pin
} else {
app.info.Printf(lm.FailedGeneratePWRLink, err)
template["pin"] = pwr.Pin
}
} else {
template["pin"] = pwr.Pin
}
template["message"] = message
}
return template
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
return emailer.construct(contentInfo, cc, template)
}
func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub bool) (*Message, error) {
email := &Message{
Subject: app.config.Section("password_resets").Key("subject").MustString(emailer.lang.PasswordReset.get("title")),
func (emailer *Emailer) constructDeleted(username, reason string, placeholders bool) (*Message, error) {
if placeholders {
username = "{username}"
reason = "{reason}"
}
template := emailer.resetValues(pwr, app, noSub)
var err error
message := app.storage.MustGetCustomContentKey("PasswordReset")
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, "password_resets", "email_", template)
}
if err != nil {
return nil, err
}
return email, nil
}
func (emailer *Emailer) deletedValues(reason string, app *appContext, noSub bool) map[string]interface{} {
template := map[string]interface{}{
contentInfo, template := emailer.baseValues("UserDeleted", username, placeholders, map[string]any{
"helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
"yourAccountWas": emailer.lang.UserDeleted.get("yourAccountWasDeleted"),
"reasonString": emailer.lang.Strings.get("reason"),
"message": "",
}
if noSub {
empty := []string{"reason"}
for _, v := range empty {
template[v] = "{" + v + "}"
}
} else {
template["reason"] = reason
template["message"] = app.config.Section("messages").Key("message").String()
}
return template
"reason": reason,
})
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
return emailer.construct(contentInfo, cc, template)
}
func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub bool) (*Message, error) {
email := &Message{
Subject: app.config.Section("deletion").Key("subject").MustString(emailer.lang.UserDeleted.get("title")),
func (emailer *Emailer) constructDisabled(username, reason string, placeholders bool) (*Message, error) {
if placeholders {
username = "{username}"
reason = "{reason}"
}
var err error
template := emailer.deletedValues(reason, app, noSub)
message := app.storage.MustGetCustomContentKey("UserDeleted")
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, "deletion", "email_", template)
}
if err != nil {
return nil, err
}
return email, nil
}
func (emailer *Emailer) disabledValues(reason string, app *appContext, noSub bool) map[string]interface{} {
template := map[string]interface{}{
contentInfo, template := emailer.baseValues("UserDisabled", username, placeholders, map[string]any{
"helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
"yourAccountWas": emailer.lang.UserDisabled.get("yourAccountWasDisabled"),
"reasonString": emailer.lang.Strings.get("reason"),
"message": "",
}
if noSub {
empty := []string{"reason"}
for _, v := range empty {
template[v] = "{" + v + "}"
}
} else {
template["reason"] = reason
template["message"] = app.config.Section("messages").Key("message").String()
}
return template
"reason": reason,
})
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
return emailer.construct(contentInfo, cc, template)
}
func (emailer *Emailer) constructDisabled(reason string, app *appContext, noSub bool) (*Message, error) {
email := &Message{
Subject: app.config.Section("disable_enable").Key("subject_disabled").MustString(emailer.lang.UserDisabled.get("title")),
func (emailer *Emailer) constructEnabled(username, reason string, placeholders bool) (*Message, error) {
if placeholders {
username = "{username}"
reason = "{reason}"
}
var err error
template := emailer.disabledValues(reason, app, noSub)
message := app.storage.MustGetCustomContentKey("UserDisabled")
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, "disable_enable", "disabled_", template)
}
if err != nil {
return nil, err
}
return email, nil
}
func (emailer *Emailer) enabledValues(reason string, app *appContext, noSub bool) map[string]interface{} {
template := map[string]interface{}{
contentInfo, template := emailer.baseValues("UserEnabled", username, placeholders, map[string]any{
"helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
"yourAccountWas": emailer.lang.UserEnabled.get("yourAccountWasEnabled"),
"reasonString": emailer.lang.Strings.get("reason"),
"message": "",
}
if noSub {
empty := []string{"reason"}
for _, v := range empty {
template[v] = "{" + v + "}"
}
} else {
template["reason"] = reason
template["message"] = app.config.Section("messages").Key("message").String()
}
return template
"reason": reason,
})
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
return emailer.construct(contentInfo, cc, template)
}
func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub bool) (*Message, error) {
email := &Message{
Subject: app.config.Section("disable_enable").Key("subject_enabled").MustString(emailer.lang.UserEnabled.get("title")),
func (emailer *Emailer) constructExpiryAdjusted(username string, expiry time.Time, reason string, placeholders bool) (*Message, error) {
if placeholders {
username = "{username}"
}
var err error
template := emailer.enabledValues(reason, app, noSub)
message := app.storage.MustGetCustomContentKey("UserEnabled")
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, "disable_enable", "enabled_", template)
}
if err != nil {
return nil, err
}
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{}{
exp := formatDatetime(expiry)
contentInfo, template := emailer.baseValues("UserExpiryAdjusted", username, placeholders, map[string]any{
"helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
"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,
})
}
"reason": reason,
"newExpiry": exp,
})
cc := emailer.storage.MustGetCustomContentKey("UserExpiryAdjusted")
if !placeholders {
if !cc.Enabled {
template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
"date": exp,
})
}
}
return template
return emailer.construct(contentInfo, cc, 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")),
func (emailer *Emailer) constructExpiryReminder(username string, expiry time.Time, placeholders bool) (*Message, error) {
if placeholders {
username = "{username}"
}
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"),
"youCanLoginWith": emailer.lang.WelcomeEmail.get("youCanLoginWith"),
"jellyfinURLString": emailer.lang.WelcomeEmail.get("jellyfinURL"),
"usernameString": emailer.lang.Strings.get("username"),
"message": "",
"yourAccountWillExpire": "",
}
if noSub {
empty := []string{"jellyfinURL", "username", "yourAccountWillExpire"}
for _, v := range empty {
template[v] = "{" + v + "}"
}
} else {
template["jellyfinURL"] = app.config.Section("jellyfin").Key("public_server").String()
template["username"] = username
template["message"] = app.config.Section("messages").Key("message").String()
exp := app.formatDatetime(expiry)
if !expiry.IsZero() {
if custom {
template["yourAccountWillExpire"] = exp
} else if !expiry.IsZero() {
template["yourAccountWillExpire"] = emailer.lang.WelcomeEmail.template("yourAccountWillExpire", tmpl{
"date": exp,
})
}
d, t, expiresIn := emailer.formatExpiry(expiry, false)
contentInfo, template := emailer.baseValues("ExpiryReminder", username, placeholders, map[string]any{
"helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
"yourAccountIsDueToExpire": emailer.lang.ExpiryReminder.get("yourAccountIsDueToExpire"),
"expiresIn": expiresIn,
"date": d,
"time": t,
})
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
if !placeholders {
if !cc.Enabled && !expiry.IsZero() {
template["yourAccountIsDueToExpire"] = emailer.lang.ExpiryReminder.template("yourAccountIsDueToExpire", template)
}
}
return template
return emailer.construct(contentInfo, cc, template)
}
func (emailer *Emailer) constructWelcome(username string, expiry time.Time, app *appContext, noSub bool) (*Message, error) {
email := &Message{
Subject: app.config.Section("welcome_email").Key("subject").MustString(emailer.lang.WelcomeEmail.get("title")),
func (emailer *Emailer) constructWelcome(username string, expiry time.Time, placeholders bool) (*Message, error) {
var exp any = formatDatetime(expiry)
if placeholders {
username = "{username}"
exp = "{yourAccountWillExpire}"
}
var err error
var template map[string]interface{}
message := app.storage.MustGetCustomContentKey("WelcomeEmail")
if message.Enabled {
template = emailer.welcomeValues(username, expiry, app, noSub, true)
} else {
template = emailer.welcomeValues(username, expiry, app, noSub, false)
}
if noSub {
contentInfo, template := emailer.baseValues("WelcomeEmail", username, placeholders, map[string]any{
"welcome": emailer.lang.WelcomeEmail.get("welcome"),
"youCanLoginWith": emailer.lang.WelcomeEmail.get("youCanLoginWith"),
"jellyfinURLString": emailer.lang.WelcomeEmail.get("jellyfinURL"),
"jellyfinURL": emailer.config.Section("jellyfin").Key("public_server").String(),
"usernameString": emailer.lang.Strings.get("username"),
})
if !expiry.IsZero() || placeholders {
template["yourAccountWillExpire"] = emailer.lang.WelcomeEmail.template("yourAccountWillExpire", tmpl{
"date": "{yourAccountWillExpire}",
"date": exp,
})
}
if message.Enabled {
content := templateEmail(
message.Content,
message.Variables,
message.Conditionals,
template,
)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "welcome_email", "email_", template)
cc := emailer.storage.MustGetCustomContentKey("WelcomeEmail")
if !placeholders {
if cc.Enabled && !expiry.IsZero() {
template["yourAccountWillExpire"] = exp
}
}
if err != nil {
return nil, err
}
return email, nil
return emailer.construct(contentInfo, cc, template)
}
func (emailer *Emailer) userExpiredValues(app *appContext, noSub bool) map[string]interface{} {
template := map[string]interface{}{
func (emailer *Emailer) constructUserExpired(username string, placeholders bool) (*Message, error) {
contentInfo, template := emailer.baseValues("UserExpired", username, placeholders, map[string]any{
"yourAccountHasExpired": emailer.lang.UserExpired.get("yourAccountHasExpired"),
"contactTheAdmin": emailer.lang.UserExpired.get("contactTheAdmin"),
"message": "",
}
if !noSub {
template["message"] = app.config.Section("messages").Key("message").String()
}
return template
}
func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Message, error) {
email := &Message{
Subject: app.config.Section("user_expiry").Key("subject").MustString(emailer.lang.UserExpired.get("title")),
}
var err error
template := emailer.userExpiredValues(app, noSub)
message := app.storage.MustGetCustomContentKey("UserExpired")
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", "email_", template)
}
if err != nil {
return nil, err
}
return email, nil
})
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
return emailer.construct(contentInfo, cc, template)
}
// calls the send method in the underlying emailClient.

491
email_test.go Normal file
View File

@@ -0,0 +1,491 @@
package main
import (
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/fatih/color"
"github.com/hrfee/jfa-go/logger"
"github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4"
)
var db *badgerhold.Store
func dbClose(e *Emailer) {
e.storage.db.Close()
e.storage.db = nil
db = nil
}
func Fatal(err any) {
fmt.Printf("Fatal log function called: %+v\n", err)
}
// NewTestEmailer initialises most of what the emailer depends on, which happens to be most of the app.
func NewTestEmailer() (*Emailer, error) {
emailer := &Emailer{
fromAddr: "from@addr",
fromName: "fromName",
LoggerSet: LoggerSet{
info: logger.NewLogger(os.Stdout, "[TEST INFO] ", log.Ltime, color.FgHiWhite),
err: logger.NewLogger(os.Stdout, "[TEST ERROR] ", log.Ltime|log.Lshortfile, color.FgRed),
debug: logger.NewLogger(os.Stdout, "[TEST DEBUG] ", log.Ltime|log.Lshortfile, color.FgYellow),
},
sender: &DummyClient{},
}
// Assume our working directory is the root of the repo
wd, _ := os.Getwd()
loadFilesystems(filepath.Join(wd, "build"), logger.NewEmptyLogger())
dConfig, err := fs.ReadFile(localFS, "config-default.ini")
if err != nil {
return emailer, err
}
// Force emailer to construct markdown
discordEnabled = true
noInfoLS := emailer.LoggerSet
noInfoLS.info = logger.NewEmptyLogger()
emailer.config, err = NewConfig(dConfig, "/tmp/jfa-go-test", noInfoLS)
if err != nil {
return emailer, err
}
emailer.storage = NewStorage("/tmp/db", emailer.debug, func(k string) DebugLogAction { return LogAll })
emailer.storage.loadLang(langFS)
emailer.storage.lang.chosenAdminLang = emailer.config.Section("ui").Key("language-admin").MustString("en-us")
emailer.storage.lang.chosenEmailLang = emailer.config.Section("email").Key("language").MustString("en-us")
emailer.storage.lang.chosenPWRLang = emailer.config.Section("password_resets").Key("language").MustString("en-us")
emailer.storage.lang.chosenTelegramLang = emailer.config.Section("telegram").Key("language").MustString("en-us")
opts := badgerhold.DefaultOptions
opts.Dir = "/tmp/jfa-go-test-db"
opts.ValueDir = opts.Dir
opts.SyncWrites = false
opts.Logger = nil
emailer.storage.db, err = badgerhold.Open(opts)
// emailer.info.Printf("DB Opened")
db = emailer.storage.db
if err != nil {
return emailer, err
}
emailer.lang = emailer.storage.lang.Email[emailer.storage.lang.chosenEmailLang]
emailer.info.SetFatalFunc(Fatal)
emailer.err.SetFatalFunc(Fatal)
return emailer, err
}
func testDummyEmailerInit(t *testing.T) *Emailer {
e, err := NewTestEmailer()
if err != nil {
t.Fatalf("error: %v", err)
}
return e
}
func TestDummyEmailerInit(t *testing.T) {
dbClose(testDummyEmailerInit(t))
}
func testContent(e *Emailer, cci CustomContentInfo, t *testing.T, testFunc func(t *testing.T)) {
e.storage.DeleteCustomContentKey(cci.Name)
t.Run(cci.Name, testFunc)
cc := CustomContent{
Name: cci.Name,
Enabled: true,
}
cc.Content = "start test content "
for _, v := range cci.Variables {
cc.Content += "{" + v + "}"
}
cc.Content += " end test content"
e.storage.SetCustomContentKey(cci.Name, cc)
t.Run(cci.Name+" Custom", testFunc)
e.storage.DeleteCustomContentKey(cci.Name)
}
// constructConfirmation(code, username, key string, placeholders bool)
func TestConfirmation(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
// non-blank key, link should therefore not be a /my/confirm one
if db == nil {
t.Fatalf("db nil")
}
testContent(e, customContent["EmailConfirmation"], t, func(t *testing.T) {
code := shortuuid.New()
username := shortuuid.New()
key := shortuuid.New()
msg, err := e.constructConfirmation(code, username, key, false)
t.Run("FromInvite", func(t *testing.T) {
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if strings.Contains(content, "/my/confirm") {
t.Fatalf("/my/confirm link generated instead of invite confirm link: %s", content)
}
if !strings.Contains(content, code) {
t.Fatalf("code not found in output: %s", content)
}
if !strings.Contains(content, key) {
t.Fatalf("key not found in output: %s", content)
}
if !strings.Contains(content, username) {
t.Fatalf("username not found in output: %s", content)
}
}
})
code = ""
msg, err = e.constructConfirmation(code, username, key, false)
t.Run("FromMyAccount", func(t *testing.T) {
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, "/my/confirm") {
t.Fatalf("/my/confirm link not generated: %s", content)
}
if !strings.Contains(content, key) {
t.Fatalf("key not found in output: %s", content)
}
if !strings.Contains(content, username) {
t.Fatalf("username not found in output: %s", content)
}
}
})
})
}
// constructInvite(invite Invite, placeholders bool)
func TestInvite(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
if db == nil {
t.Fatalf("db nil")
}
// Fix date/time format
datePattern = "%d/%m/%y"
timePattern = "%H:%M"
testContent(e, customContent["InviteEmail"], t, func(t *testing.T) {
inv := Invite{
Code: shortuuid.New(),
Created: time.Now(),
ValidTill: time.Now().Add(30 * time.Minute),
}
msg, err := e.constructInvite(inv, false)
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, inv.Code) {
t.Fatalf("code not found in output: %s", content)
}
if !strings.Contains(content, "30m") {
t.Fatalf("expiry not found in output: %s", content)
}
}
})
}
// constructExpiry(code string, invite Invite, placeholders bool)
func TestExpiry(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
if db == nil {
t.Fatalf("db nil")
}
// Fix date/time format
datePattern = "%d/%m/%y"
timePattern = "%H:%M"
testContent(e, customContent["InviteExpiry"], t, func(t *testing.T) {
inv := Invite{
Code: shortuuid.New(),
Created: time.Time{},
ValidTill: time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC),
}
// So we can easily check is the expiry time is included (which is 0001-01-01).
for strings.Contains(inv.Code, "1") {
inv.Code = shortuuid.New()
}
msg, err := e.constructExpiry(inv, false)
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, inv.Code) {
t.Fatalf("code not found in output: %s", content)
}
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
t.Fatalf("expiry not found in output: %s", content)
}
}
})
}
// constructCreated(code, username, address string, invite Invite, placeholders bool)
func TestCreated(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
if db == nil {
t.Fatalf("db nil")
}
// Fix date/time format
datePattern = "%d/%m/%y"
timePattern = "%H:%M"
testContent(e, customContent["UserCreated"], t, func(t *testing.T) {
inv := Invite{
Code: shortuuid.New(),
Created: time.Time{},
ValidTill: time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC),
}
username := shortuuid.New()
address := shortuuid.New()
msg, err := e.constructCreated(username, address, inv.ValidTill, inv, false)
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, inv.Code) {
t.Fatalf("code not found in output: %s", content)
}
if !strings.Contains(content, username) {
t.Fatalf("username not found in output: %s", content)
}
if !strings.Contains(content, address) {
t.Fatalf("address not found in output: %s", content)
}
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
t.Fatalf("expiry not found in output: %s", content)
}
}
})
}
// constructReset(pwr PasswordReset, placeholders bool)
func TestReset(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
if db == nil {
t.Fatalf("db nil")
}
// Fix date/time format
datePattern = "%d/%m/%y"
timePattern = "%H:%M"
testContent(e, customContent["PasswordReset"], t, func(t *testing.T) {
pwr := PasswordReset{
Pin: shortuuid.New(),
Username: shortuuid.New(),
Expiry: time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC),
Internal: false,
}
msg, err := e.constructReset(pwr, false)
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, pwr.Pin) {
t.Fatalf("pin not found in output: %s", content)
}
if !strings.Contains(content, pwr.Username) {
t.Fatalf("username not found in output: %s", content)
}
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
t.Fatalf("expiry not found in output: %s", content)
}
}
})
}
// constructDeleted(reason string, placeholders bool)
func TestDeleted(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
if db == nil {
t.Fatalf("db nil")
}
testContent(e, customContent["UserDeleted"], t, func(t *testing.T) {
reason := shortuuid.New()
username := shortuuid.New()
msg, err := e.constructDeleted(username, reason, false)
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, reason) {
t.Fatalf("reason not found in output: %s", content)
}
if !strings.Contains(content, username) {
t.Fatalf("username not found in output: %s", content)
}
}
})
}
// constructDisabled(reason string, placeholders bool)
func TestDisabled(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
if db == nil {
t.Fatalf("db nil")
}
testContent(e, customContent["UserDeleted"], t, func(t *testing.T) {
reason := shortuuid.New()
username := shortuuid.New()
msg, err := e.constructDisabled(username, reason, false)
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, reason) {
t.Fatalf("reason not found in output: %s", content)
}
if !strings.Contains(content, username) {
t.Fatalf("username not found in output: %s", content)
}
}
})
}
// constructEnabled(reason string, placeholders bool)
func TestEnabled(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
if db == nil {
t.Fatalf("db nil")
}
testContent(e, customContent["UserDeleted"], t, func(t *testing.T) {
reason := shortuuid.New()
username := shortuuid.New()
msg, err := e.constructEnabled(username, reason, false)
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, reason) {
t.Fatalf("reason not found in output: %s", content)
}
if !strings.Contains(content, username) {
t.Fatalf("username not found in output: %s", content)
}
}
})
}
// constructExpiryAdjusted(username string, expiry time.Time, reason string, placeholders bool)
func TestExpiryAdjusted(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
if db == nil {
t.Fatalf("db nil")
}
// Fix date/time format
datePattern = "%d/%m/%y"
timePattern = "%H:%M"
testContent(e, customContent["UserExpiryAdjusted"], t, func(t *testing.T) {
username := shortuuid.New()
expiry := time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC)
reason := shortuuid.New()
msg, err := e.constructExpiryAdjusted(username, expiry, reason, false)
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, username) {
t.Fatalf("username not found in output: %s", content)
}
if !strings.Contains(content, reason) {
t.Fatalf("reason not found in output: %s", content)
}
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
t.Fatalf("expiry not found in output: %s", content)
}
}
})
}
// constructExpiryReminder(username string, expiry time.Time, placeholders bool)
func TestExpiryReminder(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
if db == nil {
t.Fatalf("db nil")
}
// Fix date/time format
datePattern = "%d/%m/%y"
timePattern = "%H:%M"
testContent(e, customContent["ExpiryReminder"], t, func(t *testing.T) {
username := shortuuid.New()
expiry := time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC)
msg, err := e.constructExpiryReminder(username, expiry, false)
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, username) {
t.Fatalf("username not found in output: %s", content)
}
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
t.Fatalf("expiry not found in output: %s", content)
}
}
})
}
// constructWelcome(username string, expiry time.Time, placeholders bool)
func TestWelcome(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
if db == nil {
t.Fatalf("db nil")
}
// Fix date/time format
datePattern = "%d/%m/%y"
timePattern = "%H:%M"
testContent(e, customContent["WelcomeEmail"], t, func(t *testing.T) {
username := shortuuid.New()
expiry := time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC)
msg, err := e.constructWelcome(username, expiry, false)
t.Run("NoExpiry", func(t *testing.T) {
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, username) {
t.Fatalf("username not found in output: %s", content)
}
// time.Time{} is 0001-01-01... so look for a 1 in there at least.
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
t.Fatalf("expiry not found in output: %s", content)
}
}
})
username = shortuuid.New()
expiry = time.Time{}
msg, err = e.constructWelcome(username, expiry, false)
t.Run("WithExpiry", func(t *testing.T) {
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, username) {
t.Fatalf("username not found in output: %s", content)
}
if strings.Contains(content, "01/01/01") || strings.Contains(content, "00:00") {
t.Fatalf("empty expiry found in output: %s", content)
}
}
})
})
}

View File

@@ -4,20 +4,17 @@
package main
import (
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"github.com/hrfee/jfa-go/logger"
)
const binaryType = "external"
func BuildTagsExternal() { buildTags = append(buildTags, "external") }
var localFS dirFS
var langFS dirFS
// When using os.DirFS, even on Windows the separator seems to be '/'.
// func FSJoin(elem ...string) string { return filepath.Join(elem...) }
func FSJoin(elem ...string) string {
@@ -32,23 +29,12 @@ func FSJoin(elem ...string) string {
return strings.TrimSuffix(path, sep)
}
type dirFS string
func (dir dirFS) Open(name string) (fs.File, error) {
return os.Open(string(dir) + "/" + name)
}
func (dir dirFS) ReadFile(name string) ([]byte, error) {
return os.ReadFile(string(dir) + "/" + name)
}
func (dir dirFS) ReadDir(name string) ([]fs.DirEntry, error) {
return os.ReadDir(string(dir) + "/" + name)
}
func loadFilesystems() {
log.Println("Using external storage")
executable, _ := os.Executable()
localFS = dirFS(filepath.Join(filepath.Dir(executable), "data"))
langFS = dirFS(filepath.Join(filepath.Dir(executable), "data", "lang"))
func loadFilesystems(rootDir string, logger *logger.Logger) {
logger.Println("Using external storage")
if rootDir == "" {
executable, _ := os.Executable()
rootDir = filepath.Dir(executable)
}
localFS = dirFS(filepath.Join(rootDir, "data"))
langFS = dirFS(filepath.Join(rootDir, "data", "lang"))
}

29
fs.go Normal file
View File

@@ -0,0 +1,29 @@
package main
import (
"io/fs"
"os"
)
type genericFS interface {
fs.FS
fs.ReadDirFS
fs.ReadFileFS
}
var localFS genericFS
var langFS genericFS
type dirFS string
func (dir dirFS) Open(name string) (fs.File, error) {
return os.Open(string(dir) + "/" + name)
}
func (dir dirFS) ReadFile(name string) ([]byte, error) {
return os.ReadFile(string(dir) + "/" + name)
}
func (dir dirFS) ReadDir(name string) ([]fs.DirEntry, error) {
return os.ReadDir(string(dir) + "/" + name)
}

View File

@@ -11,6 +11,7 @@ import (
type GenericDaemon struct {
Stopped bool
ShutdownChannel chan string
TriggerChannel chan bool
Interval time.Duration
period time.Duration
jobs []func(app *appContext)
@@ -27,6 +28,7 @@ func NewGenericDaemon(interval time.Duration, app *appContext, jobs ...func(app
d := GenericDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
TriggerChannel: make(chan bool),
Interval: interval,
period: interval,
app: app,
@@ -46,6 +48,8 @@ func (d *GenericDaemon) run() {
case <-d.ShutdownChannel:
d.ShutdownChannel <- "Down"
return
case <-d.TriggerChannel:
break
case <-time.After(d.period):
break
}
@@ -61,6 +65,10 @@ func (d *GenericDaemon) run() {
}
}
func (d *GenericDaemon) Trigger() {
d.TriggerChannel <- true
}
func (d *GenericDaemon) Shutdown() {
d.Stopped = true
d.ShutdownChannel <- "Down"

151
go.mod
View File

@@ -1,8 +1,6 @@
module github.com/hrfee/jfa-go
go 1.23.0
toolchain go1.24.0
go 1.24.0
replace github.com/hrfee/jfa-go/docs => ./docs
@@ -22,123 +20,130 @@ replace github.com/hrfee/jfa-go/easyproxy => ./easyproxy
replace github.com/hrfee/jfa-go/jellyseerr => ./jellyseerr
// replace github.com/hrfee/mediabrowser => ../mediabrowser
require (
github.com/bwmarrin/discordgo v0.28.1
github.com/dgraph-io/badger/v4 v4.3.1
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a
github.com/bwmarrin/discordgo v0.29.0
github.com/dgraph-io/badger/v4 v4.8.0
github.com/emersion/go-autostart v0.0.0-20250403115856-34830d6457d2
github.com/fatih/color v1.18.0
github.com/fsnotify/fsnotify v1.8.0
github.com/fsnotify/fsnotify v1.9.0
github.com/getlantern/systray v1.2.2
github.com/gin-contrib/pprof v1.5.0
github.com/gin-contrib/static v1.1.2
github.com/gin-gonic/gin v1.10.0
github.com/gin-contrib/pprof v1.5.3
github.com/gin-gonic/gin v1.11.0
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
github.com/goccy/go-yaml v1.18.0
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81
github.com/hrfee/jfa-go/common v0.0.0-20241105225412-da4470bc4fbc
github.com/hrfee/jfa-go/docs v0.0.0-20241105225412-da4470bc4fbc
github.com/hrfee/jfa-go/easyproxy v0.0.0-20241105225412-da4470bc4fbc
github.com/hrfee/jfa-go/jellyseerr v0.0.0-20241105225412-da4470bc4fbc
github.com/hrfee/jfa-go/linecache v0.0.0-20241105225412-da4470bc4fbc
github.com/hrfee/jfa-go/logger v0.0.0-20241105225412-da4470bc4fbc
github.com/hrfee/jfa-go/logmessages v0.0.0-20241105225412-da4470bc4fbc
github.com/hrfee/jfa-go/ombi v0.0.0-20241105225412-da4470bc4fbc
github.com/hrfee/mediabrowser v0.3.24
github.com/itchyny/timefmt-go v0.1.6
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a
github.com/hrfee/jfa-go/common v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/docs v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/easyproxy v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/jellyseerr v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/linecache v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/logger v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/logmessages v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/ombi v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/mediabrowser v0.3.33
github.com/itchyny/timefmt-go v0.1.7
github.com/lithammer/shortuuid/v3 v3.0.7
github.com/mailgun/mailgun-go/v4 v4.18.1
github.com/mattn/go-sqlite3 v1.14.24
github.com/mailgun/mailgun-go/v4 v4.23.0
github.com/mattn/go-sqlite3 v1.14.32
github.com/robert-nix/ansihtml v1.0.1
github.com/steambap/captcha v1.4.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/gin-swagger v1.6.1
github.com/timshannon/badgerhold/v4 v4.0.3
github.com/writeas/go-strip-markdown v2.0.1+incompatible
github.com/xhit/go-simple-mail/v2 v2.16.0
gopkg.in/ini.v1 v1.67.0
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.21.1
maunium.net/go/mautrix v0.26.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/bytedance/sonic v1.12.4 // indirect
github.com/bytedance/sonic/loader v0.2.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dgraph-io/ristretto v1.0.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect
github.com/getlantern/errors v1.0.4 // indirect
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 // indirect
github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc // indirect
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 // indirect
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-chi/chi/v5 v5.1.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-chi/chi/v5 v5.2.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-openapi/jsonpointer v0.22.3 // indirect
github.com/go-openapi/jsonreference v0.21.3 // indirect
github.com/go-openapi/spec v0.22.1 // indirect
github.com/go-openapi/swag/conv v0.25.3 // indirect
github.com/go-openapi/swag/jsonname v0.25.3 // indirect
github.com/go-openapi/swag/jsonutils v0.25.3 // indirect
github.com/go-openapi/swag/loading v0.25.3 // indirect
github.com/go-openapi/swag/stringutils v0.25.3 // indirect
github.com/go-openapi/swag/typeutils v0.25.3 // indirect
github.com/go-openapi/swag/yamlutils v0.25.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.22.1 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/go-stack/stack v1.8.1 // indirect
github.com/go-test/deep v1.1.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/flatbuffers v24.3.25+incompatible // indirect
github.com/google/flatbuffers v25.9.23+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b // indirect
github.com/mailgun/errors v0.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rs/zerolog v1.33.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.0 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/swaggo/swag v1.16.4 // indirect
github.com/swaggo/swag v1.16.6 // indirect
github.com/technoweenie/multipartstreamer v1.0.1 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 // indirect
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.mau.fi/util v0.8.1 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/otel v1.31.0 // indirect
go.opentelemetry.io/otel/metric v1.31.0 // indirect
go.opentelemetry.io/otel/trace v1.31.0 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mau.fi/util v0.9.3 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/arch v0.11.0 // indirect
golang.org/x/crypto v0.35.0 // indirect
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
golang.org/x/image v0.21.0 // indirect
golang.org/x/net v0.36.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/tools v0.26.0 // indirect
google.golang.org/protobuf v1.35.1 // indirect
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/image v0.33.0 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/tools v0.39.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

287
go.sum
View File

@@ -12,13 +12,14 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdko
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4=
github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/bytedance/sonic v1.12.4 h1:9Csb3c9ZJhfUWeMtpCDCq6BUoH5ogfDFLUgQ/jG+R0k=
github.com/bytedance/sonic v1.12.4/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=
github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
@@ -26,10 +27,8 @@ github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
@@ -41,19 +40,19 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v4 v4.1.0/go.mod h1:P50u28d39ibBRmIJuQC/NSdBOg46HnHw7al2SW5QRHg=
github.com/dgraph-io/badger/v4 v4.3.1 h1:7r5wKqmoRpGgSxqa0S/nGdpOpvvzuREGPLSua73C8tw=
github.com/dgraph-io/badger/v4 v4.3.1/go.mod h1:oObz97DImXpd6O/Dt8BqdKLLTDmEmarAimo72VV5whQ=
github.com/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs=
github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w=
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
github.com/dgraph-io/ristretto v1.0.0 h1:SYG07bONKMlFDUYu5pEu3DGAh8c2OFNzKm6G9J4Si84=
github.com/dgraph-io/ristretto v1.0.0/go.mod h1:jTi2FiYEhQ1NsMmA7DeBykizjOuY88NhKBkepyu1jPc=
github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a h1:M88ob4TyDnEqNuL3PgsE/p3bDujfspnulR+0dQWNYZs=
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a/go.mod h1:buzQsO8HHkZX2Q45fdfGH1xejPjuDQaXH8btcYMFzPM=
github.com/emersion/go-autostart v0.0.0-20250403115856-34830d6457d2 h1:CgF8+TNFvlnxEbplSgS70ZI4IUFEzVkY+ICNqTVE/AM=
github.com/emersion/go-autostart v0.0.0-20250403115856-34830d6457d2/go.mod h1:buzQsO8HHkZX2Q45fdfGH1xejPjuDQaXH8btcYMFzPM=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -62,10 +61,10 @@ github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGE
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 h1:oEZYEpZo28Wdx+5FZo4aU7JFXu0WG/4wJWese5reQSA=
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201/go.mod h1:Y9WZUHEb+mpra02CbQ/QczLUe6f0Dezxaw5DCJlJQGo=
@@ -92,54 +91,72 @@ github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeME
github.com/gin-contrib/gzip v0.0.1/go.mod h1:fGBJBCdt6qCZuCAOwWuFhBB4OOq9EFqlo5dEaFhhu5w=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/pprof v1.5.0 h1:E/Oy7g+kNw94KfdCy3bZxQFtyDnAX2V7axRS7sNYVrU=
github.com/gin-contrib/pprof v1.5.0/go.mod h1:GqFL6LerKoCQ/RSWnkYczkTJ+tOAUVN/8sbnEtaqOKs=
github.com/gin-contrib/pprof v1.5.3 h1:Bj5SxJ3kQDVez/s/+f9+meedJIqLS+xlkIVDe/lcvgM=
github.com/gin-contrib/pprof v1.5.3/go.mod h1:0+LQSZ4SLO0B6+2n6JBzaEygpTBxe/nI+YEYpfQQ6xY=
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-contrib/static v1.1.2 h1:c3kT4bFkUJn2aoRU3s6XnMjJT8J6nNWJkR0NglqmlZ4=
github.com/gin-contrib/static v1.1.2/go.mod h1:Fw90ozjHCmZBWbgrsqrDvO28YbhKEKzKp8GixhR4yLw=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y=
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M=
github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I=
github.com/go-openapi/jsonreference v0.19.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I=
github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc=
github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
github.com/go-openapi/spec v0.19.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI=
github.com/go-openapi/spec v0.19.4/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg=
github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag/conv v0.25.3 h1:PcB18wwfba7MN5BVlBIV+VxvUUeC2kEuCEyJ2/t2X7E=
github.com/go-openapi/swag/conv v0.25.3/go.mod h1:n4Ibfwhn8NJnPXNRhBO5Cqb9ez7alBR40JS4rbASUPU=
github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A=
github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.3 h1:kV7wer79KXUM4Ea4tBdAVTU842Rg6tWstX3QbM4fGdw=
github.com/go-openapi/swag/jsonutils v0.25.3/go.mod h1:ILcKqe4HC1VEZmJx51cVuZQ6MF8QvdfXsQfiaCs0z9o=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3 h1:/i3E9hBujtXfHy91rjtwJ7Fgv5TuDHgnSrYjhFxwxOw=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3/go.mod h1:8kYfCR2rHyOj25HVvxL5Nm8wkfzggddgjZm6RgjT8Ao=
github.com/go-openapi/swag/loading v0.25.3 h1:Nn65Zlzf4854MY6Ft0JdNrtnHh2bdcS/tXckpSnOb2Y=
github.com/go-openapi/swag/loading v0.25.3/go.mod h1:xajJ5P4Ang+cwM5gKFrHBgkEDWfLcsAKepIuzTmOb/c=
github.com/go-openapi/swag/stringutils v0.25.3 h1:nAmWq1fUTWl/XiaEPwALjp/8BPZJun70iDHRNq/sH6w=
github.com/go-openapi/swag/stringutils v0.25.3/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/typeutils v0.25.3 h1:2w4mEEo7DQt3V4veWMZw0yTPQibiL3ri2fdDV4t2TQc=
github.com/go-openapi/swag/typeutils v0.25.3/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/yamlutils v0.25.3 h1:LKTJjCn/W1ZfMec0XDL4Vxh8kyAnv1orH5F2OREDUrg=
github.com/go-openapi/swag/yamlutils v0.25.3/go.mod h1:Y7QN6Wc5DOBXK14/xeo1cQlq0EA0wvLoSv13gDQoCao=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
@@ -147,10 +164,11 @@ github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaEL
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
@@ -160,7 +178,6 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU
github.com/golang/glog v1.1.1/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -175,16 +192,14 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81 h1:5lyLWsV+qCkoYqsKUDuycESh9DEIPVKN6iCFeL7ag50=
github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v23.5.9+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v25.9.23+incompatible h1:rGZKv+wOb6QPzIdkM2KxhBZCDrA0DeN6DNmRDrqIsQU=
github.com/google/flatbuffers v25.9.23+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -194,8 +209,8 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -205,13 +220,11 @@ github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hrfee/mediabrowser v0.3.24 h1:cT5+X3bZeaSBQFevMYkFIw6JJ8nW7Myvb+11a2/THMA=
github.com/hrfee/mediabrowser v0.3.24/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/hrfee/mediabrowser v0.3.33 h1:kjUFZc46hNhbOEU4xZNyhGVNjfZ5lENmX95Md1thxiA=
github.com/hrfee/mediabrowser v0.3.33/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -220,12 +233,10 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -245,16 +256,15 @@ github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b h1:xZ59n
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b/go.mod h1:uDd4sYVYsqcxAB8j+Q7uhL6IJCs/r1kxib1HV4bgOMg=
github.com/mailgun/errors v0.4.0 h1:6LFBvod6VIW83CMIOT9sYNp28TCX0NejFPP4dSX++i8=
github.com/mailgun/errors v0.4.0/go.mod h1:xGBaaKdEdQT0/FhwvoXv4oBaqqmVZz9P1XEnvD/onc0=
github.com/mailgun/mailgun-go/v4 v4.18.1 h1:ShNH/wzj7albTF/6le011FF+DGMd3azcSKL4iO9AgeI=
github.com/mailgun/mailgun-go/v4 v4.18.1/go.mod h1:+d4FCswFAukgYc1XtKK2IxOYaVxjVm8AN2z/5TBiT8M=
github.com/mailgun/mailgun-go/v4 v4.23.0 h1:jPEMJzzin2s7lvehcfv/0UkyBu18GvcURPr2+xtZRbk=
github.com/mailgun/mailgun-go/v4 v4.23.0/go.mod h1:imTtizoFtpfZqPqGP8vltVBB6q9yWcv6llBhfFeElZU=
github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
@@ -263,8 +273,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -276,23 +286,26 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274 h1:qli3BGQK0tYDkSEvZ/FzZTi9ZrOX86Q6CIhKLGc489A=
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a h1:VweslR2akb/ARhXfqSfRbj1vpWwYXf3eeAUyw/ndms0=
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/robert-nix/ansihtml v1.0.1 h1:VTiyQ6/+AxSJoSSLsMecnkh8i0ZqOEdiRl/odOc64fc=
github.com/robert-nix/ansihtml v1.0.1/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yDXcNeHHKZINE=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
@@ -312,6 +325,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -320,25 +334,28 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.2.0/go.mod h1:qlH2+W7zXGZkczuL+r2nEBR2JTT+/lX05Nn6vPhc7OI=
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.5.1/go.mod h1:1Bl9F/ZBpVWh22nY0zmYyASPO1lI/zIwRDrpZU+tv8Y=
github.com/swaggo/swag v1.6.7/go.mod h1:xDhTyuFIujYiN3DKWC/H/83xcfHp+UE/IzWWampG7Zc=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
@@ -347,8 +364,8 @@ github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6
github.com/timshannon/badgerhold/v4 v4.0.3 h1:W6pd2qckoXw2cl8eH0ZCV/9CXNaXvaM26tzFi5Tj+v8=
github.com/timshannon/badgerhold/v4 v4.0.3/go.mod h1:IkZIr0kcZLMdD7YJfW/G6epb6ZXHD/h0XR2BTk/VZg8=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 h1:flbMkdl6HxQkLs6DDhH1UkcnFpNBOu70391STjMS0O4=
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA=
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
@@ -356,8 +373,8 @@ github.com/ugorji/go v1.1.5-pre/go.mod h1:FwP/aQVg39TXzItUBMwnWp9T9gPQnXw4Poh4/o
github.com/ugorji/go/codec v0.0.0-20181022190402-e5e69e061d4f/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v1.1.5-pre/go.mod h1:tULtS6Gy1AE1yCENaw4Vb//HLH5njI2tfCQDUqRd8fI=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
@@ -369,31 +386,36 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/util v0.8.1 h1:Ga43cz6esQBYqcjZ/onRoVnYWoUwjWbsxVeJg2jOTSo=
go.mau.fi/util v0.8.1/go.mod h1:T1u/rD2rzidVrBLyaUdPpZiJdP/rsyi+aTzn0D+Q6wc=
go.mau.fi/util v0.9.3 h1:aqNF8KDIN8bFpFbybSk+mEBil7IHeBwlujfyTnvP0uU=
go.mau.fi/util v0.9.3/go.mod h1:krWWfBM1jWTb5f8NCa2TLqWMQuM81X7TGQjhMjBeXmQ=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.9.0/go.mod h1:np4EoPGzoPs3O67xUVNoPPcmSvsfOxNlNA4F4AC+0Eo=
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/trace v1.9.0/go.mod h1:2737Q0MuG8q1uILYm2YYVkAyLtOofiTNGg6VODnOiPo=
go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@@ -401,14 +423,14 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@@ -418,8 +440,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -441,8 +463,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -454,8 +476,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -483,8 +505,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -496,8 +518,10 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -512,8 +536,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -542,8 +566,8 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -563,6 +587,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
maunium.net/go/mautrix v0.21.1 h1:Z+e448jtlY977iC1kokNJTH5kg2WmDpcQCqn+v9oZOA=
maunium.net/go/mautrix v0.21.1/go.mod h1:7F/S6XAdyc/6DW+Q7xyFXRSPb6IjfqMb1OMepQ8C8OE=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
maunium.net/go/mautrix v0.26.0 h1:valc2VmZF+oIY4bMq4Cd5H9cEKMRe8eP4FM7iiaYLxI=
maunium.net/go/mautrix v0.26.0/go.mod h1:NWMv+243NX/gDrLofJ2nNXJPrG8vzoM+WUCWph85S6Q=

View File

@@ -140,7 +140,7 @@ func newHousekeepingDaemon(interval time.Duration, app *appContext) *GenericDaem
clearPWR := app.config.Section("captcha").Key("enabled").MustBool(false) && !app.config.Section("captcha").Key("recaptcha").MustBool(false)
if clearEmail || clearDiscord || clearTelegram || clearMatrix {
d.appendJobs(func(app *appContext) { app.jf.CacheExpiry = time.Now() })
d.appendJobs(func(app *appContext) { app.InvalidateJellyfinCache() })
}
if clearEmail {

View File

@@ -0,0 +1,16 @@
{{ if .discordEnabled }}
<div id="modal-discord" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkDiscord }}</span>
<p class="content mb-4"> {{ .discordSendPINMessage }}</p>
<h1 class="text-center text-2xl mb-2 pin"></h1>
<div class="row center">
<a class="my-5 hover:underline">
<span class="mr-2">{{ .strings.joinTheServer }}</span>
<span id="discord-invite"></span>
</a>
</div>
<span class="button ~info @low full-width center mt-4" id="discord-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}

View File

@@ -0,0 +1,18 @@
{{ if .matrixEnabled }}
<div id="modal-matrix" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkMatrix }}</span>
<p class="content mb-4"> {{ .strings.matrixEnterUser }}</p>
<input type="text" class="input ~neutral @high" placeholder="@user:riot.im" id="matrix-userid">
<div class="subheading link-center mt-4">
<span class="shield ~info mr-4">
<span class="icon">
<i class="ri-chat-3-line"></i>
</span>
</span>
{{ .matrixUser }}
</div>
<span class="button ~info @low full-width center mt-4" id="matrix-send">{{ .strings.submit }}</span>
</div>
</div>
{{ end }}

View File

@@ -0,0 +1,18 @@
{{ if .telegramEnabled }}
<div id="modal-telegram" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkTelegram }}</span>
<p class="content mb-4">{{ .strings.sendPIN }}</p>
<p class="text-center text-2xl mb-2 pin"></p>
<a class="subheading link link-center" href="{{ .telegramURL }}" target="_blank">
<span class="shield ~info mr-4">
<span class="icon">
<i class="ri-telegram-line"></i>
</span>
</span>
&#64;<span class="username">{{ .telegramUsername }}</span>
</a>
<span class="button ~info @low full-width center mt-4" id="telegram-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}

View File

@@ -1,52 +1,3 @@
{{ if .discordEnabled }}
<div id="modal-discord" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkDiscord }}</span>
<p class="content mb-4"> {{ .discordSendPINMessage }}</p>
<h1 class="text-center text-2xl mb-2 pin"></h1>
<div class="row center">
<a class="my-5 hover:underline">
<span class="mr-2">{{ .strings.joinTheServer }}</span>
<span id="discord-invite"></span>
</a>
</div>
<span class="button ~info @low full-width center mt-4" id="discord-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}
{{ if .telegramEnabled }}
<div id="modal-telegram" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkTelegram }}</span>
<p class="content mb-4">{{ .strings.sendPIN }}</p>
<p class="text-center text-2xl mb-2 pin"></p>
<a class="subheading link-center" href="{{ .telegramURL }}" target="_blank">
<span class="shield ~info mr-4">
<span class="icon">
<i class="ri-telegram-line"></i>
</span>
</span>
&#64;{{ .telegramUsername }}
</a>
<span class="button ~info @low full-width center mt-4" id="telegram-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}
{{ if .matrixEnabled }}
<div id="modal-matrix" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkMatrix }}</span>
<p class="content mb-4"> {{ .strings.matrixEnterUser }}</p>
<input type="text" class="input ~neutral @high" placeholder="@user:riot.im" id="matrix-userid">
<div class="subheading link-center mt-4">
<span class="shield ~info mr-4">
<span class="icon">
<i class="ri-chat-3-line"></i>
</span>
</span>
{{ .matrixUser }}
</div>
<span class="button ~info @low full-width center mt-4" id="matrix-send">{{ .strings.submit }}</span>
</div>
</div>
{{ end }}
{{ template "account-linking-discord.html" . }}
{{ template "account-linking-telegram.html" . }}
{{ template "account-linking-matrix.html" . }}

View File

@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
{{ template "syntaxhighlighting.html" . }}
<script>
window.usernameEnabled = {{ .username }};
window.langFile = JSON.parse({{ .language }});
@@ -68,11 +69,18 @@
</div>
</div>
<div id="modal-logs" class="modal">
<div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content content card">
<div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
<span class="heading">{{ .strings.logs }}<span class="modal-close">&times;</span></span>
<pre class="monospace" id="log-area"></pre>
</div>
</div>
<div id="modal-tasks" class="modal">
<div class="relative mx-auto my-[10%] w-min card flex flex-col gap-2">
<h1 class="heading">{{ .strings.tasks }}<span class="modal-close">&times;</span></h1>
<p class="content">{{ .strings.tasksDescription }}</p>
<div id="modal-tasks-list" class="flex flex-col gap-2"></div>
</div>
</div>
<div id="modal-modify-user" class="modal">
<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>
@@ -102,14 +110,18 @@
<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>
{{ if .ombiEnabled }}
<label class="switch">
<input type="checkbox" id="modify-user-ombi" checked>
<span>{{ .strings.applyOmbi }}</span>
</label>
{{ end }}
{{ if .jellyseerrEnabled }}
<label class="switch">
<input type="checkbox" id="modify-user-jellyseerr" checked>
<span>{{ .strings.applyJellyseerr }}</span>
</label>
{{ end }}
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
@@ -186,56 +198,60 @@
</form>
</div>
<div id="modal-extend-expiry" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm: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 flex flex-col gap-2" 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>
<div>
<span class="text-xl supra row py-1">{{ .strings.setExpiry }}</span>
<div class="row">
<input type="text" id="extend-expiry-text" class="input ~neutral @low mb-2 mt-4" placeholder="{{ .strings.enterExpiry }}">
</div>
<div class="flex flex-col gap-3">
<aside class="aside sm ~urge dark:~d_info @low unfocused" id="extend-expiry-date"></aside>
<div class="flex flex-col gap-2">
<span class="text-xl supra">{{ .strings.setExpiry }}</span>
<input type="text" id="extend-expiry-text" class="input ~neutral @low" placeholder="{{ .strings.enterExpiry }}">
</div>
<div id="extend-expiry-field-inputs">
<span class="text-xl supra row py-1">{{ .strings.extendExpiry }}</span>
<div class="row">
<div class="col">
<div id="extend-expiry-field-inputs" class="flex flex-col gap-2">
<span class="text-xl supra">{{ .strings.extendExpiry }}</span>
<div class="grid grid-cols-2 grid-rows-2 gap-2">
<div class="flex flex-col gap-2">
<label class="label supra" for="extend-expiry-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="extend-expiry-months">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<div class="flex flex-col gap-2">
<label class="label supra" for="extend-expiry-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="extend-expiry-days">
<option>0</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="flex flex-col gap-2">
<label class="label supra" for="extend-expiry-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="extend-expiry-hours">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<div class="flex flex-col gap-2">
<label class="label supra" for="extend-expiry-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="extend-expiry-minutes">
<option>0</option>
</select>
</div>
</div>
</div>
<label class="switch">
<input type="checkbox" id="expiry-use-previous">
<span>{{ .strings.extendFromPreviousExpiry }}</span>
<div class="tooltip left">
<i class="icon ri-information-line align-middle"></i>
<div class="content sm w-max">{{ .strings.extendFromPreviousExpiryDescription }}</div>
</div>
</label>
</div>
<label class="switch mb-4">
<label class="switch">
<input type="checkbox" id="expiry-extend-enable" checked>
<span>{{ .strings.sendDeleteNotificationEmail }}</span>
</label>
@@ -441,6 +457,7 @@
{{ end }}
<th>{{ .strings.from }}</th>
<th>{{ .strings.userProfilesLibraries }}</th>
<th></th>
<th><span class="button ~neutral @high" id="button-profile-create">{{ .strings.create }}</span></th>
</tr>
</thead>
@@ -449,26 +466,44 @@
</div>
</div>
</div>
<div id="modal-edit-profile" class="modal">
<form class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-2/3 card flex flex-col gap-2" id="form-edit-profile">
<span class="heading">{{ .strings.editProfile }} <span class="modal-close">&times;</span></span>
<p class="content">{{ .strings.editProfileDescription }}</p>
<div id="modal-edit-profile-editor"></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-add-profile" class="modal">
<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>
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 flex flex-col gap-2" id="form-add-profile" href="">
<h1 class="heading">{{ .strings.addProfile }} <span class="modal-close">&times;</span></h1>
<p class="content">{{ .strings.addProfileDescription }}</p>
<label class="flex flex-col gap-2">
<span class="supra">{{ .strings.addProfileNameOf }} </span>
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.name }}" id="add-profile-name">
<label>
<input type="text" class="field input ~neutral @high" placeholder="{{ .strings.name }}" id="add-profile-name">
</label>
<label class="flex flex-col gap-2">
<span class="supra">{{ .strings.user }}</span>
<div class="select ~neutral @low mt-4 mb-2">
<div class="select ~neutral @low">
<select id="add-profile-user"></select>
</div>
</label>
<label class="switch mb-4">
<label class="switch">
<input type="checkbox" id="add-profile-homescreen" checked>
<span>{{ .strings.addProfileStoreHomescreenLayout }}</span>
</label>
{{ if .jellyseerrEnabled }}
<label class="switch">
<input type="checkbox" id="add-profile-jellyseerr" checked>
<span>{{ .strings.addProfileStoreJellyseerr }}</span>
</label>
{{ end }}
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.create }}</span>
<span class="button ~urge @low w-full center supra submit">{{ .strings.create }}</span>
</label>
</form>
</div>
@@ -487,24 +522,7 @@
<span class="button ~urge @low full-width center mt-2" id="update-update">{{ .strings.update }}</span>
</div>
</div>
{{ if .telegramEnabled }}
<div id="modal-telegram" class="modal">
<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>
<a class="subheading link-center" id="telegram-link" target="_blank">
<span class="shield ~info mr-2">
<span class="icon">
<i class="ri-telegram-line"></i>
</span>
</span>
&#64;<span id="telegram-username">
</a>
<span class="button ~info @low full-width center mt-4" id="telegram-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}
{{ template "account-linking-telegram.html" . }}
{{ if .discordEnabled }}
<div id="modal-discord" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3">
@@ -541,7 +559,7 @@
<span class="button ~critical @low unfocused" id="logout-button">{{ .strings.logout }}</span>
{{ if .userPageEnabled }}
<div class="">
<a class="button ~info" href="{{ .pages.Base }}{{ .pages.MyAccount }}"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
<a class="button ~info" href="{{ .pages.Base }}{{ .pages.MyAccount }}/"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
</div>
{{ end }}
</div>
@@ -557,7 +575,7 @@
<div id="tab-invites" class="flex flex-col gap-4">
<div class="card @low dark:~d_neutral flex flex-col gap-2 overflow-visible invites">
<span class="heading">{{ .strings.invites }}</span>
<div id="invites"></div>
<div id="invites" class="flex flex-col gap-2"></div>
</div>
<div class="card @low dark:~d_neutral flex flex-col gap-2">
<span class="heading">{{ .strings.create }}</span>
@@ -715,16 +733,24 @@
</div>
</div>
<div id="tab-accounts" class="flex flex-col gap-4 unfocused">
<div class="card @low dark:~d_neutral accounts mb-4 overflow-visible">
<div class="card @low dark:~d_neutral accounts mb-4 overflow-visible flex flex-col gap-2">
<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">
<div class="flex flex-row align-middle w-full gap-2">
<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>
<span class="button ~neutral @low center inside-input rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<div class="tooltip left">
<button class="button ~info @low center h-full accounts-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAll }}</span>
</button>
<span class="content sm">{{ .strings.searchAllRecords }}</span>
</div>
<button class="button ~info @low" id="accounts-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
</div>
</div>
<div class="dropdown-display max-w-full">
@@ -733,13 +759,17 @@
</div>
</div>
</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 class="flex flex-row justify-between">
<div class="supra sm hidden" id="accounts-search-options-header">{{ .strings.searchOptions }}</div>
<div class="supra sm flex flex-row gap-2" id="accounts-record-counter"></div>
</div>
<div class="supra pt-1 pb-2 sm">{{ .strings.actions }}</div>
<div class="flex flex-row flex-wrap gap-3 mb-4">
<div class="flex flex-row gap-2 flex-wrap">
<div id="accounts-sort-by-field"></div>
<span id="accounts-filter-area" class="flex flex-row gap-2 flex-wrap"></span>
</div>
<div class="supra sm">{{ .strings.actions }}</div>
<div class="flex flex-row flex-wrap gap-3">
<button class="button ~neutral @low center accounts-load-all">{{ .strings.loadAll }}</button>
<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>
@@ -774,7 +804,7 @@
<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">
<div class="card @low accounts-header table-responsive">
<table class="table text-base leading-5">
<thead>
<tr>
@@ -802,19 +832,32 @@
</thead>
<tbody id="accounts-list"></tbody>
</table>
<div id="accounts-loader"></div>
<div class="unfocused h-[100%] my-3" id="accounts-not-found">
<div class="flex flex-col h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span>
<button class="button ~neutral @low accounts-search-clear">
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
<div class="flex flex-col gap-2 h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span>
<span class="text-sm font-light italic unfocused text-center" id="accounts-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
<div class="flex flex-row">
<button class="button ~neutral @low accounts-search-clear gap-1">
<i class="ri-close-line"></i>
<span>{{ .strings.clearSearch }}</span>
</button>
</div>
</div>
</div>
<div class="flex flex-row gap-2 justify-center">
<button class="button ~neutral @low" id="accounts-load-more">{{ .strings.loadMore }}</button>
<button class="button ~neutral @low accounts-load-all">{{ .strings.loadAll }}</button>
<button class="button ~info @low center accounts-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAllRecords }}</span>
</button>
</div>
</div>
</div>
</div>
<div id="tab-activity" class="flex flex-col gap-4 unfocused">
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible">
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible flex flex-col gap-2">
<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">
@@ -824,10 +867,17 @@
<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">
<div class="flex flex-row align-middle w-full gap-2">
<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>
<span class="button ~neutral @low center inside-input rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<div class="tooltip left">
<button class="button ~info @low center h-full activity-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAll }}</span>
</button>
<span class="content sm">{{ .strings.searchAllRecords }}</span>
</div>
<button class="button ~info @low" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
</div>
</div>
<div class="dropdown-display max-w-full">
@@ -836,37 +886,35 @@
</div>
</div>
</div>
<div class="flex flex-row justify-between pt-3 pb-2">
<div class="flex flex-row justify-between">
<div class="supra sm hidden" id="activity-search-options-header">{{ .strings.searchOptions }}</div>
<div class="supra sm flex flex-row gap-2">
<span id="activity-total-records"></span>
<span id="activity-loaded-records"></span>
<span id="activity-shown-records"></span>
</div>
<div class="supra sm flex flex-row gap-2" id="activity-record-counter"></div>
</div>
<div class="row -mx-2 mb-2">
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="activity-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
<span id="activity-filter-area"></span>
<div class="flex flex-row gap-2 flex-wrap">
<span id="activity-filter-area" class="flex flex-row gap-2 flex-wrap"></span>
</div>
<div class="my-2">
<div id="activity-card-list"></div>
<div id="activity-loader"></div>
<div class="unfocused h-[100%] my-3" id="activity-not-found">
<div class="flex flex-col h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span>
<span class="text-xl font-medium italic mb-3 unfocused" id="activity-keep-searching-description">{{ .strings.keepSearchingDescription }}</span>
<div class="flex flex-row">
<button class="button ~neutral @low activity-search-clear">
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
<button class="button ~neutral @low unfocused" id="activity-keep-searching">{{ .strings.keepSearching }}</button>
</div>
<div class="unfocused h-[100%]" id="activity-not-found">
<div class="flex flex-col gap-2 h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span>
<span class="text-sm font-light italic unfocused text-center" id="activity-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
<div class="flex flex-row">
<button class="button ~neutral @low activity-search-clear gap-1">
<i class="ri-close-line"></i>
<span>{{ .strings.clearSearch }}</span>
</button>
<button class="button ~neutral @low unfocused" id="activity-keep-searching">{{ .strings.keepSearching }}</button>
</div>
</div>
<div class="flex justify-center">
<button class="button m-2 ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button>
<button class="button m-2 ~neutral @low" id="activity-load-all">{{ .strings.loadAll }}</button>
</div>
</div>
<div id="activity-card-list"></div>
<div id="activity-loader"></div>
<div class="flex flex-row gap-2 justify-center">
<button class="button ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button>
<button class="button ~neutral @low activity-load-all">{{ .strings.loadAll }}</button>
<button class="button ~info @low center activity-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAllRecords }}</span>
</button>
</div>
</div>
</div>
@@ -881,26 +929,27 @@
</label>
</div>
<div class="flex flex-row justify-start md:justify-end gap-2 w-full">
<span class="button ~neutral @low gap-1 unfocused" id="settings-tasks"><i class="ri-calendar-schedule-line"></i>{{ .strings.tasks }}</span>
<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>
<span class="button ~info @low gap-1" id="settings-backups"><i class="icon ri-file-copy-line"></i>{{ .strings.backups }}</span>
<span class="button ~neutral @low gap-1" id="settings-restart"><i class="icon ri-restart-line"></i>{{ .strings.settingsRestart }}</span>
<span class="button ~urge @low unfocused gap-1" id="settings-save"><i class="icon ri-save-line"></i>{{ .strings.settingsSave }}</span>
</div>
</div>
<div class="flex flex-col md:flex-row gap-3">
<div class="md:card @low dark:~d_neutral flex md:flex flex-col gap-2 flex-1" id="settings-sidebar">
<div class="@low dark:~d_neutral flex md:flex flex-col gap-2" id="settings-sidebar">
<div class="flex flex-row justify-between">
<input type="search" class="field ~neutral @low input settings-section-button justify-between" id="settings-search" placeholder="{{ .strings.search }}">
<button class="button ~neutral @low center -ml-10 rounded-s-none settings-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></button>
</div>
<aside class="aside sm ~urge dark:~d_info @low" id="settings-message">Note: <span class="badge ~critical">*</span> indicates a required field, <span class="badge ~info dark:~d_warning">R</span> indicates changes require a restart.</aside>
<div id="settings-loader" class="flex flex-row flex-wrap gap-2">
<span class="button ~neutral @low justify-center grow" id="setting-about"><span class="flex">{{ .strings.aboutProgram }} <i class="ri-information-line ml-2"></i></span></span>
<a class="button ~urge dark:~d_info @low justify-center grow" target="_blank" href="https://wiki.jfa-go.com"><span class="flex">{{ .strings.wiki }} <i class="ri-book-shelf-line ml-2"></i></a>
<span class="button ~neutral @low justify-center grow" id="setting-profiles"><span class="flex">{{ .strings.userProfiles }} <i class="ri-user-line ml-2"></i></span></span>
</div>
<div class="flex md:flex flex-col gap-2 overflow-y-scroll" id="settings-sidebar-items"></div>
</div>
<div class="card ~neutral @low overflow flex-1" id="settings-panel">
<div class="card ~neutral @low overflow flex-1 grow" id="settings-panel">
<div class="settings-section unfocused h-[100%]" id="settings-not-found">
<div class="flex flex-col h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-2">{{ .strings.noResultsFound }}</span>

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<!--- This CSS is inlined so we should keep this here! -->
<link inline rel="stylesheet" type="text/css" href="web/css/v3bundle.css">
<link inline rel="stylesheet" type="text/css" href="web/css/v0.6.0bundle.css">
{{ template "header.html" . }}
<title>Crash report</title>
</head>

View File

@@ -27,6 +27,7 @@
window.reCAPTCHASiteKey = "{{ .reCAPTCHASiteKey }}";
window.userPageEnabled = {{ .userPageEnabled }};
window.userPageAddress = "{{ .userPageAddress }}";
window.collectEmail = {{ .collectEmail }};
{{ if index . "customSuccessCard" }}
window.customSuccessCard = {{ .customSuccessCard }};
{{ else }}

View File

@@ -68,8 +68,10 @@
<input type="text" class="input ~neutral @high mt-2 mb-4" placeholder="{{ .strings.username }}" id="create-username" aria-label="{{ .strings.username }}">
</label>
<label class="label supra" for="create-email">{{ .strings.emailAddress }}</label>
<input type="email" class="input ~neutral @high mt-2 mb-4" placeholder="{{ .strings.emailAddress }}" id="create-email" aria-label="{{ .strings.emailAddress }}" value="{{ .email }}">
<div>
<label class="label supra" for="create-email">{{ .strings.emailAddress }}</label>
<input type="email" class="input ~neutral @high mt-2 mb-4" placeholder="{{ .strings.emailAddress }}" id="create-email" aria-label="{{ .strings.emailAddress }}" value="{{ .email }}">
</div>
{{ if .telegramEnabled }}
<span class="button ~info @low full-width center mb-4" id="link-telegram">{{ .strings.linkTelegram }} {{ if .telegramRequired }}({{ .strings.required }}){{ end }}</span>
{{ end }}

View File

@@ -15,6 +15,8 @@
<script>
window.pages = {
"Base": "{{ .pages.Base }}",
"TrueBase": "{{ .pages.TrueBase }}",
"ExternalURI": "{{ .pages.ExternalURI }}",
"Current": "{{ .pages.Current }}",
"Admin": "{{ .pages.Admin }}",
"MyAccount": "{{ .pages.MyAccount }}",

View File

@@ -12,7 +12,7 @@
{{ template "lang-select.html" . }}
</div>
<span class="button ~warning" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
</div>
</div>
<div class="card lg:container sectioned ~neutral @low flex flex-col gap-4 justify-between items-center">
<img class="w-[105%] max-w-none" src="banner.svg" alt="jfa-go" />
<span class="heading welcome">{{ .lang.StartPage.welcome }}</span>
@@ -106,7 +106,7 @@
<p class="support">{{ .lang.General.urlBaseNotice }}</p>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.General.externalURL }}</span>
<span>{{ .lang.General.externalURL }} ({{ .lang.Strings.required }})</span>
<input type="text" class="input ~neutral @low" id="ui-jfa_url" placeholder="https://jellyf.in/mysubfolder">
<p class="support">{{ .lang.General.externalURLNotice }}</p>
</label>
@@ -557,12 +557,13 @@
<div class="card lg:container sectioned ~neutral @low unfocused">
<section class="section flex flex-col gap-2 justify-center items-center">
<span class="heading">{{ .lang.EndPage.finished }}</span>
<p class="content text-center">{{ .lang.EndPage.restartMessage }} {{ .lang.EndPage.urlChangedNotice }}</p>
<p class="content text-center">{{ .lang.EndPage.moreFeatures }} {{ .lang.EndPage.restartReload }} {{ .lang.EndPage.ifFailedLoad }}</p>
</section>
<section class="section w-full ~neutral footer flex flex-row justify-center items-center gap-2">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<span class="button ~urge @low" id="restart">{{ .lang.Strings.submit }}</span>
<span class="button ~urge @low unfocused" id="refresh">{{ .lang.EndPage.refreshPage }}</span>
<a class="button ~urge @low flex flex-col gap-0.5 unfocused" id="refresh-internal"></a>
<a class="button ~urge @low flex flex-col gap-0.5 unfocused" id="refresh-external"></a>
</div>
</div>
</div>

View File

@@ -0,0 +1,3 @@
<link rel="stylesheet" type="text/css" href="{{ .pages.Base }}/css/{{ .cssVersion }}highlightjs-light.css" data-theme="light">
<link rel="stylesheet" type="text/css" href="{{ .pages.Base }}/css/{{ .cssVersion }}highlightjs-dark.css" data-theme="dark">
<link rel="stylesheet" type="text/css" href="{{ .pages.Base }}/css/{{ .cssVersion }}code-input.css">

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 52 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 59 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 73 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -6,22 +6,20 @@ package main
import (
"embed"
"io/fs"
"log"
"github.com/hrfee/jfa-go/logger"
)
const binaryType = "internal"
func BuildTagsExternal() {}
//go:embed data data/html data/web data/web/css data/web/js
//go:embed build/data build/data/html build/data/web build/data/web/css build/data/web/js
var loFS embed.FS
//go:embed lang/common lang/admin lang/email lang/form lang/setup lang/pwreset lang/telegram
var laFS embed.FS
var langFS rewriteFS
var localFS rewriteFS
type rewriteFS struct {
fs embed.FS
prefix string
@@ -38,8 +36,8 @@ func FSJoin(elem ...string) string {
return out[:len(out)-1]
}
func loadFilesystems() {
func loadFilesystems(rootDir string, logger *logger.Logger) {
langFS = rewriteFS{laFS, "lang/"}
localFS = rewriteFS{loFS, "data/"}
log.Println("Using internal storage")
localFS = rewriteFS{loFS, "build/data/"}
logger.Println("Using internal storage")
}

View File

@@ -2,14 +2,20 @@ package main
import (
"strconv"
"strings"
"time"
"github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
)
type JellyseerrInitialSyncStatus struct {
Done bool
}
// Ensure the Jellyseerr cache is up to date before calling.
func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
user, imported, err := app.js.GetOrImportUser(jfID)
user, imported, err := app.js.GetOrImportUser(jfID, true)
if err != nil {
app.debug.Printf(lm.FailedImportUser, lm.Jellyseerr, jfID, err)
return
@@ -28,7 +34,11 @@ func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
if ok && email.Addr != "" && user.Email != email.Addr {
err = app.js.ModifyMainUserSettings(jfID, jellyseerr.MainUserSettings{Email: email.Addr})
if err != nil {
app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, jfID, err)
if strings.Contains(err.Error(), "INVALID_EMAIL") {
app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, jfID, err.Error()+"\""+email.Addr+"\"")
} else {
app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, jfID, err)
}
} else {
contactMethods[jellyseerr.FieldEmailEnabled] = email.Contact
}
@@ -45,7 +55,7 @@ func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
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.FieldTelegram] = strconv.FormatInt(u.ChatID, 10)
contactMethods[jellyseerr.FieldTelegramEnabled] = tgUser.Contact
}
}
@@ -58,19 +68,30 @@ func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
}
func (app *appContext) SynchronizeJellyseerrUsers() {
jsSync := JellyseerrInitialSyncStatus{}
app.storage.db.Get("jellyseerr_inital_sync_status", &jsSync)
if jsSync.Done {
return
}
users, err := app.jf.GetUsers(false)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
return
}
app.js.ReloadCache()
// 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)
}
// Don't run again until this flag is unset
// Stored in the DB as it's not something the user needs to see.
app.storage.db.Upsert("jellyseerr_inital_sync_status", JellyseerrInitialSyncStatus{true})
}
// Not really a normal daemon, since it'll only fire once when the feature is enabled.
func newJellyseerrDaemon(interval time.Duration, app *appContext) *GenericDaemon {
d := NewGenericDaemon(interval, app,
func(app *appContext) {
@@ -78,5 +99,12 @@ func newJellyseerrDaemon(interval time.Duration, app *appContext) *GenericDaemon
},
)
d.Name("Jellyseerr import")
jsSync := JellyseerrInitialSyncStatus{}
app.storage.db.Get("jellyseerr_inital_sync_status", &jsSync)
if jsSync.Done {
return nil
}
return d
}

View File

@@ -25,7 +25,9 @@ type Jellyseerr struct {
server, key string
header map[string]string
httpClient *http.Client
userCache map[string]User // Map of jellyfin IDs to users
userCache map[string]User // Map of jellyfin IDs to users
jsToJfID map[int64]string // Map of jellyseerr IDs to jellyfin IDs
invalidatedUsers map[int64]bool // Map of jellyseerr IDs needing a re-caching
cacheExpiry time.Time
cacheLength time.Duration
timeoutHandler co.TimeoutHandler
@@ -51,6 +53,8 @@ func NewJellyseerr(server, key string, timeoutHandler co.TimeoutHandler) *Jellys
cacheExpiry: time.Now(),
timeoutHandler: timeoutHandler,
userCache: map[string]User{},
jsToJfID: map[int64]string{},
invalidatedUsers: map[int64]bool{},
LogRequestBodies: false,
}
}
@@ -92,8 +96,9 @@ func (js *Jellyseerr) req(mode string, uri string, data any, queryParams url.Val
var responseText string
defer resp.Body.Close()
if response || err != nil {
responseText, err = js.decodeResp(resp)
if err != nil {
var decodeErr error
responseText, decodeErr = js.decodeResp(resp)
if decodeErr != nil {
return responseText, resp.StatusCode, err
}
}
@@ -157,6 +162,7 @@ func (js *Jellyseerr) ImportFromJellyfin(jfIDs ...string) ([]User, error) {
for _, u := range data {
if u.JellyfinUserID != "" {
js.userCache[u.JellyfinUserID] = u
js.jsToJfID[u.ID] = u.JellyfinUserID
}
}
return data, err
@@ -165,8 +171,13 @@ func (js *Jellyseerr) ImportFromJellyfin(jfIDs ...string) ([]User, error) {
func (js *Jellyseerr) getUsers() error {
if js.cacheExpiry.After(time.Now()) {
return nil
if len(js.invalidatedUsers) != 0 {
return js.getInvalidatedUsers()
}
}
js.cacheExpiry = time.Now().Add(js.cacheLength)
userCache := map[string]User{}
jsToJfID := map[int64]string{}
pageCount := 1
pageIndex := 0
for {
@@ -178,7 +189,8 @@ func (js *Jellyseerr) getUsers() error {
if u.JellyfinUserID == "" {
continue
}
js.userCache[u.JellyfinUserID] = u
userCache[u.JellyfinUserID] = u
jsToJfID[u.ID] = u.JellyfinUserID
}
pageCount = res.Page.Pages
pageIndex++
@@ -186,6 +198,10 @@ func (js *Jellyseerr) getUsers() error {
break
}
}
js.userCache = userCache
js.jsToJfID = jsToJfID
js.invalidatedUsers = map[int64]bool{}
return nil
}
@@ -206,15 +222,15 @@ func (js *Jellyseerr) getUserPage(page int) (GetUsersDTO, error) {
}
func (js *Jellyseerr) MustGetUser(jfID string) (User, error) {
u, _, err := js.GetOrImportUser(jfID)
u, _, err := js.GetOrImportUser(jfID, false)
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) {
func (js *Jellyseerr) GetOrImportUser(jfID string, fixedCache bool) (u User, imported bool, err error) {
imported = false
u, err = js.GetExistingUser(jfID)
u, err = js.GetExistingUser(jfID, fixedCache)
if err == nil {
return
}
@@ -232,15 +248,24 @@ func (js *Jellyseerr) GetOrImportUser(jfID string) (u User, imported bool, err e
return
}
func (js *Jellyseerr) GetExistingUser(jfID string) (u User, err error) {
func (js *Jellyseerr) GetExistingUser(jfID string, fixedCache bool) (u User, err error) {
js.getUsers()
ok := false
err = nil
if u, ok = js.userCache[jfID]; ok {
u, ok = js.userCache[jfID]
_, invalidated := js.invalidatedUsers[u.ID]
if ok && !invalidated {
return
}
js.cacheExpiry = time.Now()
js.getUsers()
if invalidated {
err = js.getInvalidatedUsers()
if err != nil {
return
}
} else if !fixedCache {
js.cacheExpiry = time.Now()
js.getUsers()
}
if u, ok = js.userCache[jfID]; ok {
err = nil
return
@@ -253,7 +278,7 @@ func (js *Jellyseerr) getUser(jfID string) (User, error) {
if js.AutoImportUsers {
return js.MustGetUser(jfID)
}
return js.GetExistingUser(jfID)
return js.GetExistingUser(jfID, false)
}
func (js *Jellyseerr) Me() (User, error) {
@@ -267,6 +292,25 @@ func (js *Jellyseerr) Me() (User, error) {
return data, err
}
func (js *Jellyseerr) getInvalidatedUsers() error {
// FIXME: Collect errors and return
for jellyseerrID, _ := range js.invalidatedUsers {
jfID, ok := js.jsToJfID[jellyseerrID]
if !ok {
continue
}
user, err := js.UserByID(jellyseerrID)
if err != nil {
continue
}
js.userCache[jfID] = user
js.jsToJfID[jellyseerrID] = jfID
delete(js.invalidatedUsers, jellyseerrID)
}
return nil
}
func (js *Jellyseerr) GetPermissions(jfID string) (Permissions, error) {
data := permissionsDTO{Permissions: -1}
u, err := js.getUser(jfID)
@@ -294,6 +338,7 @@ func (js *Jellyseerr) SetPermissions(jfID string, perm Permissions) error {
}
u.Permissions = perm
js.userCache[jfID] = u
js.jsToJfID[u.ID] = jfID
return nil
}
@@ -309,6 +354,7 @@ func (js *Jellyseerr) ApplyTemplateToUser(jfID string, tmpl UserTemplate) error
}
u.UserTemplate = tmpl
js.userCache[jfID] = u
js.jsToJfID[u.ID] = jfID
return nil
}
@@ -325,8 +371,7 @@ func (js *Jellyseerr) ModifyUser(jfID string, conf map[UserField]any) error {
if err != nil {
return err
}
// Lazily just invalidate the cache.
js.cacheExpiry = time.Now()
js.invalidatedUsers[u.ID] = true
return nil
}
@@ -412,12 +457,19 @@ func (js *Jellyseerr) ModifyMainUserSettings(jfID string, conf MainUserSettings)
if err != nil {
return err
}
return js.ModifyMainUserSettingsByID(u.ID, conf)
}
_, _, err = js.post(fmt.Sprintf(js.server+"/user/%d/settings/main", u.ID), conf, false)
func (js *Jellyseerr) ModifyMainUserSettingsByID(jellyseerrID int64, conf MainUserSettings) error {
_, _, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/main", jellyseerrID), conf, false)
if err != nil {
return err
}
// Lazily just invalidate the cache.
js.cacheExpiry = time.Now()
js.invalidatedUsers[jellyseerrID] = true
return nil
}
func (js *Jellyseerr) ReloadCache() error {
js.cacheExpiry = time.Now()
return js.getUsers()
}

11
lang.go
View File

@@ -1,6 +1,10 @@
package main
import "github.com/hrfee/jfa-go/common"
import (
"fmt"
"github.com/hrfee/jfa-go/common"
)
type langMeta struct {
Name string `json:"name"`
@@ -108,6 +112,7 @@ type emailLang struct {
WelcomeEmail langSection `json:"welcomeEmail"`
EmailConfirmation langSection `json:"emailConfirmation"`
UserExpired langSection `json:"userExpired"`
ExpiryReminder langSection `json:"expiryReminder"`
}
type setupLangs map[string]setupLang
@@ -165,7 +170,7 @@ func (ts *telegramLangs) getOptions() []common.Option {
}
type langSection map[string]string
type tmpl map[string]string
type tmpl = map[string]any
func templateString(text string, vals tmpl) string {
start, previousEnd := -1, -1
@@ -182,7 +187,7 @@ func templateString(text string, vals tmpl) string {
start = -1
continue
}
out += text[previousEnd+1:start] + val
out += text[previousEnd+1:start] + fmt.Sprint(val)
previousEnd = i
start = -1
}

View File

@@ -136,7 +136,37 @@
"enableReferrals": "Empfehlungen aktivieren",
"disableReferrals": "Empfehlungen deaktivieren",
"userLabel": "Benutzer Label",
"noResultsFound": "Keine Resultate gefunden"
"noResultsFound": "Keine Resultate gefunden",
"buildTime": "Erstellungszeit",
"accountDisabled": "Konto deaktiviert: {user}",
"accountReEnabled": "Konto reaktiviert: {user}",
"accountExpired": "Konto abgelaufen: {user}",
"accountWillExpire": "Konto läuft ab am {date}.",
"expirationBasedOn": "Angegebenes Datum basiert auf dem ersten Benutzer.",
"userDeleted": "Benutzer wurde gelöscht.",
"userDisabled": "Benutzer wurde deaktiviert",
"inviteCreated": "Einladung erstellt: {invite}",
"inviteDeleted": "Einladung gelöscht: {invite}",
"builtBy": "Erstellt von",
"accountLinked": "{contactMethod} verknüpft: {user}",
"referrer": "Empfehlungsgeber",
"loginNotAdmin": "Kein Administrator?",
"jellyseerrProfile": "Jellyseerr-Benutzerprofil",
"jellyseerrUserDefaultsDescription": "Erstellen Sie einen Jellyseerr-Benutzer und konfigurieren Sie ihn. Wählen Sie ihn anschließend unten aus. Seine Einstellungen/Berechtigungen werden gespeichert und auf neue Jellyseerr-Benutzer angewendet, die von jfa-go erstellt werden, wenn dieses Profil ausgewählt ist.",
"sortDirection": "Sortierreihenfolge",
"searchAll": "Alle suchen/sortieren",
"searchAllRecords": "Alle Datensätze suchen/sortieren (auf dem Server)",
"postSignupCard": "Hilfekarte nach der Anmeldung",
"postSignupCardDescription": "Karte, die dem Benutzer nach der Anmeldung angezeigt wird. Überschreibt die „Erfolgsmeldung“. Wird durch die Einstellung „Automatische Weiterleitung bei Erfolg“ überschrieben.",
"buildTags": "Build Tags",
"accountUnlinked": "{contactMethod} entfernt: {user}",
"accountResetPassword": "{user} hat sein Passwort zurückgesetzt",
"accountChangedPassword": "{user} hat sein Passwort geändert",
"accountCreated": "Konto erstellt: {user}",
"accountDeleted": "Konto gelöscht: {user}",
"applyConfigurationAndPolicy": "Jellyfin Konfiguration/Richtlinie anwenden",
"applyOmbi": "Ombi -Profil anwenden (falls verfügbar)",
"applyJellyseerr": "Jellyseerr-Profil anwenden (falls verfügbar)"
},
"notifications": {
"changedEmailAddress": "E-Mail-Adresse von {n} geändert.",

View File

@@ -39,11 +39,15 @@
"commitNoun": "Commit",
"newUser": "New User",
"profile": "Profile",
"editProfile": "Edit profile",
"editProfileDescription": "For large changes, it is recommended you modify settings in Jellyfin/Jellyseerr/Ombi and re-generate the profile, but you can also make direct changes here. Please use caution when editing.",
"unknown": "Unknown",
"label": "Label",
"userLabel": "User Label",
"userLabelDescription": "Label to apply to users created with this invite.",
"logs": "Logs",
"tasks": "Tasks",
"tasksDescription": "Tasks are large actions that may be run periodically in the background. You can manually trigger them here if you wish.",
"announce": "Announce",
"templates": "Templates",
"subject": "Subject",
@@ -56,8 +60,10 @@
"unlink": "Unlink Account",
"deleted": "Deleted",
"disabled": "Disabled",
"run": "Run",
"sendPWR": "Send Password Reset",
"noResultsFound": "No Results Found",
"noResultsFoundLocally": "Only loaded records were searched. You can load more, or perform the search over all records on the server.",
"keepSearching": "Keep Searching",
"keepSearchingDescription": "Only the current loaded activities were searched. Click below if you wish to search all activities.",
"contactThrough": "Contact through:",
@@ -65,6 +71,8 @@
"setExpiry": "Set expiry",
"removeExpiry": "Remove expiry",
"enterExpiry": "Enter an expiry",
"extendFromPreviousExpiry": "Extend from previous expiry date (if possible)",
"extendFromPreviousExpiryDescription": "If a record of an expired user's expiry time is found in the activity log, expiry will be extended from then, rather than the current time, unless the new expiry date would have already passed.",
"sendPWRManual": "User {n} has no method of contact, press copy to get a link to send to them.",
"sendPWRSuccess": "Password reset link sent.",
"sendPWRSuccessManual": "If the user hasn't received it, press copy to get a link to manually send to them.",
@@ -112,6 +120,7 @@
"addProfileDescription": "Create a Jellyfin user and configure it, then select it below. When this profile is applied to an invite, new users will be created with the settings.",
"addProfileNameOf": "Profile Name",
"addProfileStoreHomescreenLayout": "Store homescreen layout",
"addProfileStoreJellyseerr": "Create Jellyseerr profile",
"inviteNoUsersCreated": "None yet!",
"inviteUsersCreated": "Created users",
"inviteNoProfile": "No Profile",
@@ -136,6 +145,8 @@
"filters": "Filters",
"clickToRemoveFilter": "Click to remove this filter.",
"clearSearch": "Clear search",
"searchAll": "Search/sort all",
"searchAllRecords": "Search/sort all records (on server)",
"actions": "Actions",
"searchOptions": "Search Options",
"matchText": "Match Text",
@@ -190,6 +201,9 @@
"totalRecords": "{n} Total Records",
"loadedRecords": "{n} Loaded",
"shownRecords": "{n} Shown",
"selectedRecords": "{n} Selected",
"allMatchingSelected": "All matching results selected.",
"allLoadedSelected": "All loaded matching results selected. Click again to load all.",
"backups": "Backups",
"backupsDescription": "Backups of the database can be made, restored, or downloaded from here.",
"backupsFormatNote": "Only backup files with the standard name format will be shown here. To use any other, upload the backup manually.",
@@ -203,7 +217,9 @@
"backupCanBeFound": "The backup can be found on the server at {filepath}.",
"backupCanDownload": "Alternatively, click below to download the backup.",
"wikiPage": "Wiki Page",
"wiki": "Wiki"
"wiki": "Wiki",
"restartRequired": "Restart required",
"required": "Required"
},
"notifications": {
"pathCopied": "Full path copied to clipboard.",
@@ -231,6 +247,7 @@
"errorBlankFields": "Fields were left blank",
"errorDeleteProfile": "Failed to delete profile {n}",
"errorLoadProfiles": "Failed to load profiles.",
"errorLoadProfile": "Failed to load profile.",
"errorCreateProfile": "Failed to create profile {n}",
"errorSavedProfile": "Failed to save profile {n}",
"errorSetDefaultProfile": "Failed to set default profile.",
@@ -248,8 +265,10 @@
"errorNoReferralTemplate": "Profile doesn't contain referral template, add one in settings.",
"errorLoadActivities": "Failed to load activities.",
"errorInvalidDate": "Date is invalid.",
"errorInvalidJSON": "Invalid JSON.",
"updateAvailable": "A new update is available, check settings.",
"noUpdatesAvailable": "No new updates available."
"noUpdatesAvailable": "No new updates available.",
"runTask": "Triggered task."
},
"quantityStrings": {
"modifySettingsFor": {

View File

@@ -136,7 +136,20 @@
"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."
"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.",
"settingsHiddenDependency": "Los ajustes que coinciden son escondidos porque dependen del valor de otro ajuste",
"actions": "Acciones",
"applyConfigurationAndPolicy": "Aplica la póliza/configuración de Jellyfin",
"jellyseerrUserDefaultsDescription": "Crea un usuario de Jellyseer y configúralo, después selezionalo abajo. Los ajustes/permisos serán almacenados y aplicados a los usuarios nuevos de jellyseerr creados por jfa-go cuando este perfil está seleccionado.",
"postSignupCard": "Tarjeta de ayuda post registro",
"loginNotAdmin": "¿No eres un administrador?",
"accountLinked": "{Metododecontacto} vinculado a: {usuario}",
"applyOmbi": "Aplica el perfil de Ombi(si está disponible)",
"applyJellyseerr": "Aplica el perfil de jellyseer(si está disponible)",
"jellyseerrProfile": "Perfilé de usuario de Jellyseerr",
"referrer": "Referente",
"accountUnlinked": "{metododecontacto} removido de: {usuario}",
"accountResetPassword": "{usuario} restableció su contraseña"
},
"notifications": {
"changedEmailAddress": "Se cambió la dirección de correo electrónico de {n}.",

View File

@@ -93,14 +93,14 @@
"notifyEvent": "Értesítés ekkor:",
"notifyInviteExpiry": "Lejáratkor",
"notifyUserCreation": "Használatkor",
"sendPIN": "",
"searchDiscordUser": "",
"findDiscordUser": "",
"linkMatrixDescription": "",
"matrixHomeServer": "",
"saveAsTemplate": "",
"deleteTemplate": "",
"templateEnterName": "",
"sendPIN": "Kérd meg a felhasználókat, hogy küldjék el a PIN-t a botnak.",
"searchDiscordUser": "Kezd el írni adiscord felhasználó nevet a keresés indításához.",
"findDiscordUser": "Discord felhasználó keresése",
"linkMatrixDescription": "Add meg a felhasználó nevét és jelszavát hogy botként tudd használni. A beküldés után az alkalmazás újra fog indulni.",
"matrixHomeServer": "Otthoni szerver címe",
"saveAsTemplate": "Mentés sablonként",
"deleteTemplate": "Sablon törlése",
"templateEnterName": "Adj meg egy nevet a sablon mentéséhez.",
"unlink": "Fiók leválasztása",
"after": "Utánna",
"before": "Elötte",
@@ -112,7 +112,40 @@
"matchText": "Eggyező szöveg",
"jellyfinID": "Jellyfin azonosító",
"userPageLogin": "Felhasználói oldal: Bejelentkezés",
"clickToRemoveFilter": "Szűrő eltávolítása."
"clickToRemoveFilter": "Szűrő eltávolítása.",
"deleted": "Törölt",
"invite": "Meghívás",
"activity": "Aktivitás",
"userLabel": "Felhasználói címke",
"userLabelDescription": "Ezzel a meghívóval létrehozott felhasználókra alkalmazandó címke.",
"noResultsFoundLocally": "A keresés csak a betöltött adatokon meg végbe. Betölthetsz több adatot is vagy kereshetsz az összes adaton.",
"keepSearchingDescription": "Csak a betöltött tevékenységek között futott le a keresés. Kattints ide ha az összes tevékenység között szeretnél keresni.",
"enableReferralsDescription": "Adjon a felhasználóknak egy meghívóhoz hasonló személyes hivatkozási linket, amelyet elküldhet barátainak/családjának. Ez származhat a profiljukban található ajánlói sablonból vagy egy meglévő meghívóból.",
"enableReferralsProfileDescription": "Adj az ezzel a profillal létrehozott felhasználóknak egy személyre szabott ajánlói linket, hasonlóan egy meghívóhoz, amelyet elküldhetnek barátaiknak és családtagjaiknak. Hozz létre egy meghívót a kívánt beállításokkal, majd válaszd ki itt. Minden ajánlás ezután ezen a meghívón alapul majd. A meghívót törölheted, ha kész vagy.",
"postSignupCardDescription": "A felhasználónak a regisztráció után megjelenő kártya. Felülírja a „Sikerüzenet” beállítást. Felülírja az „Automatikus átirányítás siker esetén” beállítás.",
"buildTime": "Készítési idő",
"accessJFA": "jfa-go hozzáférés",
"accessJFASettings": "Nem módosítható, mert a Beállítások > Általános menüpontban engedélyezve van a „Csak rendszergazdai felhasználók” vagy az „Összes Jellyfin felhasználó bejelentkezése” lehetőség.",
"disabled": "Tiltva",
"userPagePage": "Felhasználói oldal: oldal",
"noResultsFound": "Nincs megjeleníthető adat",
"settingsHiddenDependency": "Az egyező beállítások rejtve vannak, mert egy másik beállítás értékétől függenek:",
"settingsDependsOn": "{setting}: ettől függ: {dependency}",
"settingsAdvancedMode": "{setting}: Haladó beállítások engedélyezése szükséges",
"keepSearching": "Keresés folytatása",
"removeExpiry": "Lejárat eltávolítása",
"enterExpiry": "Lejárati dátum megadása",
"enableReferrals": "Hivatkozások engedélyezése",
"disableReferrals": "Hivatkozások tiltása",
"useInviteExpiry": "Lejárat beállítása profilból vagy meghívóból",
"useInviteExpiryNote": "Alapértelmezés szerint a meghívók 90 nap után lejárnak, de a felhasználó megújíthatja őket. Engedélyezze, ha azt szeretné, hogy a megadott idő lejárta után a meghívás letiltásra kerüljön.",
"settingsMaybeUnderAdvanced": "Tipp: Lehet hogy megtalálod amit keresel ha bekapcsolod a haladó beállíításokat.",
"jellyseerrProfile": "Jellyseer felhasználói profil",
"jellyseerrUserDefaultsDescription": "Hozz létre egy Jellyseerr felhasználót, állítsd be, majd válaszd ki lent. A beállításait/engedélyeit a rendszer tárolja és alkalmazza a jfa-go által létrehozott új Jellyseerr felhasználókra, amikor ezt a profilt kiválasztod.",
"sortDirection": "Rendezés iránya",
"searchAll": "Összes keresés/rendezés",
"searchAllRecords": "Keresés/rendezés az összes adaton(a szerveren lévő)",
"builtBy": "Készítette"
},
"notifications": {
"changedEmailAddress": "",

View File

@@ -76,7 +76,8 @@
"download": "Unduh",
"inviteMonths": "Bulan",
"inviteDuration": "Durasi undangan",
"activity": "Aktivitas"
"activity": "Aktivitas",
"disabled": "Dihentikan"
},
"notifications": {
"changedEmailAddress": "Alamat email {n} diubah.",

View File

@@ -18,7 +18,7 @@
"create": "",
"apply": "",
"select": "",
"name": "",
"name": "Nome",
"date": "",
"setExpiry": "",
"updates": "",
@@ -117,7 +117,8 @@
"userPageLogin": "",
"userPagePage": "",
"buildTime": "",
"builtBy": ""
"builtBy": "",
"disabled": "Disabilitato"
},
"notifications": {
"changedEmailAddress": "",

314
lang/admin/th-TH.json Normal file
View File

@@ -0,0 +1,314 @@
{
"meta": {
"name": "ภาษาไทย (TH)"
},
"strings": {
"invites": "คำเชิญ",
"invite": "คำเชิญ",
"accounts": "บัญชี",
"activity": "กิจกรรม",
"settings": "ตั้งค่า",
"inviteMonths": "เดือน",
"inviteDays": "วัน",
"inviteHours": "ชั่วโมง",
"inviteMinutes": "นาที",
"inviteNumberOfUses": "จำนวนผู้ใช้",
"inviteDuration": "ระยะเวลาคำเชิญ",
"warning": "คำเตือน",
"inviteInfiniteUsesWarning": "คำเชิญที่รับผู้ใช้ไม่จำกัดอาจถูกใช้ในทางที่ผิดได้",
"inviteSendToEmail": "ส่งไปยัง",
"create": "สร้าง",
"apply": "ใช้",
"select": "เลือก",
"name": "ชื่อ",
"date": "วันที่",
"updates": "อัปเดต",
"update": "อัปเดต",
"download": "ดาวน์โหลด",
"search": "ค้นหา",
"advancedSettings": "การตั้งค่าขั้นสูง",
"lastActiveTime": "ใช้งานล่าสุด",
"from": "จาก",
"after": "หลัง",
"before": "ก่อน",
"user": "ผู้ใช้",
"userExpiry": "ผู้ใช้หมดอายุ",
"userExpiryDescription": "ระยะเวลาจำนวนหนึ่งหลังจากสมัคร jfa-go จะลบ/ปิดใช้งาน บัญชีให้ คุณสามารถเปลี่ยนวิธีการจัดการได้ในการตั้งค่า",
"aboutProgram": "เกี่ยวกับ",
"version": "เวอร์ชั่น",
"commitNoun": "Commit",
"newUser": "ผู้ใช้ใหม่",
"profile": "โปรไฟล์",
"unknown": "ไม่รู้จัก",
"label": "ป้าย",
"userLabel": "ป้ายผู้ใช้",
"userLabelDescription": "ป้ายจะถูกใช้เมื่อผู้ใช้สมัครผ่านคำเชิญนี้",
"logs": "บันทึก",
"announce": "ประกาศ",
"templates": "แม่แบบ",
"subject": "หัวเรื่อง",
"message": "ข้อความ",
"variables": "ตัวแปร",
"conditionals": "เงื่อนไข",
"preview": "พรีวิว",
"reset": "ตั้งค่าใหม่",
"donate": "โดเนท",
"unlink": "ปลดลิงค์บัญชี",
"deleted": "ลบ",
"disabled": "ปิดใช้งาน",
"sendPWR": "ส่งคำขอตั้งค่ารหัสผ่าน",
"noResultsFound": "ไม่พบผลลัพธ์",
"keepSearching": "ค้นหาต่อไป",
"keepSearchingDescription": "เฉพาะกิจกรรมที่กำลังโหลดอยู่ถูกค้นหา กดด้านล่างถ้าต้องการค้นหาทั้งหมด",
"contactThrough": "ติดต่อผ่าน:",
"extendExpiry": "ยืดเวลาหมดอายุ",
"setExpiry": "ตั้งเวลาหมดอายุ",
"removeExpiry": "ลบเวลาหมดอายุ",
"enterExpiry": "กรอกเวลาหมดอายุ",
"sendPWRManual": "ผู้ใช้ {n} ไม่มีช่องทางการติดต่อ, กดคัดลอกเพื่อรับลิงค์เพื่อส่งให้เขา",
"sendPWRSuccess": "ส่งลิงค์ตั้งรหัสใหม่แล้ว",
"sendPWRSuccessManual": "ถ้าผู้ใช้ของคุณยังไม่ได้ลิงค์, กดคัดลอกเพื่อรับส่งไปส่งให้เขาด้วยตนเอง",
"sendPWRValidFor": "ลิงค์สามารถใช้ได้ภายใน 30 นาที",
"customizeMessages": "ปรับแต่งข้อความ",
"customizeMessagesDescription": "ถ้าคุณไม่อยากใช้แบบข้อความของ jfa-go, คุณสามารถทำเองได้โดยใช้ Markdown.",
"markdownSupported": "รอบรับ Markdown",
"modifySettings": "ปรับแต่งการตั้งค่า",
"modifySettingsDescription": "ปรับใช้การตั้งค่าจากโปรไฟล์ที่มีอยู่, หรือใช้แบบข้อมูลจากผู้ใช้",
"enableReferrals": "เปิดใช้งานคำเชิญ",
"disableReferrals": "ปิดใช้งานคำเชิญ",
"enableReferralsDescription": "ให้ลิงค์เชิญส่วนตัวสำหรับผู้ใช้ เสมือนคำเชิญสำหรับส่งให้เพื่อน/ครอบครัว สามารถอ้างอิงจากแบบคำเชิญหน้าโปรไฟล์ หรือจากคำเชิญที่มีอยู่แล้ว",
"enableReferralsProfileDescription": "",
"useInviteExpiry": "",
"useInviteExpiryNote": "",
"applyHomescreenLayout": "",
"applyConfigurationAndPolicy": "",
"applyOmbi": "",
"applyJellyseerr": "",
"sendDeleteNotificationEmail": "",
"sendDeleteNotifiationExample": "",
"settingsRestart": "",
"settingsRestarting": "",
"settingsRestartRequired": "",
"settingsRestartRequiredDescription": "",
"settingsApplyRestartLater": "",
"settingsApplyRestartNow": "",
"settingsApplied": "",
"settingsRefreshPage": "",
"settingsRequiredOrRestartMessage": "",
"settingsSave": "",
"settingsHiddenDependency": "",
"settingsDependsOn": "",
"settingsAdvancedMode": "",
"settingsMaybeUnderAdvanced": "",
"ombiProfile": "",
"ombiUserDefaultsDescription": "",
"jellyseerrProfile": "",
"jellyseerrUserDefaultsDescription": "",
"userProfiles": "",
"userProfilesDescription": "",
"userProfilesIsDefault": "",
"userProfilesLibraries": "",
"addProfile": "",
"addProfileDescription": "",
"addProfileNameOf": "",
"addProfileStoreHomescreenLayout": "",
"inviteNoUsersCreated": "",
"inviteUsersCreated": "",
"inviteNoProfile": "",
"inviteDateCreated": "",
"inviteNoInvites": "",
"inviteExpiresInTime": "",
"notifyEvent": "",
"notifyInviteExpiry": "",
"notifyUserCreation": "",
"sendPIN": "",
"searchDiscordUser": "",
"findDiscordUser": "",
"linkMatrixDescription": "",
"matrixHomeServer": "",
"saveAsTemplate": "",
"deleteTemplate": "",
"templateEnterName": "",
"accessJFA": "",
"accessJFASettings": "",
"sortingBy": "",
"sortDirection": "",
"filters": "",
"clickToRemoveFilter": "",
"clearSearch": "",
"actions": "",
"searchOptions": "",
"matchText": "",
"jellyfinID": "",
"userPageLogin": "",
"userPagePage": "",
"postSignupCard": "",
"postSignupCardDescription": "",
"buildTime": "",
"builtBy": "",
"loginNotAdmin": "",
"referrer": "",
"accountLinked": "",
"accountUnlinked": "",
"accountResetPassword": "",
"accountChangedPassword": "",
"accountCreated": "",
"accountDeleted": "",
"accountDisabled": "",
"accountReEnabled": "",
"accountExpired": "",
"accountWillExpire": "",
"expirationBasedOn": "",
"userDeleted": "",
"userDisabled": "",
"inviteCreated": "",
"inviteDeleted": "",
"inviteExpired": "",
"fromInvite": "",
"byAdmin": "",
"byUser": "",
"byJfaGo": "",
"activityID": "",
"title": "",
"usersMentioned": "",
"actor": "",
"actorDescription": "",
"accountCreationFilter": "",
"accountDeletionFilter": "",
"accountDisabledFilter": "",
"accountEnabledFilter": "",
"contactLinkedFilter": "",
"contactUnlinkedFilter": "",
"passwordChangeFilter": "",
"passwordResetFilter": "",
"inviteCreatedFilter": "",
"inviteDeletedFilter": "",
"loadMore": "",
"loadAll": "",
"noMoreResults": "",
"totalRecords": "",
"loadedRecords": "",
"shownRecords": "",
"backups": "",
"backupsDescription": "",
"backupsFormatNote": "",
"backupsCopy": "",
"backupDownloadRestore": "",
"backupUpload": "",
"backupDownload": "",
"backupRestore": "",
"backupNow": "",
"backupCreated": "",
"backupCanBeFound": "",
"backupCanDownload": "",
"wikiPage": ""
},
"notifications": {
"pathCopied": "",
"changedEmailAddress": "",
"userCreated": "",
"createProfile": "",
"saveSettings": "",
"saveEmail": "",
"sentAnnouncement": "",
"savedAnnouncement": "",
"setOmbiProfile": "",
"savedProfile": "",
"updateApplied": "",
"updateAppliedRefresh": "",
"telegramVerified": "",
"accountConnected": "",
"referralsEnabled": "",
"activityDeleted": "",
"errorInviteNoLongerExists": "",
"errorInviteNotFound": "",
"errorSettingsAppliedNoHomescreenLayout": "",
"errorHomescreenAppliedNoSettings": "",
"errorSettingsFailed": "",
"errorSaveEmail": "",
"errorBlankFields": "",
"errorDeleteProfile": "",
"errorLoadProfiles": "",
"errorCreateProfile": "",
"errorSavedProfile": "",
"errorSetDefaultProfile": "",
"errorLoadUsers": "",
"errorLoadSettings": "",
"errorSetOmbiProfile": "",
"errorLoadOmbiUsers": "",
"errorChangedEmailAddress": "",
"errorFailureCheckLogs": "",
"errorPartialFailureCheckLogs": "",
"errorUserCreated": "",
"errorSendWelcomeEmail": "",
"errorApplyUpdate": "",
"errorCheckUpdate": "",
"errorNoReferralTemplate": "",
"errorLoadActivities": "",
"errorInvalidDate": "",
"updateAvailable": "",
"noUpdatesAvailable": ""
},
"quantityStrings": {
"modifySettingsFor": {
"singular": "",
"plural": ""
},
"enableReferralsFor": {
"singular": "",
"plural": ""
},
"deleteNUsers": {
"singular": "",
"plural": ""
},
"disableUsers": {
"singular": "",
"plural": ""
},
"reEnableUsers": {
"singular": "",
"plural": ""
},
"addUser": {
"singular": "",
"plural": ""
},
"deleteUser": {
"singular": "",
"plural": ""
},
"deletedUser": {
"singular": "",
"plural": ""
},
"disabledUser": {
"singular": "",
"plural": ""
},
"enabledUser": {
"singular": "",
"plural": ""
},
"announceTo": {
"singular": "",
"plural": ""
},
"appliedSettings": {
"singular": "",
"plural": ""
},
"extendExpiry": {
"singular": "",
"plural": ""
},
"setExpiry": {
"singular": "",
"plural": ""
},
"extendedExpiry": {
"singular": "",
"plural": ""
}
}
}

322
lang/admin/tr-TR.json Normal file
View File

@@ -0,0 +1,322 @@
{
"meta": {
"name": "İngilizce (ABD)"
},
"strings": {
"invites": "Davetler",
"invite": "Davet",
"accounts": "Hesaplar",
"activity": "Aktivite",
"settings": "Ayarlar",
"inviteMonths": "Ay",
"inviteDays": "Gün",
"inviteHours": "Saat",
"inviteMinutes": "Dakika",
"inviteNumberOfUses": "",
"inviteDuration": "",
"warning": "",
"inviteInfiniteUsesWarning": "",
"inviteSendToEmail": "",
"create": "",
"apply": "",
"select": "",
"name": "",
"date": "",
"updates": "",
"update": "",
"download": "",
"search": "",
"advancedSettings": "",
"lastActiveTime": "",
"from": "",
"after": "",
"before": "",
"user": "",
"userExpiry": "",
"userExpiryDescription": "",
"aboutProgram": "",
"version": "",
"commitNoun": "",
"newUser": "",
"profile": "",
"unknown": "",
"label": "",
"userLabel": "",
"userLabelDescription": "",
"logs": "",
"announce": "",
"templates": "",
"subject": "",
"message": "Mesaj",
"variables": "",
"conditionals": "",
"preview": "",
"reset": "",
"donate": "",
"unlink": "",
"deleted": "",
"disabled": "Devre Dışı",
"sendPWR": "",
"noResultsFound": "",
"noResultsFoundLocally": "",
"keepSearching": "",
"keepSearchingDescription": "",
"contactThrough": "",
"extendExpiry": "",
"setExpiry": "",
"removeExpiry": "",
"enterExpiry": "",
"sendPWRManual": "",
"sendPWRSuccess": "",
"sendPWRSuccessManual": "",
"sendPWRValidFor": "",
"customizeMessages": "",
"customizeMessagesDescription": "",
"markdownSupported": "",
"modifySettings": "",
"modifySettingsDescription": "",
"enableReferrals": "",
"disableReferrals": "",
"enableReferralsDescription": "",
"enableReferralsProfileDescription": "",
"useInviteExpiry": "",
"useInviteExpiryNote": "",
"applyHomescreenLayout": "",
"applyConfigurationAndPolicy": "",
"applyOmbi": "",
"applyJellyseerr": "",
"sendDeleteNotificationEmail": "",
"sendDeleteNotifiationExample": "",
"settingsRestart": "",
"settingsRestarting": "",
"settingsRestartRequired": "",
"settingsRestartRequiredDescription": "",
"settingsApplyRestartLater": "",
"settingsApplyRestartNow": "",
"settingsApplied": "",
"settingsRefreshPage": "",
"settingsRequiredOrRestartMessage": "",
"settingsSave": "",
"settingsHiddenDependency": "",
"settingsDependsOn": "",
"settingsAdvancedMode": "",
"settingsMaybeUnderAdvanced": "",
"ombiProfile": "",
"ombiUserDefaultsDescription": "",
"jellyseerrProfile": "",
"jellyseerrUserDefaultsDescription": "",
"userProfiles": "",
"userProfilesDescription": "",
"userProfilesIsDefault": "",
"userProfilesLibraries": "",
"addProfile": "",
"addProfileDescription": "",
"addProfileNameOf": "",
"addProfileStoreHomescreenLayout": "",
"inviteNoUsersCreated": "",
"inviteUsersCreated": "",
"inviteNoProfile": "",
"inviteDateCreated": "",
"inviteNoInvites": "",
"inviteExpiresInTime": "",
"notifyEvent": "",
"notifyInviteExpiry": "",
"notifyUserCreation": "",
"sendPIN": "",
"searchDiscordUser": "",
"findDiscordUser": "",
"linkMatrixDescription": "",
"matrixHomeServer": "",
"saveAsTemplate": "",
"deleteTemplate": "",
"templateEnterName": "",
"accessJFA": "",
"accessJFASettings": "",
"sortingBy": "",
"sortDirection": "",
"filters": "",
"clickToRemoveFilter": "",
"clearSearch": "",
"searchAll": "",
"searchAllRecords": "",
"actions": "",
"searchOptions": "",
"matchText": "",
"jellyfinID": "",
"userPageLogin": "",
"userPagePage": "",
"postSignupCard": "",
"postSignupCardDescription": "",
"buildTime": "",
"builtBy": "",
"buildTags": "",
"loginNotAdmin": "",
"referrer": "",
"accountLinked": "",
"accountUnlinked": "",
"accountResetPassword": "",
"accountChangedPassword": "",
"accountCreated": "",
"accountDeleted": "",
"accountDisabled": "",
"accountReEnabled": "",
"accountExpired": "",
"accountWillExpire": "",
"expirationBasedOn": "",
"userDeleted": "",
"userDisabled": "",
"inviteCreated": "",
"inviteDeleted": "",
"inviteExpired": "",
"fromInvite": "",
"byAdmin": "",
"byUser": "",
"byJfaGo": "",
"activityID": "",
"title": "",
"usersMentioned": "",
"actor": "",
"actorDescription": "",
"accountCreationFilter": "",
"accountDeletionFilter": "",
"accountDisabledFilter": "",
"accountEnabledFilter": "",
"contactLinkedFilter": "",
"contactUnlinkedFilter": "",
"passwordChangeFilter": "",
"passwordResetFilter": "",
"inviteCreatedFilter": "",
"inviteDeletedFilter": "",
"loadMore": "",
"loadAll": "",
"noMoreResults": "",
"totalRecords": "",
"loadedRecords": "",
"shownRecords": "",
"selectedRecords": "",
"allMatchingSelected": "",
"allLoadedSelected": "",
"backups": "",
"backupsDescription": "",
"backupsFormatNote": "",
"backupsCopy": "",
"backupDownloadRestore": "",
"backupUpload": "",
"backupDownload": "",
"backupRestore": "",
"backupNow": "",
"backupCreated": "",
"backupCanBeFound": "",
"backupCanDownload": "",
"wikiPage": "",
"wiki": ""
},
"notifications": {
"pathCopied": "",
"changedEmailAddress": "",
"userCreated": "",
"createProfile": "",
"saveSettings": "",
"saveEmail": "",
"sentAnnouncement": "",
"savedAnnouncement": "",
"setOmbiProfile": "",
"savedProfile": "",
"updateApplied": "",
"updateAppliedRefresh": "",
"telegramVerified": "",
"accountConnected": "",
"referralsEnabled": "",
"activityDeleted": "",
"errorInviteNoLongerExists": "",
"errorInviteNotFound": "",
"errorSettingsAppliedNoHomescreenLayout": "",
"errorHomescreenAppliedNoSettings": "",
"errorSettingsFailed": "",
"errorSaveEmail": "",
"errorBlankFields": "",
"errorDeleteProfile": "",
"errorLoadProfiles": "",
"errorCreateProfile": "",
"errorSavedProfile": "",
"errorSetDefaultProfile": "",
"errorLoadUsers": "",
"errorLoadSettings": "",
"errorSetOmbiProfile": "",
"errorLoadOmbiUsers": "",
"errorChangedEmailAddress": "",
"errorFailureCheckLogs": "",
"errorPartialFailureCheckLogs": "",
"errorUserCreated": "",
"errorSendWelcomeEmail": "",
"errorApplyUpdate": "",
"errorCheckUpdate": "",
"errorNoReferralTemplate": "",
"errorLoadActivities": "",
"errorInvalidDate": "",
"updateAvailable": "",
"noUpdatesAvailable": ""
},
"quantityStrings": {
"modifySettingsFor": {
"singular": "",
"plural": ""
},
"enableReferralsFor": {
"singular": "",
"plural": ""
},
"deleteNUsers": {
"singular": "",
"plural": ""
},
"disableUsers": {
"singular": "",
"plural": ""
},
"reEnableUsers": {
"singular": "",
"plural": ""
},
"addUser": {
"singular": "",
"plural": ""
},
"deleteUser": {
"singular": "",
"plural": ""
},
"deletedUser": {
"singular": "",
"plural": ""
},
"disabledUser": {
"singular": "",
"plural": ""
},
"enabledUser": {
"singular": "",
"plural": ""
},
"announceTo": {
"singular": "",
"plural": ""
},
"appliedSettings": {
"singular": "",
"plural": ""
},
"extendExpiry": {
"singular": "",
"plural": ""
},
"setExpiry": {
"singular": "",
"plural": ""
},
"extendedExpiry": {
"singular": "",
"plural": ""
}
}
}

View File

@@ -41,7 +41,9 @@
"delete": "Delete",
"myAccount": "My Account",
"referrals": "Referrals",
"inviteRemainingUses": "Remaining uses"
"inviteRemainingUses": "Remaining uses",
"internal": "Internal",
"external": "External"
},
"notifications": {
"errorLoginBlank": "The username and/or password were left blank.",

View File

@@ -39,14 +39,19 @@
"contactMethods": "Kapcsolati lehetőségek",
"accountStatus": "Fiók státusz",
"notSet": "Nincs beállítva",
"myAccount": "Saját fiókom"
"myAccount": "Saját fiókom",
"internal": "Belső",
"referrals": "Hivatkozások",
"inviteRemainingUses": "Fennmaradó felhasználások",
"external": "Külső"
},
"notifications": {
"errorLoginBlank": "A felhasználónév és/vagy a jelszó üresen lett hagyva.",
"errorConnection": "Nem lehet csatlakozni a jfa-go-hoz.",
"errorUnknown": "Ismeretlen hiba.",
"error401Unauthorized": "Nincs jogosultság. Próbáld frissíteni az oldalt.",
"errorSaveSettings": "Nem lehet menteni a beállításokat."
"errorSaveSettings": "Nem lehet menteni a beállításokat.",
"errorSpecialSymbols": "Ez a mező nem tartalmazhat speciális karaktereket."
},
"quantityStrings": {
"year": {

View File

@@ -18,9 +18,29 @@
"theme": "Tema",
"login": "Masuk",
"logout": "Keluar",
"edit": "Edit",
"edit": "Ubah",
"delete": "Hapus",
"inviteRemainingUses": "Penggunaan yang tersisa"
"inviteRemainingUses": "Penggunaan yang tersisa",
"linkDiscord": "Discord Link",
"linkMatrix": "Matrix Link",
"contactDiscord": "Hubungi melalui Discord",
"linkTelegram": "Telegram Link",
"contactEmail": "Hubungi melalui Email",
"contactTelegram": "Hubungi melalui Telegram",
"refresh": "Segarkan",
"required": "Dibutuhkan",
"admin": "Admin",
"enabled": "Diaktifkan",
"disabled": "Dihentikan",
"reEnable": "Diaktifkan kembali",
"disable": "Matikan",
"accountStatus": "Status Akun",
"notSet": "Belum ditetapkan",
"expiry": "Kedaluwarsa",
"add": "Tambah",
"myAccount": "Akun Saya",
"copied": "Telah disalin",
"referrals": "Referensi"
},
"notifications": {
"errorLoginBlank": "Nama pengguna dan / atau sandi kosong.",
@@ -28,5 +48,19 @@
"errorUnknown": "Terjadi kesalahan yang tidak diketahui.",
"error401Unauthorized": "Tidak ter-otorisasi. Coba segarkan halaman.",
"errorSaveSettings": "Tidak dapat menyimpan pengaturan."
},
"quantityStrings": {
"year": {
"singular": "{n} Tahun",
"plural": "{n} Beberapa tahun"
},
"month": {
"singular": "{n} Bulan",
"plural": "{n} Beberapa bulan"
},
"day": {
"singular": "{n} Hari",
"plural": "{n} Beberapa hari"
}
}
}

View File

@@ -3,7 +3,7 @@
"name": "Italiano (IT)"
},
"strings": {
"username": "Username",
"username": "Nome Utente",
"password": "Password",
"emailAddress": "Indirizzo Email",
"name": "Nome",

67
lang/common/th-TH.json Normal file
View File

@@ -0,0 +1,67 @@
{
"meta": {
"name": "ภาษาไทย (TH)"
},
"strings": {
"username": "ชื่อผู้ใช้งาน",
"password": "รหัสผ่าน",
"emailAddress": "ที่อยู่อีเมล",
"name": "ชื่อ",
"submit": "ส่ง",
"send": "ส่ง",
"success": "เสร็จสิ้น",
"continue": "ดำเนินการต่อ",
"error": "ข้อผิดผลาด",
"copy": "คัดลอก",
"copied": "คัดลอกแล้ว",
"time24h": "เวลา 24 ชม.",
"time12h": "เวลา 12 ชม.",
"linkTelegram": "ลิงค์ Telegram",
"contactEmail": "ติดต่อผ่านอีเมล",
"contactTelegram": "ติดต่อผ่าน Telegram",
"linkDiscord": "ลิงค์ Discord",
"linkMatrix": "ลิงค์ Matrix",
"contactDiscord": "ติดต่อผ่าน Discord",
"theme": "ธีม",
"refresh": "โหลดใหม่",
"required": "จำเป็น",
"login": "เข้าสู่ระบบ",
"logout": "ออกจากระบบ",
"admin": "ผู้ดูแล",
"enabled": "เปิดใช้งาน",
"disabled": "ปิดใช้งาน",
"reEnable": "เปิดใช้งานอีกครั้ง",
"disable": "ปิดใช้งาน",
"contactMethods": "ช่องทางการติดต่อ",
"accountStatus": "สถานะบัญชี",
"notSet": "ยังไม่ตั้งค่า",
"expiry": "หมดอายุ",
"add": "เพิ่ม",
"edit": "แก้ไข",
"delete": "ลบ",
"myAccount": "บัญชีของฉัน",
"referrals": "คำเชิญ",
"inviteRemainingUses": "จำนวนใช้ที่เหลือ"
},
"notifications": {
"errorLoginBlank": "ชื่อผู้ใช้ และ/หรือ รหัสผ่านถูกเว้นว่างไว้",
"errorConnection": "ไม่สามารถเชื่อต่อไปยัง jfa-go ได้",
"errorUnknown": "เกิดข้อผิดผลาดที่ไม่รู้จัก",
"error401Unauthorized": "ไม่อนุญาติการเข้าถึง, ลองโหลดหน้านี้อีกครั้ง",
"errorSaveSettings": "ไม่สามารถบันทึกการตั้งค่าได้"
},
"quantityStrings": {
"year": {
"singular": "{n} ปี",
"plural": "{n} ปี"
},
"month": {
"singular": "{n} เดือน",
"plural": "{n} เดือน"
},
"day": {
"singular": "{n} วัน",
"plural": "{n} วัน"
}
}
}

70
lang/common/tr-TR.json Normal file
View File

@@ -0,0 +1,70 @@
{
"meta": {
"name": "İngilizce (ABD)"
},
"strings": {
"username": "Kullanıcı Adı",
"password": "Şifre",
"emailAddress": "E-posta Adresi",
"name": "İsim",
"submit": "Kaydet",
"send": "Gönder",
"success": "Başarılı",
"continue": "Devam Et",
"error": "Hata",
"copy": "Kopyala",
"copied": "Kopyalandı",
"time24h": "24 Saat",
"time12h": "12 Saat",
"linkTelegram": "Telegram Bağla",
"contactEmail": "E-posta ile İletişim",
"contactTelegram": "Telegram ile İletişim",
"linkDiscord": "Discord Bağla",
"linkMatrix": "Matrix Bağla",
"contactDiscord": "Discord ile İletişim",
"theme": "Tema",
"refresh": "Yenile",
"required": "Gerekli",
"login": "Oturum Aç",
"logout": "Oturumu Kapat",
"admin": "Yönetici",
"enabled": "Etkin",
"disabled": "Devre Dışı",
"reEnable": "Yeniden Etkinleştir",
"disable": "Devre Dışı Bırak",
"contactMethods": "İletişim Yöntemleri",
"accountStatus": "Hesap Durumu",
"notSet": "Ayarlanmadı",
"expiry": "Son Kullanma Tarihi",
"add": "Ekle",
"edit": "Düzenle",
"delete": "Sil",
"myAccount": "Hesabım",
"referrals": "Referanslar",
"inviteRemainingUses": "Kalan Kullanım",
"internal": "Dahili",
"external": "Harici"
},
"notifications": {
"errorLoginBlank": "Kullanıcı adı ve/veya şifre boş bırakıldı.",
"errorConnection": "jfa-go'ya bağlanılamadı.",
"errorUnknown": "Bilinmeyen hata.",
"error401Unauthorized": "Yetkisiz İşlem. Sayfayı yenilemeyi deneyin.",
"errorSaveSettings": "Ayarlar kaydedilemedi.",
"errorSpecialSymbols": "Alan özel semboller içeremez."
},
"quantityStrings": {
"year": {
"singular": "{n} Yıl",
"plural": "{n} Yıl"
},
"month": {
"singular": "{n} Ay",
"plural": "{n} Ay"
},
"day": {
"singular": "{n} Gün",
"plural": "{n} Gün"
}
}
}

View File

@@ -14,11 +14,12 @@
"add": "Thêm",
"edit": "Chỉnh sửa",
"delete": "Xóa",
"inviteRemainingUses": "Số lần sử dụng còn lại"
"inviteRemainingUses": "Số lần sử dụng còn lại",
"username": "Tài khoản",
"password": "Mật khẩu"
},
"notifications": {
"errorConnection": "Không thể kết nối với jfa-go.",
"error401Unauthorized": "Không được phép. Hãy thử làm mới trang."
},
"quantityStrings": {}
}
}
}

View File

@@ -80,5 +80,10 @@
"title": "Your account has expired - Jellyfin",
"yourAccountHasExpired": "Your account has expired.",
"contactTheAdmin": "Contact the administrator for more info."
},
"expiryReminder": {
"name": "Expiry reminder",
"title": "Reminder: your account will expire soon - Jellyfin",
"yourAccountIsDueToExpire": "Your account is due to expire in {expiresIn}, or on {date} at {time}."
}
}

View File

@@ -3,75 +3,82 @@
"name": "Magyar (HU)"
},
"strings": {
"ifItWasNotYou": "",
"helloUser": "",
"reason": ""
"ifItWasNotYou": "Ha nem Te voltál, akkor hagyd figyelmen kívül.",
"helloUser": "Szia {username},",
"reason": "Ok"
},
"userCreated": {
"name": "",
"title": "",
"aUserWasCreated": "",
"time": "",
"notificationNotice": ""
"name": "Felhasználó létrehozása",
"title": "Értesítés: Felhasználó létrehozva",
"aUserWasCreated": "Felhasználó létrehozva {code} kóddal.",
"time": "Idő",
"notificationNotice": "Megjegyzés: A figyelmeztető üzenetek ki- be kapcsolhatók az admin felületen."
},
"inviteExpiry": {
"name": "",
"title": "",
"inviteExpired": "",
"expiredAt": "",
"notificationNotice": ""
"name": "Meghívó lejárata",
"title": "Értesítés: A meghívó lejárt",
"inviteExpired": "A meghívó lejárt.",
"expiredAt": "A {code} kód lejárt ekkor {time}.",
"notificationNotice": "Megjegyzés: A figyelmeztető üzenetek ki- be kapcsolhatók az admin felületen."
},
"passwordReset": {
"name": "",
"title": "",
"someoneHasRequestedReset": "",
"ifItWasYou": "",
"ifItWasYouLink": "",
"codeExpiry": "",
"pin": ""
"name": "Jelszó visszaállítás",
"title": "Jelszó visszaállítási kérelem - Jellyfin",
"someoneHasRequestedReset": "Valaki mostanában jelszó visszaállítást kért.",
"ifItWasYou": "Ha Te voltál, írd be a kódot ide.",
"ifItWasYouLink": "Ha Te voltál, kattints a linkre.",
"codeExpiry": "A kód lejárt {expiresInMinutes} perce. ({date} {time} UTC).",
"pin": "PIN"
},
"userDeleted": {
"name": "",
"title": "",
"yourAccountWasDeleted": ""
"name": "Felhasználó törlése",
"title": "A fiókod törölve lett - Jellyfin",
"yourAccountWasDeleted": "A jellyfin fiókod törölve lett."
},
"userDisabled": {
"name": "",
"title": "",
"yourAccountWasDisabled": ""
"name": "Felhsználó letiltva",
"title": "A felhasználód le lett tiltva - Jellyfin",
"yourAccountWasDisabled": "A fiókod le lett tiltva."
},
"userEnabled": {
"name": "",
"title": "",
"yourAccountWasEnabled": ""
"name": "Felhasználó engedélyezve",
"title": "A fiókod fel lett oldva - Jellyfin",
"yourAccountWasEnabled": "A fiókod fel lett oldva."
},
"inviteEmail": {
"name": "",
"title": "",
"hello": "",
"youHaveBeenInvited": "",
"toJoin": "",
"inviteExpiry": "",
"linkButton": ""
"name": "Meghívó email",
"title": "Meghívó - Jellyfin",
"hello": "Szia",
"youHaveBeenInvited": "Meghívtak a jellyfin alkalmazásba.",
"toJoin": "Csatlakozáshoz kattints a linkre.",
"inviteExpiry": "A meghívó {date} {time}-kor lejár, ami {expiresInMinutes} perc múlva lesz, szóval gyorsan cselekedj.",
"linkButton": "Fiók beállítása"
},
"welcomeEmail": {
"name": "",
"title": "",
"welcome": "",
"youCanLoginWith": "",
"yourAccountWillExpire": "",
"jellyfinURL": ""
"name": "Üdvözöllek",
"title": "Üdvözöllek a Jellyfin-ben",
"welcome": "Üdvözöllek a Jellyfin-ben!",
"youCanLoginWith": "Be tudsz lépni az alábbi adatokkal",
"yourAccountWillExpire": "A fiókod {date} dátummal lejár.",
"jellyfinURL": "URL"
},
"emailConfirmation": {
"name": "",
"title": "",
"clickBelow": "",
"confirmEmail": ""
"name": "Megerősítő email cím",
"title": "Erősítsd meg az email címed- Jellyfin",
"clickBelow": "Kattints az alábbi linkre, hogy megerősítsd az email címed és elkezd használni a jellyfin-t.",
"confirmEmail": "Email megerősítése"
},
"userExpired": {
"name": "",
"title": "",
"yourAccountHasExpired": "",
"contactTheAdmin": ""
"name": "Felhasználó lejárata",
"title": "A fiókod lejárt - Jellyfin",
"yourAccountHasExpired": "A fiókod lejárt.",
"contactTheAdmin": "Lépj kapcsolatba az rendszergazdával további információkért."
},
"userExpiryAdjusted": {
"name": "Lejárat módosítva",
"title": "Fiók lejárat módosítva - Jellyfin",
"yourExpiryWasAdjusted": "A fiókod lejárata módosult.",
"ifPreviouslyDisabled": "Ha fiókod korábban letiltották, előfordulhat, hogy újra engedélyezték.",
"newExpiry": "A fiókod {date} napon lejár."
}
}

View File

@@ -27,12 +27,13 @@
"ifItWasYou": "Jika ini adalah benar anda, masukkan pin dibawah ke dalam tempat yang sudah disediakan.",
"codeExpiry": "Kode akan kadaluarsa pada {date}, pada waktu {time} UTC, yaitu dalam {expiresInMinutes}.",
"pin": "PIN",
"name": "Atur ulang kata sandi"
"name": "Atur ulang kata sandi",
"ifItWasYouLink": "Jika ini kamu yang request, silahkan klik link dibawah ini."
},
"userDeleted": {
"title": "Akun anda telah dihapus - Jellyfin",
"yourAccountWasDeleted": "Akun Jellyfin anda telah dihapus.",
"name": "Penghapusan pengguna"
"name": "Hapus Pengguna"
},
"inviteEmail": {
"title": "Undangan - Jellyfin",
@@ -48,12 +49,36 @@
"welcome": "Selamat datang di Jellyfin!",
"youCanLoginWith": "Anda dapat masuk dengan menggunakan data dibawah ini",
"jellyfinURL": "URL",
"name": "Email selamat datang"
"name": "Selamat Datang",
"yourAccountWillExpire": "Akun kamu akan kedaluwarsa pada {date}."
},
"emailConfirmation": {
"title": "Konfirmasi emailmu - Jellyfin",
"clickBelow": "Klik link dibawah ini untuk mengkonfirmasikan alamat emailmu untuk mulai menggunakan Jellyfin.",
"clickBelow": "Klik link dibawah ini untuk mengkonfirmasikan alamat emailmu dan mulai menggunakan Jellyfin.",
"confirmEmail": "Konfirmasi Email",
"name": "Email konfirmasi"
},
"userDisabled": {
"title": "Akun anda telah dihentikan - Jellyfin",
"yourAccountWasDisabled": "Akun anda telah dihentikan.",
"name": "Akun telah dihentikan"
},
"userEnabled": {
"name": "Akun telah diaktifkan",
"yourAccountWasEnabled": "Akun anda telah diaktifkan kembali.",
"title": "Akun anda telah diaktifkan kembali - Jellyfin"
},
"userExpired": {
"name": "Pengguna kedaluwarsa",
"title": "Akun kamu sudah kedaluwarsa - Jellyfin",
"yourAccountHasExpired": "Akun kamu sudah kedaluwarsa.",
"contactTheAdmin": "Hubungi admin untuk info lebih lanjut."
},
"userExpiryAdjusted": {
"name": "Waktu habis sudah diubah",
"yourExpiryWasAdjusted": "Tanggal kedaluwarsa akun kamu sudah disesuaikan.",
"title": "Waktu habis akun sudah disesuaikan - Jellyfin",
"ifPreviouslyDisabled": "Jika akun kamu sebelumnya dihentikan, akun kamu butuh untuk diaktifkan kembali.",
"newExpiry": "Akun kamu akan kedaluwarsa pada {date}."
}
}
}

84
lang/email/th-TH.json Normal file
View File

@@ -0,0 +1,84 @@
{
"meta": {
"name": "ภาษาไทย (TH)"
},
"strings": {
"ifItWasNotYou": "ถ้าหากไม่ใช่คุณ, สามารถมองข้ามได้เลย",
"helloUser": "สวัสดี {username},",
"reason": "เหตุผล"
},
"userCreated": {
"name": "การสร้างผู้ใช้",
"title": "แจ้งเตือน: ผู้ใช้ถูกสร้างแล้ว",
"aUserWasCreated": "ผู้ใช้นี้ถูกสร้างโดยใช้รหัส {code}",
"time": "เวลา",
"notificationNotice": "ปล: การแจ้งเตือนสามารถเปิด/ปิดได้ผ่านหน้าผู้ดูแลระบบ"
},
"inviteExpiry": {
"name": "คำเชิญหมดอายุ",
"title": "แจ้งเตือน: คำเชิญหมดอายุ",
"inviteExpired": "คำเชิญหมดอายุแล้ว",
"expiredAt": "รหัส {code} หมดอายุเมื่อ {time}",
"notificationNotice": "ปล: การแจ้งเตือนสามารถเปิด/ปิดได้ผ่านหน้าผู้ดูแลระบบ"
},
"passwordReset": {
"name": "ตั้งค่ารหัสผ่านใหม่",
"title": "คำขอตั้งค่ารหัสผ่านใหม่ - Jellyfin",
"someoneHasRequestedReset": "บางคนได้ส่งคำขอตั้งค่ารหัสผ่านใหม่บน Jellyfin",
"ifItWasYou": "ถ้าเป็นคุณ, ให้กดรหัสด้านล่างไปยังหน้าจอ",
"ifItWasYouLink": "ถ้าเป็นคุณ, ให้กดลิงค์ด้านล่าง",
"codeExpiry": "รหัสจะหมดอายุภายในวันที่ {date} เวลา {time} (UTC) ซึ่งภายใน {expiresInMinutes}",
"pin": "หรัส (PIN)"
},
"userDeleted": {
"name": "ลบผู้ใช้งาน",
"title": "บัญชีของคุณถูกลบ - Jellyfin",
"yourAccountWasDeleted": "บัญชี Jellyfin ของคุณถูกลบ"
},
"userDisabled": {
"name": "บัญชีถูกปิดใช้งาน",
"title": "บัญชีของคุณถูกปิดใช้งาน - Jellyfin",
"yourAccountWasDisabled": "บัญชีของคุณถูกปิดใช้งาน"
},
"userEnabled": {
"name": "บัญชีถูกเปิดใช้งาน",
"title": "บัญชีของคุณถูกเปิดใช้งานอีกครั้ง - Jellyfin",
"yourAccountWasEnabled": "บัญชีของคุณถูกเปิดใช้งานอีกครั้ง"
},
"userExpiryAdjusted": {
"name": "วันหมดอายุถูกปรับ",
"title": "วันหมดอายุบัญชีถูกปรับ - Jellyfin",
"yourExpiryWasAdjusted": "วันหมดอายุบัญชีของคุณถูกปรับ",
"ifPreviouslyDisabled": "ถ้าบัญชีของคุณถูกปิดก่อนหน้า, ตอนนี้อาจถูกเปิดใช้งานอีกครั้งแล้ว",
"newExpiry": "วันหมดอายุของคุณตอนนี้กลายเป็น {date}."
},
"inviteEmail": {
"name": "อีเมลเชิญ",
"title": "คำเชิญ - Jellyfin",
"hello": "สวัสดี",
"youHaveBeenInvited": "คุณได้รับคำเชิญเข้าสู่ Jellyfin",
"toJoin": "กดลิงค์ด้านล่างเพื่อเข้าร่วม",
"inviteExpiry": "ลิงค์คำเชิญจะหมดอายุภายในวันที่ {date} เวลา {time} ซึ่งภายใน {expiresInMinutes}, เพราะฉนั้น รีบซ่ะละ",
"linkButton": "ตั้งค่าบัญชีของคุณ"
},
"welcomeEmail": {
"name": "ยินดีต้อนรับ",
"title": "ยินดีต้อนรับเข้าสู่ Jellyfin",
"welcome": "ยินดีต้อนรับเข้าสู่ Jellyfin!",
"youCanLoginWith": "คุณสามารถเข้าสู่ระบบด้วยข้อมูลด้านล่าง",
"yourAccountWillExpire": "บัญชีของคุณจะหมดอายุภายใน {date}",
"jellyfinURL": "URL"
},
"emailConfirmation": {
"name": "อีเมลยืนยันตัวตน",
"title": "ยืนยันอีเมลของคุณ - Jellyfin",
"clickBelow": "คลิกลิงค์ด้านล่างเพื่อยืนยันอีเมลของคุณและเริ่มเข้าใช้งาน Jellyfin",
"confirmEmail": "ยืนยันอีเมล"
},
"userExpired": {
"name": "บัญชีหมดอายุ",
"title": "บัญชีของคุณหมดอายุ - Jellyfin",
"yourAccountHasExpired": "บัญชีของคุณหมดอายุแล้ว",
"contactTheAdmin": "ติดต่อผู้ดูแลระบบสำหรับข้อมูลเพิ่มเติม"
}
}

View File

@@ -3,7 +3,7 @@
"name": "کوردی سۆرانی"
},
"strings": {
"pageTitle": "دروستکردنی هەژماری جێڵیفن",
"pageTitle": "دروستکردنی هەژماری Jellyfin",
"createAccountHeader": "دروستکردنی هەژمار",
"accountDetails": "زانیارییەکان",
"emailAddress": "ئیمەیل",
@@ -23,14 +23,14 @@
"sendPINDiscord": "{command} لە چەناڵی {server_channel}ی دیسکۆردەکەت بنوسە، پاشان ئەم ژمارە نهێنییەی خوارەوە بنێرە.",
"matrixEnterUser": "",
"welcomeUser": "{user}، بەخێربێیت!",
"addContactMethod": "",
"editContactMethod": "",
"joinTheServer": "",
"customMessagePlaceholderHeader": "",
"addContactMethod": "زیادکردنی ڕێگەی پەیوەندیی پێوە کردنم",
"editContactMethod": "گۆڕینی ڕێگەی پەیوەندیی پێوە کردنم",
"joinTheServer": "چوونە سێرڤەر:",
"customMessagePlaceholderHeader": "دەستکاریکردنی ئەم کارتە",
"customMessagePlaceholderContent": "",
"userPageSuccessMessage": "",
"resetPassword": "",
"resetPasswordThroughJellyfin": "",
"userPageSuccessMessage": "دواتر دەتوانیت زانیاری لەسەر هەژمارەکەت ببینیت و دەستکاری بکەیت لە بەشی {myAccount}.",
"resetPassword": "هێنانەوەی تێپەڕەوشەی نهێنی",
"resetPasswordThroughJellyfin": "بۆ هێنانەوەی تێپەڕەوشەکەت، سەردانی {jfLink} بکە و گرتە لە \"تێپەڕەوشەم بیرچووە\" بکە.",
"resetPasswordThroughLink": "",
"resetPasswordThroughLinkStart": "",
"resetPasswordThroughLinkEnd": "",

View File

@@ -40,7 +40,8 @@
"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."
"referralsWithExpiryDescription": "Invite a amigos y familia a Jellyfin con este enlace. El enlace se desactivará cuando caduque.",
"welcomeUser": "Bienvenido, {user}!"
},
"notifications": {
"errorUserExists": "El usuario ya existe.",

View File

@@ -17,7 +17,7 @@
"confirmationRequired": "E-mail megerősítés szükséges",
"confirmationRequiredMessage": "Kérjük ellenőrizze az e-mail címére küldött üzenetet, a fiók ellenőrzéséhez.",
"yourAccountIsValidUntil": "A fiókja eddig lesz érvényes: {date}.",
"sendPIN": "Az alábbi PIN-t küldje el a botnak, majd itt csatolja össze a fiókját.",
"sendPIN": "Az alábbi PIN-t küld el a botnak, majd itt csatold össze a fiókoddal.",
"sendPINDiscord": "Írja be a {command} parancsot a {server_channel} Discord csatornába, adja meg a PIN-t.",
"matrixEnterUser": "Írja be a felhasználója azonosítóját majd nyomja meg a beküldés gombot. A kapott kódot ide írja be.",
"customMessagePlaceholderContent": "Kattints a felhasználói oldal szerkesztés gombjára a beállításokban a kártya testreszabásához, vagy jeleníts meg egyet a bejelentkezési képernyőn, ne aggódj, a felhasználó ezt nem láthatja.",
@@ -34,7 +34,16 @@
"resetPasswordThroughJellyfin": "A jelszavad visszaállításához látogass el a {jfLink} oldalra, és nyomj rá az \"Elfelejtett jelszó\" gombra.",
"resetPasswordThroughLink": "A jelszavad visszaállításához, add meg a felhasználóneved, e-mail címed vagy a hozzákötött kapcsolattartási felhasználónevet, és nyomj a gombra. A linket levélben fogod kapni.",
"resetSent": "Visszaállítás elküldve.",
"changePassword": "Jelszó megváltoztatása"
"changePassword": "Jelszó megváltoztatása",
"referralsWithExpiryDescription": "Hívd meg barátaidat és családtagjaidat a Jellyfinre ezzel a linkkel. A link nem lesz elérhető, ha lejár.",
"referralsDescription": "Hívd meg barátaidat és családtagjaidat a Jellyfinre ezzel a linkkel. Gyere vissza ide egy újért, ha lejár.",
"copyReferral": "Link másolása",
"invitedBy": "Meghívást kaptál {user} által.",
"resetPasswordThroughLinkStart": "Jelszava visszaállításához adja meg az alábbiak egyikét:",
"resetPasswordThroughLinkEnd": "Ezután kattints az elküldésre. Egy linket fogsz kapni a jelszó visszaállításához.",
"resetPasswordUsername": "Jellyfin felhasználónév",
"resetPasswordEmail": "Email cím",
"resetPasswordContactMethod": "A fiókodhoz kapcsolt kapcsolatfelvételi mód felhasználóneve"
},
"notifications": {
"errorUserExists": "A felhasználó már létezik.",

View File

@@ -26,7 +26,22 @@
"welcomeUser": "Selamat datang, {user}!",
"joinTheServer": "Bergabung ke server:",
"changePassword": "Ubah Sandi",
"resetPassword": "Atur Ulang Sandi"
"resetPassword": "Atur Ulang Sandi",
"resetPasswordUsername": "Username Jellyfin kamu",
"resetPasswordEmail": "Email kamu",
"referralsWithExpiryDescription": "Undang teman & keluarga ke Jellyfin dengan link ini. Link tidak akan bisa digunakan kalau sudah kedaluwarsa.",
"addContactMethod": "Tambahkan cara untuk menghubungimu",
"editContactMethod": "Ubah informasi kontak",
"customMessagePlaceholderHeader": "Sesuaikan kartu ini",
"customMessagePlaceholderContent": "Tekan tombol edit User Page di settings untuk sesuaikan kartu ini, atau tampilkan satu dilayar masuk, dan jangan khawatir, pengguna tidak bisa melihat ini.",
"resetSent": "Reset dikirim.",
"resetPasswordThroughLinkEnd": "Lalu tekan submit. Ada link yang akan dikirim ke email untuk reset password kamu.",
"resetPasswordThroughLink": "Untuk reset password kamu, masukkan salah satu dari username, email, atau kontak yang terhubung pada akun, lalu submit. Akan ada link yang dikirim ke email untuk reset password kamu.",
"resetPasswordThroughJellyfin": "Untuk mengubah password kamu, kunjungi {ifLink} dan tekan tombol \"Forgot Password\".",
"copyReferral": "Salin Link",
"referralsDescription": "Undang teman & keluarga untuk bergabung ke Jellyfin dengan link ini. Kembali kesini untuk membuat yang baru jika ini sudah kedaluwarsa.",
"invitedBy": "Kamu diundang oleh {user}.",
"resetPasswordThroughLinkStart": "Untuk reset password kamu, masukkan salah satu yang ada dibawah:"
},
"notifications": {
"errorUserExists": "Pengguna sudah ada.",

View File

@@ -3,11 +3,11 @@
"name": "Italiano (IT)"
},
"strings": {
"pageTitle": "Crea Un Account Jellyfin",
"pageTitle": "Crea Account Jellyfin",
"createAccountHeader": "Crea Un Account",
"accountDetails": "Dettagli",
"emailAddress": "Email",
"username": "Username",
"username": "Nome Utente",
"password": "Password",
"reEnterPassword": "Riscrivi La Password",
"reEnterPasswordInvalid": "Le password non sono uguali.",
@@ -17,7 +17,7 @@
"confirmationRequired": "Richiesta la conferma Email",
"confirmationRequiredMessage": "Controlla la tua casella email per verificare il tuo indirizzo.",
"yourAccountIsValidUntil": "Il tuo account sarà valido fino al {date}.",
"sendPIN": "Scrivi il PIN qui sotto al bot, poi torna qui per connettere il tuo account.",
"sendPIN": "Invia il PIN riportato sotto al bot, poi torna qui per associare il tuo account.",
"sendPINDiscord": "Scrivi {command} in {server_channel} su Discord, poi invia il PIN qui sotto.",
"matrixEnterUser": "Inserisci il tuo ID utente, premi invia e ti verrò inviato un PIN. Inseriscilo qui per continuare.",
"customMessagePlaceholderHeader": "Personalizza questa scheda",
@@ -34,7 +34,15 @@
"resetPassword": "Ripristina Password",
"resetSent": "Richiesta di ripristino inviata.",
"resetSentDescription": "Se l'username/metodo di contatto corrisponde ad un account esistente, verrà inviato un link di reset a tutti i metodi di contatto disponibili. Il codice scadrà tra 30 minuti.",
"changePassword": "Cambia Password"
"changePassword": "Cambia Password",
"resetPasswordThroughLinkStart": "Per reimpostare la password, inserisci uno dei seguenti:",
"resetPasswordThroughLinkEnd": "Successivamente premi Invia. Un link verra' inviato per resettare la tua password.",
"resetPasswordUsername": "Il tuo nome utente Jellyfin",
"resetPasswordEmail": "Il tuo indirizzo email",
"referralsWithExpiryDescription": "Invita amici e famigliari su Jellyfin con questo link. Il link verra' disabilitato una volta scaduto.",
"referralsDescription": "Invita amici e famigliari su Jellyfin usando questo link. Ritorna su questa pagina per ottenerne uno nuovo.",
"copyReferral": "Copia Link",
"invitedBy": "Sei stato invitato dall'utente {user}."
},
"notifications": {
"errorUserExists": "L'utente è già esistente.",
@@ -76,4 +84,4 @@
"plural": "Deve avere almeno {n} caratteri speciali"
}
}
}
}

88
lang/form/pt-PT.json Normal file
View File

@@ -0,0 +1,88 @@
{
"meta": {
"name": "Português (PT)"
},
"strings": {
"pageTitle": "Criar uma conta Jellyfin",
"createAccountHeader": "Criar conta",
"accountDetails": "Detalhes",
"emailAddress": "E-mail",
"username": "Nome de utilizador",
"oldPassword": "Palavra-passe antiga",
"newPassword": "Nova palavra-passe",
"password": "Palavra-passe",
"reEnterPassword": "Reintroduzir a palavra-passe",
"reEnterPasswordInvalid": "As palavras-passe não são iguais.",
"createAccountButton": "Criar conta",
"passwordRequirementsHeader": "Requisitos da palavra-passe",
"successHeader": "Sucesso!",
"confirmationRequired": "É necessária uma confirmação por e-mail",
"confirmationRequiredMessage": "Verifique a sua caixa de entrada para confirmar o seu e-mail.",
"yourAccountIsValidUntil": "A sua conta é válida até {data}.",
"sendPIN": "Envie o PIN abaixo para o bot e, em seguida, volte aqui para associar a sua conta.",
"sendPINDiscord": "Escreva {command} em {server_channel} no Discord e envie o PIN abaixo.",
"matrixEnterUser": "Introduza o seu ID de utilizador, prima enviar e ser-lhe-á enviado um PIN. Introduza-o aqui para continuar.",
"welcomeUser": "Bem-vindo(a), {utilizador}!",
"addContactMethod": "Adicionar método de contacto",
"editContactMethod": "Editar método de contacto",
"joinTheServer": "Junte-se ao servidor:",
"customMessagePlaceholderHeader": "Personalizar este cartão",
"customMessagePlaceholderContent": "Clique no botão de edição da página do utilizador nas definições para personalizar este cartão, ou mostre um no ecrã de início de sessão, e não se preocupe, o utilizador não o pode ver.",
"userPageSuccessMessage": "Pode ver e alterar os detalhes da sua conta mais tarde na página {myAccount}.",
"resetPassword": "Redefinir palavra-passe",
"resetPasswordThroughJellyfin": "Para redefinir a sua palavra-passe, visite {jfLink} e prima o botão “Esqueci-me da palavra-passe”.",
"resetPasswordThroughLink": "Para redefinir a sua palavra-passe, introduza o seu nome de utilizador, e-mail ou um nome de utilizador de um método de contacto associado e clique em enviar. Será enviado um link para redefinir a sua palavra-passe.",
"resetPasswordThroughLinkStart": "Para redefinir a sua palavra-passe, introduza uma das seguintes opções:",
"resetPasswordThroughLinkEnd": "Em seguida, clique em enviar. Será enviado um link para redefinir a sua palavra-passe.",
"resetPasswordUsername": "O seu nome de utilizador Jellyfin",
"resetPasswordEmail": "O seu endereço de e-mail",
"resetPasswordContactMethod": "O nome de utilizador de qualquer método de contacto associado à sua conta",
"resetSent": "Link de redefinição enviado.",
"resetSentDescription": "Se existir uma conta com o nome de utilizador/método de contacto indicado, será enviado um link de redefinição da palavra-passe através de todos os métodos de contacto disponíveis. O código expirará dentro de 30 minutos.",
"changePassword": "Alterar palavra-passe",
"referralsDescription": "Convide amigos e familiares para o Jellyfin com este link. Volte aqui para obter um novo link se ele expirar.",
"referralsWithExpiryDescription": "Convide amigos e familiares para o Jellyfin com este link. O link será desativado quando expirar.",
"copyReferral": "Copiar link",
"invitedBy": "Foi convidado pelo utilizador {user}."
},
"notifications": {
"errorUserExists": "O utilizador já existe.",
"errorInvalidCode": "Código de convite inválido.",
"errorAccountLinked": "Esta conta já está a ser utilizada.",
"errorEmailLinked": "Este e-mail já está a ser utilizado.",
"errorTelegramVerification": "É necessária a verificação do Telegram.",
"errorDiscordVerification": "É necessária a verificação do Discord.",
"errorMatrixVerification": "É necessária a verificação da Matrix.",
"errorInvalidPIN": "PIN inválido.",
"errorUnknown": "Erro desconhecido.",
"errorNoEmail": "E-mail necessário.",
"errorCaptcha": "Captcha incorreto.",
"errorPassword": "Verifique os requisitos da palavra-passe.",
"errorNoMatch": "As palavras-passe não coincidem.",
"errorOldPassword": "A palavra-passe antiga está incorreta.",
"passwordChanged": "Palavra-passe alterada.",
"verified": "Conta verificada."
},
"validationStrings": {
"length": {
"singular": "Deve ter pelo menos {n} caráter",
"plural": "Deve ter pelo menos {n} carateres"
},
"uppercase": {
"singular": "Deve ter pelo menos {n} caráter em maiúscula",
"plural": "Deve ter pelo menos {n} carateres em maiúsculas"
},
"lowercase": {
"singular": "Deve ter pelo menos {n} caráter minúsculo",
"plural": "Deve ter pelo menos {n} carateres minúsculos"
},
"number": {
"singular": "Deve ter pelo menos {n} número",
"plural": "Deve ter pelo menos {n} números"
},
"special": {
"singular": "Deve ter pelo menos {n} caráter especial",
"plural": "Deve ter pelo menos {n} carateres especiais"
}
}
}

88
lang/form/th-TH.json Normal file
View File

@@ -0,0 +1,88 @@
{
"meta": {
"name": "ภาษาไทย (TH)"
},
"strings": {
"pageTitle": "สมัครบัญชี Jellyfin",
"createAccountHeader": "สมัครบัญชี",
"accountDetails": "รายละเอียด",
"emailAddress": "ที่อยู่อีเมล",
"username": "ชื่อผู้ใช้งาน",
"oldPassword": "รหัสผ่านเก่า",
"newPassword": "รหัสผ่านใหม่",
"password": "รหัสผ่าน",
"reEnterPassword": "กรอกรหัสผ่านซ้ำ",
"reEnterPasswordInvalid": "รหัสผ่านไม่เหมือนกัน",
"createAccountButton": "สมัครบัญชี",
"passwordRequirementsHeader": "ความต้องการของรหัสผ่าน",
"successHeader": "สำเร็จ!",
"confirmationRequired": "จำเป็นต้องยืนยันอีเมล",
"confirmationRequiredMessage": "โปรดตรวจสอบกล่องข้อความ (Inbox) เพื่อยืนยันที่อยู่ของท่าน",
"yourAccountIsValidUntil": "บัญชีของคุณจะใช้ได้ถึงวันที่ {date}",
"sendPIN": "ส่งรหัส (PIN) ด้านล่างไปให้บอท, หลังจากนั้นกลับมาเพื่อผูกบัญชีของคุณ",
"sendPINDiscord": "พิมพ์ {command} ในห้อง {server_channel} บน Discord, หลังจากนั้นส่งรหัส (PIN) ด้านล่าง",
"matrixEnterUser": "กรอกรหัสประจำไอดี, แล้วกดส่ง, รหัส (PIN) จะส่งไปให้คุณ และโปรดกรอกลงตรงนี้เพื่อดำเนินการต่อ",
"welcomeUser": "ยินดีต้อนรับ, {user}!",
"addContactMethod": "เพิ่มช่องทางการติดต่อ",
"editContactMethod": "แก้ไขช่องทางการติดต่อ",
"joinTheServer": "เข้าร่วมเซิฟเวอร์:",
"customMessagePlaceholderHeader": "ปรับเปลี่ยนการ์ดนี้",
"customMessagePlaceholderContent": "กดไปที่ปุ่ม \"แก้ไขหน้าผู้ใช้\" ในการตั้งค่าเพื่อปรับเปลี่ยนการ์ดนี้, หรือแสดงบนหน้าเข้าสู่ระบบ, และไม่ต้องห่วง! ผู้ใช้จะไม่เห็นสิ่งนี้",
"userPageSuccessMessage": "คุณสามารถแก้ไขหรือดูข้อมูลเกี่ยวกับบัญชีได้ทีหลัง ผ่านหน้า {myAccount}",
"resetPassword": "ตั้งค่ารหัสผ่านใหม่",
"resetPasswordThroughJellyfin": "หากต้องการตั้งค่ารหัสผ่านใหม่, เข้าไปที่ {jfLink} และกดปุ่ม \"ตั้งค่ารหัสผ่านใหม่\"",
"resetPasswordThroughLink": "หากต้องการตั้งค่ารหัสผ่านใหม่, กรอกชื่อผู้ใช้, บัญชีอีเมล หรือชื่อผู้ใช้ที่เชื่อมไว้, แล้วกดส่ง แล้วลิงค์จะถูกส่งให้ตั้งค่ารหัสผ่านใหม่",
"resetPasswordThroughLinkStart": "หากต้องการตั้งค่ารหัสผ่านใหม่, กรอกช่องใดช่องหนึ่งด้านล่าง:",
"resetPasswordThroughLinkEnd": "หลังจากนั้นกดส่ง แล้วลิงค์จะถูกส่งให้ตั้งค่ารหัสผ่านใหม่",
"resetPasswordUsername": "ชื่อผู้ใช้ Jellyfin ของคุณ",
"resetPasswordEmail": "บัญชีอีเมลของคุณ",
"resetPasswordContactMethod": "ชื่อผู้ใช้ที่เชื่อมไว้กับบัญชีของคุณอันใดก็ได้",
"resetSent": "ส่งอีกครั้ง",
"resetSentDescription": "ถ้าหากบัญชี/ข้อมูลที่ให้ไว้ตรงกับในระบบ, ลิงค์เพื่อตั้งค่ารหัสใหม่จะถูกส่งให้ผ่านช่องทางติดต่อทั้งหมด. รหัสจะหมดอายุภายใน 30 นาที",
"changePassword": "เปลี่ยนรหัสผ่าน",
"referralsDescription": "ชวนเพื่อน & ครอบครัวเข้าสู่ Jellyfin ผ่านลิงค์นี้, กลับมาเอาอันใหม่อีกครั้งหากหมดอายุ",
"referralsWithExpiryDescription": "ชวนเพื่อน & ครอบครัวเข้าสู่ Jellyfin ผ่านลิงค์นี้, ลิงค์นี้จะถูกปิดใช้งานหลังจากหมดอายุ",
"copyReferral": "คัดลอกลิงค์",
"invitedBy": "คุณถูกเชิญโดยผู้ใช้ {user}"
},
"notifications": {
"errorUserExists": "ชื่อผู้ใช้นี้มีอยู่แล้ว",
"errorInvalidCode": "รหัสเชิญไม่ถูกต้อง",
"errorAccountLinked": "บัญชีนี้ถูกใช้ไปแล้ว",
"errorEmailLinked": "อีเมลนี้ถูกใช้ไปแล้ว",
"errorTelegramVerification": "จำเป็นต้องยืนยันตัวตนผ่าน Telegram",
"errorDiscordVerification": "จำเป็นต้องยืนยันตัวตนผ่าน Discord",
"errorMatrixVerification": "จำเป็นต้องยืนยันตัวตนผ่าน Matrix",
"errorInvalidPIN": "รหัส (PIN) ไม่ถูกต้อง",
"errorUnknown": "เกิดข้อผิดผลาดที่ไม่รู้จัก",
"errorNoEmail": "จำเป็นต้องกรอกอีเมล",
"errorCaptcha": "ยืนยันตัว (Capcha) ไม่ถูกต้อง",
"errorPassword": "ตรวจสอบกฎเกณฑ์รหัสผ่าน",
"errorNoMatch": "รหัสผ่านไม่ตรงกัน",
"errorOldPassword": "รหัสผ่านเก่าไม่ถูกต้อง",
"passwordChanged": "รหัสผ่านถูกเปลี่ยนแล้ว",
"verified": "ยืนยันบัญชีแล้ว"
},
"validationStrings": {
"length": {
"singular": "ต้องมีอย่างน้อย {n} ตัว",
"plural": "ต้องมีอย่างน้อย {n} ตัว"
},
"uppercase": {
"singular": "ต้องมีตัวอักษรตัวใหญ่อย่างน้อย {n} ตัว",
"plural": "ต้องมีตัวอักษรตัวใหญ่อย่างน้อย {n} ตัว"
},
"lowercase": {
"singular": "ต้องมีตัวอักษรตัวเล็กอย่างน้อย {n} ตัว",
"plural": "ต้องมีตัวอักษรตัวเล็กอย่างน้อย {n} ตัว"
},
"number": {
"singular": "ต้องมีตัวเลขอย่างน้อย {n} ตัว",
"plural": "ต้องมีตัวเลขอย่างน้อย {n} ตัว"
},
"special": {
"singular": "ต้องมีอักขระพิเศษอย่างน้อย {n} ตัว",
"plural": "ต้องมีอักขระพิเศษอย่างน้อย {n} ตัว"
}
}
}

88
lang/form/tr-TR.json Normal file
View File

@@ -0,0 +1,88 @@
{
"meta": {
"name": "İngilizce (ABD)"
},
"strings": {
"pageTitle": "Jellyfin Hesabı Oluştur",
"createAccountHeader": "Hesap Oluştur",
"accountDetails": "Ayrıntılar",
"emailAddress": "E-posta",
"username": "Kullanıcı Adı",
"oldPassword": "Eski Şifre",
"newPassword": "Yeni Şifre",
"password": "Şifre",
"reEnterPassword": "Şifreyi Tekrar Girin",
"reEnterPasswordInvalid": "Şifreler aynı değil.",
"createAccountButton": "Hesap Oluştur",
"passwordRequirementsHeader": "Şifre Gereksinimleri",
"successHeader": "Başarılı!",
"confirmationRequired": "E-posta onayı gerekli",
"confirmationRequiredMessage": "Lütfen adresinizi doğrulamak için e-posta gelen kutunuzu kontrol edin.",
"yourAccountIsValidUntil": "Hesabınız {date} tarihine kadar geçerli olacaktır.",
"sendPIN": "Aşağıdaki **PIN'i** bota gönderin, ardından hesabınızı bağlamak için buraya geri gelin.",
"sendPINDiscord": "Discord'da {server_channel} {command} yazın, ardından aşağıdaki PIN'i gönderin.",
"matrixEnterUser": "Kullanıcı Kimliğinizi girin, gönderin ve size bir PIN gönderilecektir. Devam etmek için buraya girin.",
"welcomeUser": "Hoşgeldin, {user}!",
"addContactMethod": "İletişim Yöntemi Ekle",
"editContactMethod": "İletişim Yöntemini Düzenle",
"joinTheServer": "Sunucuya katıl:",
"customMessagePlaceholderHeader": "Bu kartı özelleştir",
"customMessagePlaceholderContent": "Bu kartı özelleştirmek için ayarlarda kullanıcı sayfası düzenleme düğmesine tıklayın ya da oturum açma ekranında bir tane gösterin ve endişelenmeyin, kullanıcı bunu göremez.",
"userPageSuccessMessage": "Hesabınızla ilgili ayrıntıları daha sonra {myAccount} sayfasında görebilir ve değiştirebilirsiniz.",
"resetPassword": "Şifreyi Sıfırla",
"resetPasswordThroughJellyfin": "Şifrenizi sıfırlamak için {jfLink} adresini ziyaret edin ve \"Şifremi Unuttum\" düğmesine basın.",
"resetPasswordThroughLink": "Şifrenizi sıfırlamak için kullanıcı adınızı, e-posta adresinizi veya bağlı bir iletişim yöntemi kullanıcı adınızı girin ve gönderin. Şifrenizi sıfırlamanız için bir bağlantı gönderilecektir.",
"resetPasswordThroughLinkStart": "Şifrenizi sıfırlamak için aşağıdakilerden birini girin:",
"resetPasswordThroughLinkEnd": "Şifrenizi sıfırlamanız için bir bağlantı gönderilecektir. Ardından gönder'e basın.",
"resetPasswordUsername": "Jellyfin kullanıcı adınız",
"resetPasswordEmail": "E-posta adresiniz",
"resetPasswordContactMethod": "Hesabınıza bağlı herhangi bir iletişim yönteminin kullanıcı adı",
"resetSent": "Sıfırlama Gönderildi.",
"resetSentDescription": "Verilen kullanıcı adı/iletişim yöntemine sahip bir hesap varsa, mevcut tüm iletişim yöntemleri aracılığıyla bir şifre sıfırlama bağlantısı gönderilmiştir. Kodun süresi **30 dakika** içinde dolacaktır.",
"changePassword": "Şifreyi Değiştir",
"referralsDescription": "Bu bağlantı ile arkadaşlarınızı ve ailenizi Jellyfin'e davet edin. Süresi dolarsa yeni bir tane almak için buraya geri gelin.",
"referralsWithExpiryDescription": "Bu bağlantı ile arkadaşlarınızı ve ailenizi Jellyfin'e davet edin. Bağlantının süresi dolduğunda devre dışı bırakılacaktır.",
"copyReferral": "Linki Kopyala",
"invitedBy": "Sizi {user} adlı kullanıcı davet etti."
},
"notifications": {
"errorUserExists": "Kullanıcı zaten mevcut.",
"errorInvalidCode": "Geçersiz davet kodu.",
"errorAccountLinked": "Hesap zaten kullanımda.",
"errorEmailLinked": "E-posta zaten kullanımda.",
"errorTelegramVerification": "Telegram doğrulama gerekli.",
"errorDiscordVerification": "Discord doğrulama gerekli.",
"errorMatrixVerification": "Matrix doğrulama gerekli.",
"errorInvalidPIN": "PIN geçersiz.",
"errorUnknown": "Bilinmeyen hata.",
"errorNoEmail": "E-posta gerekli.",
"errorCaptcha": "Captcha yanlış.",
"errorPassword": "Şifre gereksinimlerini kontrol edin.",
"errorNoMatch": "Şifreler eşleşmiyor.",
"errorOldPassword": "Eski şifre yanlış.",
"passwordChanged": "Şifre Değiştirildi.",
"verified": "Hesap doğrulandı."
},
"validationStrings": {
"length": {
"singular": "En az {n} karakter içermeli",
"plural": "En az {n} karakter içermeli"
},
"uppercase": {
"singular": "En az {n} büyük harf içermeli",
"plural": "En az {n} büyük harf içermeli"
},
"lowercase": {
"singular": "En az {n} küçük harf içermeli",
"plural": "En az {n} küçük harf içermeli"
},
"number": {
"singular": "En az {n} küçük harf içermeli",
"plural": "En az {n} küçük harf içermeli"
},
"special": {
"singular": "En az {n} özel karakter içermeli",
"plural": "En az {n} özel karakter içermeli"
}
}
}

16
lang/pwreset/th-TH.json Normal file
View File

@@ -0,0 +1,16 @@
{
"meta": {
"name": "ภาษาไทย (TH)"
},
"strings": {
"passwordReset": "ตั้งค่ารหัสผ่านใหม่",
"reset": "ตั้งค่าใหม่",
"resetFailed": "การตั้งค่ารหัสผ่านใหม่ผิดพลาด",
"tryAgain": "โปรดลองอีกครั้ง",
"youCanLogin": "คุณสามารถเข้าสู่ระบบด้วยรหัสด้านล่างเป็นรหัสผ่านของคุณ",
"youCanLoginOmbi": "คุณสามารถเข้าสู่ระบบ Jellyfin & Ombi ด้วยรหัสด้านล่างเป็นรหัสผ่านของคุณ",
"youCanLoginPassword": "คุณสามารถเข้าใช้งานได้ด้วยรหัสผ่านใหม่ กดปุ่มด้านล่างเพื่อเข้าสู่ Jellyfin",
"changeYourPassword": "อย่าลืมเปลี่ยนรหัสใหม่หลังจากเข้าสู่ระบบ",
"enterYourPassword": "กรอกรหัสผ่านใหม่ด้านล่าง"
}
}

16
lang/pwreset/tr-TR.json Normal file
View File

@@ -0,0 +1,16 @@
{
"meta": {
"name": "İngilizce (ABD)"
},
"strings": {
"passwordReset": "Şifre sıfırlama",
"reset": "Sıfırla",
"resetFailed": "Şifre sıfırlama başarısız oldu",
"tryAgain": "Lütfen tekrar deneyin.",
"youCanLogin": "Artık aşağıdaki kodla şifreniz olarak oturum açabilirsiniz.",
"youCanLoginOmbi": "Artık aşağıdaki kodu şifreniz olarak kullanarak Jellyfin & Ombi'ye oturum açabilirsiniz.",
"youCanLoginPassword": "Artık yeni şifrenizle oturum açabilirsiniz. Jellyfin'e devam etmek için aşağıya basın.",
"changeYourPassword": "Oturum açtıktan sonra şifrenizi değiştirdiğinizden emin olun.",
"enterYourPassword": "Yeni şifrenizi aşağıya girin."
}
}

View File

@@ -131,4 +131,4 @@
"stable": "Σταθερό",
"unstable": "Ασταθές"
}
}
}

View File

@@ -141,10 +141,10 @@
},
"notifications": {
"title": "Admin Notifications",
"description": "If enabled, you can choose (per invite) to receive an message when an invite expires, or a user is created. If you didn't choose the Jellyfin login method, make sure you provided your email address, or add another contact method later."
"description": "If enabled, you can choose (per invite) to receive a message when an invite expires, or a user is created. If you didn't choose the Jellyfin login method, make sure you provided your email address, or add another contact method later."
},
"inviteEmails": {
"title": "Invite Messages",
"description": "If enabled, you can send invites directly to a user's email address, Discord or Matrix user. Because you might be using a reverse proxy, you need to provide the URL invites are accessed from. Write your URL Base, and append '/invite'."
}
}
}

View File

@@ -18,7 +18,7 @@
"apiKey": "API Key",
"error": "Error",
"errorInvalidUserPass": "Invalid username/password.",
"errorNotAdmin": "User is not aEnabledllowed to manage server.",
"errorNotAdmin": "User is not allowed to manage server.",
"errorUserDisabled": "User may be disabled.",
"error404": "404, check the internal URL.",
"errorConnectionRefused": "Connection refused.",
@@ -33,8 +33,9 @@
},
"endPage": {
"finished": "Finished!",
"restartMessage": "Features like Discord/Telegram/Matrix bots, custom Markdown messages, and a user-accessible \"My Account\" page can be found in Settings, so make sure to give it a browse. Click below to restart, then refresh the page.",
"urlChangedNotice": "If you've changed the host, port, subfolder etc. that jfa-go is hosted on, check the URL is right.",
"moreFeatures": "Tons more features like Discord/Telegram/Matrix bots and custom Markdown messages can be found in Settings, so make sure to give it a browse.",
"restartReload": "Click below to restart, then access jfa-go at one of the given internal/external URLs.",
"ifFailedLoad": "If it doesn't load, check the application's logs for any clues as to why.",
"refreshPage": "Refresh"
},
"language": {
@@ -125,7 +126,7 @@
},
"notifications": {
"title": "Admin Notifications",
"description": "If enabled, you can choose (per invite) to receive an message when an invite expires, or a user is created. If you didn't choose the Jellyfin login method, make sure you provided your email address, or add another contact method later."
"description": "If enabled, you can choose (per invite) to receive a message when an invite expires, or a user is created. If you didn't choose the Jellyfin login method, make sure you provided your email address, or add another contact method later."
},
"userPage": {
"title": "User Page",
@@ -135,7 +136,7 @@
},
"welcomeEmails": {
"title": "Welcome messages",
"description": "If enabled, an message will be sent to new users with the Jellyfin/Emby URL and their username."
"description": "If enabled, a message will be sent to new users with the Jellyfin/Emby URL and their username."
},
"inviteEmails": {
"title": "Invite Messages",

167
lang/setup/fa-IR.json Normal file
View File

@@ -0,0 +1,167 @@
{
"meta": {
"name": ""
},
"strings": {
"pageTitle": "",
"next": "",
"back": "",
"optional": "",
"serverType": "",
"disabled": "",
"enabled": "",
"port": "",
"message": "",
"serverAddress": "",
"emailSubject": "",
"URL": "",
"apiKey": "",
"error": "",
"errorInvalidUserPass": "",
"errorNotAdmin": "",
"errorUserDisabled": "",
"error404": "",
"errorConnectionRefused": "",
"errorUnknown": "",
"errorProxy": ""
},
"startPage": {
"welcome": "",
"pressStart": "",
"httpsNotice": "",
"start": ""
},
"endPage": {
"finished": "",
"restartMessage": "",
"refreshPage": ""
},
"language": {
"title": "",
"description": "",
"defaultAdminLang": "",
"defaultFormLang": "",
"defaultEmailLang": ""
},
"general": {
"title": "",
"listenAddress": "",
"urlBase": "",
"urlBaseNotice": "",
"lightTheme": "",
"darkTheme": "",
"useHTTPS": "",
"httpsPort": "",
"useHTTPSNotice": "",
"pathToCertificate": "",
"pathToKeyFile": ""
},
"updates": {
"title": "",
"description": "",
"updateChannel": "",
"stable": "",
"unstable": ""
},
"proxy": {
"title": "",
"description": "",
"protocol": "",
"address": ""
},
"login": {
"title": "",
"description": "",
"authorizeWithJellyfin": "",
"authorizeManual": "",
"adminOnly": "",
"allowAll": "",
"allowAllDescription": "",
"authorizeManualUserPageNotice": "",
"emailNotice": ""
},
"jellyfinEmby": {
"title": "",
"description": "",
"embyNotice": "",
"internal": "",
"external": "",
"replaceJellyfin": "",
"replaceJellyfinNotice": "",
"addressExternalNotice": "",
"testConnection": ""
},
"ombi": {
"title": "",
"description": "",
"apiKeyNotice": ""
},
"messages": {
"title": "",
"description": ""
},
"email": {
"title": "",
"description": "",
"method": "",
"useEmailAsUsername": "",
"useEmailAsUsernameNotice": "",
"fromAddress": "",
"senderName": "",
"dateFormat": "",
"dateFormatNotice": "",
"encryption": "",
"mailgunApiURL": ""
},
"notifications": {
"title": "",
"description": ""
},
"userPage": {
"title": "",
"description": "",
"customizeMessages": "",
"requiredSettings": ""
},
"welcomeEmails": {
"title": "",
"description": ""
},
"inviteEmails": {
"title": "",
"description": ""
},
"passwordResets": {
"title": "",
"description": "",
"pathToJellyfin": "",
"pathToJellyfinNotice": "",
"resetLinks": "",
"resetLinksRequiredForUserPage": "",
"resetLinksNotice": "",
"resetLinksLanguage": "",
"setPassword": "",
"setPasswordNotice": ""
},
"passwordValidation": {
"title": "",
"description": "",
"length": "",
"uppercase": "",
"lowercase": "",
"numbers": "",
"special": ""
},
"helpMessages": {
"title": "",
"description": "",
"contactMessage": "",
"contactMessageNotice": "",
"helpMessage": "",
"helpMessageNotice": "",
"successMessage": "",
"successMessageNotice": "",
"emailMessage": "",
"emailMessageNotice": ""
}
}

View File

@@ -20,132 +20,162 @@
"errorNotAdmin": "A felhasználó számára nincs engedélyezve a szerver kezelése.",
"errorUserDisabled": "Lehetséges, hogy a felhasználó le lett tiltva.",
"error404": "404, ellenőrizze a belső URL-t.",
"errorConnectionRefused": "",
"error": "Hiba"
"errorConnectionRefused": "Csatlakozás visszautasítva.",
"error": "Hiba",
"errorUnknown": "Váratlan hiba, ellenőrizd a napló fájlt.",
"errorProxy": "Proxy beállítás érvénytelen."
},
"startPage": {
"welcome": "Üdv!",
"pressStart": "",
"httpsNotice": "",
"start": ""
"pressStart": "A jfa-go beállításához néhány dolgot el kell végezned. A folytatáshoz nyomd meg a kezdés gombot.",
"httpsNotice": "Győződjön meg róla, hogy HTTPS-en vagy privát hálózaton keresztül éri el ezt az oldalt.",
"start": "Kezdés"
},
"endPage": {
"finished": "",
"finished": "Kész!",
"restartMessage": "",
"refreshPage": ""
"refreshPage": "Újratöltés",
"moreFeatures": "Rengeteg további funkció, mint például a Discord/Telegram/Matrix botok és az egyéni Markdown üzenetek, megtalálható a Beállításokban, ezért mindenképpen böngészd át őket.",
"restartReload": "Kattints ide az újraindításhoz, majd a megadott belső/külső URL-címek egyikén nyisd meg a jfa-go alkalmazást.",
"ifFailedLoad": "Ha nem töltődik be, ellenőrizd az alkalmazás naplóit, hogy miért."
},
"language": {
"title": "",
"description": "",
"defaultAdminLang": "",
"defaultFormLang": "",
"defaultEmailLang": ""
"title": "Nyelv",
"description": "A jfa-go legtöbb részéhez elérhetők közösségi fordítások. Az alábbiakban kiválaszthatod az alapértelmezett nyelveket, de a felhasználók továbbra is módosíthatják azokat, ha akarják. Ha szeretnél segíteni a fordításban, regisztrálj a {n}-re, hogy elkezdhesd a közreműködést!",
"defaultAdminLang": "Alapártelmezett rendszergazda nyelv",
"defaultFormLang": "Alapértelmezett fiók nyelv",
"defaultEmailLang": "Alapértelmezett email nyelv"
},
"general": {
"title": "",
"listenAddress": "",
"urlBase": "",
"urlBaseNotice": "",
"lightTheme": "",
"darkTheme": "",
"useHTTPS": "",
"httpsPort": "",
"useHTTPSNotice": "",
"pathToCertificate": "",
"pathToKeyFile": ""
"title": "Alap",
"listenAddress": "Figyelő címe",
"urlBase": "Alap URL",
"urlBaseNotice": "Csak akkor szükséges, ha fordított proxyt használsz egy almappán (pl. 'jellyf.in/accounts').",
"lightTheme": "Fényes",
"darkTheme": "Sötét",
"useHTTPS": "HTTPS használata",
"httpsPort": "HTTPS Port",
"useHTTPSNotice": "Csak akkor aljánlott ha fordított proxy-t használsz.",
"pathToCertificate": "Tanúsítvány elérési útja",
"pathToKeyFile": "Kulcs fájl elérési útja",
"externalURLNotice": "Az URL, amelyről a jfa-go címhez fogsz hozzáférni. Linkek generálására szolgál, például jelszó-visszaállításhoz. Ha beállítottál egyet, feltétlenül add meg a fenti alap URL-t is.",
"externalURL": "Külső jfa-go URL"
},
"updates": {
"title": "",
"description": "",
"updateChannel": "",
"stable": "",
"unstable": ""
"title": "Frissítések",
"description": "Engedélyezd ha szeretnél értesítést az új frissítésekről. A jfa-go 30 percenként ellenőrzi a(z) {n} címet. Nem gyűjt IP-címeket vagy személyes adatokat.",
"updateChannel": "Csatorna frissítése",
"stable": "Stabil",
"unstable": "Instabil"
},
"login": {
"title": "",
"description": "",
"authorizeWithJellyfin": "",
"authorizeManual": "",
"adminOnly": "",
"allowAll": "",
"allowAllDescription": "",
"emailNotice": ""
"title": "Belépés",
"description": "Az admin oldal eléréséhez az alábbi módszerrel kell bejelentkezned:",
"authorizeWithJellyfin": "Bejelentkezés Jellyfin/Emby segítségével: A bejelentkezési adatok meg vannak osztva a Jellyfin-nel, ami több felhasználó létrehozását teszi lehetővé.",
"authorizeManual": "Felhasználónév és Jelszó: Felhasználónév és jelszó manuális beállítása.",
"adminOnly": "Csak rendszergazda felhasználók (ajánlott)",
"allowAll": "Összes Jellyfin felhasználó belépéssének engedélyezése",
"allowAllDescription": "Nem ajánlott, a beállítás után engedélyezni kell az egyes felhasználók bejelentkezését.",
"emailNotice": "Az email címed értesítések fogadására lesz használva.",
"authorizeManualUserPageNotice": "Ennek használata letiltja a „Felhasználói oldal” funkciót."
},
"jellyfinEmby": {
"title": "",
"description": "",
"embyNotice": "",
"internal": "",
"external": "",
"replaceJellyfin": "",
"replaceJellyfinNotice": "",
"addressExternalNotice": "",
"testConnection": ""
"title": "Jellyfin/Emby",
"description": "Egy adminisztrátori fiók szükséges, mivel az API nem engedélyezi a felhasználók létrehozását API-kulcs használatával. Létre kell hoznia egy külön fiókot, és engedélyeznie kell az „Ez a felhasználó kezelheti a szervert” beállítást. Minden mást letilthat. Ha ezt megtette, adja meg itt a hitelesítő adatait.",
"embyNotice": "Az Emby támogatása korlátozott, és nem támogatja a jelszó-visszaállítást.",
"internal": "Belső",
"external": "Külső",
"replaceJellyfin": "Szerver neve",
"replaceJellyfinNotice": "Ha meg van adva, ez felülírja a 'Jellyfin' minden előfordulását az alkalmazásban.",
"addressExternalNotice": "Hagyja üresen, ha ugyanazt a címet szeretnéd használni.",
"testConnection": "Kapcsolat tesztelése"
},
"ombi": {
"title": "",
"description": "",
"apiKeyNotice": ""
"title": "Ombi",
"description": "Az Ombihoz való csatlakozással Jellyfin és Ombi fiók is létrejön, amikor a felhasználó a jfa-go-n keresztül csatlakozik. A beállítás befejezése után lépjen a Beállítások menüpontra, hogy alapértelmezett profilt állítson be az új ombi-felhasználók számára.",
"apiKeyNotice": "Ezt az Ombi beállítások első lapján találod.",
"stabilityWarning": "Figyelmeztetés: Az Ombi integráció instabil, és problémákat okozhat. Helyette a Jellyseerr használata ajánlott. További információkért lásd: {n}."
},
"messages": {
"title": "",
"description": ""
"title": "Üzenetek",
"description": "A jfa-go jelszó-visszaállítási információkat és különféle üzeneteket tud küldeni e-mailben, Discordon, Telegramon és/vagy Matrixon keresztül. Az e-mailt alább állíthatod be, a többit pedig később a Beállításokban konfigurálhatod. Az utasításokat a {n} oldalon találod. Ha erre nincs szükséged, itt letilthatod ezeket a funkciókat."
},
"email": {
"title": "",
"description": "",
"method": "",
"useEmailAsUsername": "",
"useEmailAsUsernameNotice": "",
"fromAddress": "",
"senderName": "",
"dateFormat": "",
"dateFormatNotice": "",
"encryption": "",
"mailgunApiURL": ""
"title": "Email",
"description": "A jfa-go jelszó-visszaállító PIN-kódokat és különféle értesítéseket tud küldeni e-mailben. Csatlakozhatsz egy SMTP-kiszolgálóhoz, vagy használhatod az {n} API-t.",
"method": "Küldési mód",
"useEmailAsUsername": "Email cím használata fehasználónévnek",
"useEmailAsUsernameNotice": "Ha engedélyezve van, az új felhasználók a Jellyfin/Emby rendszerbe felhasználónév helyett az e-mail címükkel jelentkeznek be.",
"fromAddress": "Feladó címe",
"senderName": "Küldő címe",
"dateFormat": "Dátum formátuma",
"dateFormatNotice": "A dátum az strftime formátumot követi. További információkért látogasson el a {n} oldalra.",
"encryption": "Titkosítás",
"mailgunApiURL": "API URL"
},
"notifications": {
"title": "",
"description": ""
"title": "Rendszergazda értesítések",
"description": "Ha engedélyezve van, meghívónként kiválaszthatod, hogy üzenetet kapj-e, amikor egy meghívó lejár, vagy amikor létrejön egy felhasználó. Ha nem a Jellyfin bejelentkezési módot választottad, győződj meg róla, hogy megadtad az e-mail címedet, vagy adj hozzá később egy másik kapcsolatfelvételi módot."
},
"welcomeEmails": {
"title": "",
"description": ""
"title": "Üdvözlő üzenetek",
"description": "Ha engedélyezve van, az új felhasználók üzenetben kapják meg a Jellyfin/Emby URL-címet és a felhasználónevüket."
},
"inviteEmails": {
"title": "",
"description": ""
"title": "Meghívó üzenetek",
"description": "Ha engedélyezve van, közvetlenül a felhasználó e-mail címére, Discord vagy Matrix felhasználóra küldhet meghívókat. Mivel fordított proxyt használhat, meg kell adnia azt az URL-címet, ahonnan a meghívók elérhetők. Írja be az URL-alapját, és fűzze hozzá a '/invite' részt."
},
"passwordResets": {
"title": "",
"description": "",
"pathToJellyfin": "",
"pathToJellyfinNotice": "",
"resetLinks": "",
"resetLinksNotice": "",
"resetLinksLanguage": "",
"setPassword": "",
"setPasswordNotice": ""
"title": "Jelszó visszaállítás",
"description": "Amikor egy felhasználó megpróbálja visszaállítani a jelszavát, a Jellyfin létrehoz egy „passwordreset-*.json” nevű fájlt, amely egy PIN-kódot tartalmaz. A jfa-go beolvassa a fájlt, és elküldi a PIN-kódot a felhasználónak. Ha engedélyezte a „Felhasználói oldal” funkciót, a visszaállítás ott is elvégezhető felhasználónév, e-mail cím vagy kapcsolatfelvételi mód megadásával.",
"pathToJellyfin": "Jellyfin konfigurációs könyvtár elérési útja",
"pathToJellyfinNotice": "Ha nem tudod, hol van ez, próbáld meg visszaállítani a jelszavadat a Jellyfinben. Megjelenik egy felugró ablak a következővel: '<jellyfin elérési útja>/passwordreset-*.json'. Ez nem szükséges, ha csak az önkiszolgáló jelszó-visszaállítást szeretnéd használni a \"Felhasználói oldalon\".",
"resetLinks": "Link küldése PIN kód helyett",
"resetLinksNotice": "Ha az Ombi integráció engedélyezve van, használja ezt a Jellyfin jelszó-visszaállítások Ombival való szinkronizálásához.",
"resetLinksLanguage": "Alapértelmezett jelszó-visszaállítási nyelv",
"setPassword": "Jelszó beállítás linken keresztül",
"setPasswordNotice": "Ha engedélyezve van, a felhasználónak nem kell PIN-kóddal módosítania a jelszavát. Ez a jelszó-ellenőrzést is kikényszeríti.",
"moreInfo": "A jelszavak visszaállításának különböző módjairól további információt a {n} oldalon talál.",
"resetLinksRequiredForUserPage": "Szükséges az önkiszolgáló jelszó-visszaállításhoz a felhasználói oldalon."
},
"passwordValidation": {
"title": "",
"description": "",
"length": "",
"uppercase": "",
"lowercase": "",
"numbers": "",
"special": ""
"title": "Jelszóérvényesítés",
"description": "Ha engedélyezve van, a fiók létrehozási oldalán megjelennek a jelszóra vonatkozó követelmények, például a minimális hossz, a nagy- és kisbetűk stb.",
"length": "Hossz",
"uppercase": "Nagybetűs karakterek",
"lowercase": "Kisbetűs karakterek",
"numbers": "Számok",
"special": "Speciális karakterek"
},
"helpMessages": {
"title": "",
"description": "",
"contactMessage": "",
"contactMessageNotice": "",
"helpMessage": "",
"helpMessageNotice": "",
"successMessage": "",
"successMessageNotice": "",
"emailMessage": "",
"emailMessageNotice": ""
"title": "Súgóüzenetek",
"description": "Ezek az üzenetek a fiók létrehozási oldalán és néhány e-mailben jelennek meg.",
"contactMessage": "Kapcsolatfelvételi üzenet",
"contactMessageNotice": "Az adminisztrációs oldal kivételével az összes oldal alján megjelenik.",
"helpMessage": "Súgóüzenet",
"helpMessageNotice": "A fiók létrehozási oldalán jelenik meg.",
"successMessage": "Sikeres üzenet",
"successMessageNotice": "Akkor jelenik meg, amikor a felhasználó létrehozza a fiókját.",
"emailMessage": "Email üzenet",
"emailMessageNotice": "Az e-mailek alján jelenik meg.",
"markdownMessageNotice": "Egyes e-mailek, oldalak és üzenetek tartalma testreszabható a Markdown segítségével a beállításokban."
},
"jellyseerr": {
"description": "A Jellyseerr az Ombi alternatívája, és jobban integrálódik a jfa-go-val. A beállítás befejezése után a Beállítások menüpontban hozz létre egy profilt, és adj hozzá egy sablont az új Jellyseerr fiókokhoz.",
"title": "Jellyseerr",
"importExisting": "Meglévő fiókok importálása",
"importExistingDescription": "Ha engedélyezve van, a meglévő felhasználók elérhetőségi adatai és beállításai szinkronizálva lesznek a jfa-go rendszerből."
},
"userPage": {
"description": "A felhasználói oldal („Fiókom” néven látható) lehetővé teszi a felhasználók számára, hogy hozzáférjenek a fiókjukkal kapcsolatos információkhoz, például a kapcsolatfelvételi módokhoz és a fiók lejáratához. Megváltoztathatják jelszavukat, jelszó-visszaállítást kezdeményezhetnek, és összekapcsolhatják/módosíthatják a kapcsolatfelvételi módokat anélkül, hogy megkérdeznék Önt. Ezenkívül személyre szabott Markdown-üzenetek jeleníthetők meg a felhasználóknak a bejelentkezés előtt és után.",
"title": "Felhasználói oldal",
"customizeMessages": "Kattintson a beállításokban a „Felhasználói oldal” melletti szerkesztés gombra a későbbi módosításhoz.",
"requiredSettings": "A jfa-go-ba Jellyfinen keresztül történő bejelentkezést be kell állítani. Győződjön meg róla, hogy a „jelszó visszaállítása linken keresztül” lehetőség van kiválasztva később az önkiszolgáló jelszó-visszaállításhoz."
},
"proxy": {
"title": "Proxy",
"description": "A jfa-go minden kapcsolatot HTTP/SOCKS5 proxyn keresztül hozzon létre. A Jellyfinhez való csatlakozást ezen a proxyn keresztül fogja tesztelni.",
"protocol": "Protokoll",
"address": "Cím (Port-al együtt)"
}
}

View File

@@ -8,7 +8,7 @@
"back": "Kembali",
"optional": "Pilihan",
"serverType": "Tipe Server",
"disabled": "Dinonaktifkan",
"disabled": "Dihentikan",
"enabled": "Diaktifkan",
"port": "Port",
"message": "Pesan",
@@ -124,4 +124,4 @@
"emailMessage": "Pesan Email",
"emailMessageNotice": "Ditampilkan di bagian bawah email."
}
}
}

View File

@@ -20,17 +20,20 @@
"errorNotAdmin": "Użytkownik nie jest upoważniony do zarządzania serwerem.",
"errorUserDisabled": "Użytkownik może być wyłączony.",
"error404": "404, nie znaleziono URL.",
"errorConnectionRefused": "Brak dostępu."
"errorConnectionRefused": "Brak dostępu.",
"errorProxy": "Konfiguracja proxy jest nieprawidłowa.",
"error": "Błąd",
"errorUnknown": "Nieznany błąd, sprawdź logi."
},
"startPage": {
"welcome": "Witaj!",
"pressStart": "Musisz wykonać kilka czynności aby skonfigurować jfa-go. Wciśnij start aby kontynuować.",
"httpsNotice": "Upewnij się , że masz dostęp do strony przy użyciu HTTPS lub sieci LAN.",
"pressStart": "Musisz wykonać kilka czynności, aby skonfigurować jfa-go. Wciśnij start, aby kontynuować.",
"httpsNotice": "Upewnij się, że masz dostęp do strony przy użyciu HTTPS lub sieci LAN.",
"start": "Start"
},
"endPage": {
"finished": "Ukończono!",
"restartMessage": "Możesz skonfigurować boty Discord/Telegram/Matrix, dostosować wiadomości i nie tylko w Ustawieniach. Kliknij poniżej, aby ponownie uruchomić, a następnie odśwież stronę.",
"restartMessage": "Funkcje takie jak boty Discord/Telegram/Matrix, niestandardowe wiadomości Markdown i dostępna dla użytkownika strona „Moje konto” można znaleźć w Ustawieniach, więc koniecznie je przejrzyj. Kliknij poniżej, aby ponownie uruchomić, a następnie odśwież stronę.",
"refreshPage": "Odśwież"
},
"language": {
@@ -42,8 +45,8 @@
},
"general": {
"title": "Ogólne",
"listenAddress": "",
"urlBase": "",
"listenAddress": "Adres nasłuchiwania",
"urlBase": "Adres URL",
"urlBaseNotice": "Wymagane tylko jeśli używasz reverse proxy na subdomenie np. jellyf.in/accounts.",
"lightTheme": "Jasny",
"darkTheme": "Ciemny",
@@ -55,25 +58,26 @@
},
"updates": {
"title": "Aktualizacje",
"description": "",
"updateChannel": "",
"description": "Włącz, aby otrzymywać powiadomienia o dostępności nowych aktualizacji. jfa-go będzie sprawdzać {n} co 30 minut. Nie są zbierane żadne adresy IP ani dane osobowe.",
"updateChannel": "Kanał aktualizacji",
"stable": "Stabilny",
"unstable": "Niestabilne"
},
"login": {
"title": "Zaloguj",
"description": "",
"authorizeWithJellyfin": "",
"authorizeManual": "",
"title": "Zaloguj się",
"description": "Aby uzyskać dostęp do strony administratora, należy zalogować się za pomocą poniższej metody:",
"authorizeWithJellyfin": "Autoryzacja za pomocą Jellyfin/Emby: Dane logowania są współdzielone z Jellyfin, co pozwala na korzystanie z wielu użytkowników.",
"authorizeManual": "Nazwa użytkownika i hasło: Ręczne ustawienie nazwy użytkownika i hasła.",
"adminOnly": "Tylko administratorzy (zalecane)",
"allowAll": "Zezwój wszystkim użytkownikom na logowanie do Jellyfin",
"allowAllDescription": "",
"emailNotice": ""
"allowAllDescription": "Niezalecane, należy zezwolić poszczególnym użytkownikom na logowanie się po skonfigurowaniu.",
"emailNotice": "Twój adres e-mail może być używany do otrzymywania powiadomień.",
"authorizeManualUserPageNotice": "Użycie tej opcji spowoduje wyłączenie funkcji „Strona użytkownika”."
},
"jellyfinEmby": {
"title": "",
"description": "",
"embyNotice": "",
"title": "Jellyfin/Emby",
"description": "Konto administratora jest potrzebne, ponieważ API nie pozwala na tworzenie użytkowników przy użyciu klucza API. Powinieneś utworzyć osobne konto i zaznaczyć opcję „Zezwalaj temu użytkownikowi na zarządzanie serwerem”. Pozostałe opcje można wyłączyć. Po zakończeniu wprowadź dane logowania tutaj.",
"embyNotice": "Wsparcie Emby jest ograniczone i nie obsługuje resetowania hasła.",
"internal": "",
"external": "",
"replaceJellyfin": "Nazwa serwera",
@@ -146,5 +150,11 @@
"successMessageNotice": "",
"emailMessage": "",
"emailMessageNotice": ""
},
"proxy": {
"description": "Niech jfa-go wykonuje wszystkie połączenia przez proxy HTTP/SOCKS5. Połączenie z Jellyfin będzie testowane za jego pośrednictwem.",
"title": "Proxy",
"protocol": "Protokół",
"address": "Adres (wraz z portem)"
}
}
}

View File

@@ -134,4 +134,4 @@
"stable": "Stabil",
"unstable": "Ostabil"
}
}
}

167
lang/setup/th-TH.json Normal file
View File

@@ -0,0 +1,167 @@
{
"meta": {
"name": "ภาษาไทย (TH)"
},
"strings": {
"pageTitle": "ตั้งค่า - jfa-go",
"next": "ถัดไป",
"back": "กลับ",
"optional": "ไม่จำเป็น",
"serverType": "ประเภทเซิฟเวอร์",
"disabled": "ปิดใช้งาน",
"enabled": "เปิดใช้งาน",
"port": "Port",
"message": "ข้อความ",
"serverAddress": "ที่อยู่เซิฟเวอร์",
"emailSubject": "ผู้รับอีเมล",
"URL": "URL",
"apiKey": "API Key",
"error": "ข้อผิดผลาด",
"errorInvalidUserPass": "ชื่อผู้ใช้/รหัสผ่าน ไม่ถูกต้อง",
"errorNotAdmin": "ผู้ใช้นี้ไม่ได้รับอนุญาติในการจัดการเซิฟเวอร์",
"errorUserDisabled": "ผู้ใช้นี้อาจถูกปิดใช้งาน",
"error404": "404, โปรดตรวจสอบ URL ภายใน",
"errorConnectionRefused": "การเชื่อมต่อถูกปฏิเสธ",
"errorUnknown": "ข้อผิดพลาดที่ไม่รู้จัก, โปรดตรวจสอบบันทึกแอปฯ",
"errorProxy": "การตั้งค่า Proxy ไม่ถูกต้อง"
},
"startPage": {
"welcome": "ยินดีต้อนรับ!",
"pressStart": "คุณจะต้องทำอะไรเล็กน้อยเพื่อตั้งค่า jfa-go, กดเริ่มเพื่อดำเนินการต่อ",
"httpsNotice": "อย่าลืมตรวจสอบว่าเข้าถึงหน้านี้ผ่าน HTTPS หรือเครือข่ายส่วนตัว",
"start": "เริ่ม"
},
"endPage": {
"finished": "เสร็จสิ้น!",
"restartMessage": "ฟีเจอร์อย่างเช่น บอท Discord/Telegram/Matrix, ข้อความ Markdown แบบกำหนดเอง และหน้า \"บัญชีของฉัน\" ที่ผู้ใช้เข้าถึงได้ สามารถพบได้ที่การตั้งค่า, อย่าลืมส่องดูหล่ะ กดปุ่มข้างล่างเพื่อเริ่มระบบใหม่ หลังจากนั้นโหลดหน้านี้ใหม่อีกครั้ง",
"refreshPage": "โหลดใหม่"
},
"language": {
"title": "ภาษา",
"description": "แปลภาษาโดยชุมชนมีให้ใช้งานเกือบทุกส่วนใน jfa-go, คุณสามารถเลือกภาษาหลักได้ข้างล่าง, แต่ผู้ใช้สามารถเปลี่ยนเองได้ตามต้องการ ถ้าต้องการช่วยแปลภาษา ลงทะเบียนที่ {n} เพื่อเริ่มแปลภาษาได้เลย!",
"defaultAdminLang": "ภาษาหลักสำหรับผู้ดูแล",
"defaultFormLang": "ภาษาหลักสำหรับหน้าสร้างบัญชี",
"defaultEmailLang": "ภาษาหลักสำหรับอีเมล"
},
"general": {
"title": "ทั่วไป",
"listenAddress": "Listen Address",
"urlBase": "URL Base",
"urlBaseNotice": "จำเป็นเฉพาะเมื่อผ่าน Reverse Proxy บนโดเมนย่อย (ตัวอย่าง 'jellyf.in/accounts')",
"lightTheme": "สว่าง",
"darkTheme": "มืด",
"useHTTPS": "ใช้ HTTPS",
"httpsPort": "พอร์ต HTTPS",
"useHTTPSNotice": "จำเป็นเฉพาะถ้าเข้าใช้งานไม่ผ่าน Reverse Proxy",
"pathToCertificate": "ที่อยู่ใบรับรอง",
"pathToKeyFile": "ที่อยู่ไฟล์กุญแจ (รหัสใบรับรอง)"
},
"updates": {
"title": "อัปเดต",
"description": "เปิดเพื่อรับการแจ้งเตือนเมื่ออัปเดตพร้อมใช้งาน, jfa-go จะตรวจ {n} ทุก ๆ 30 นาที โดยไม่มีการเก็บ IP หรือข้อมูลที่ระบุถึงบุคคลได้",
"updateChannel": "ช่องอัปเดต",
"stable": "Stable",
"unstable": "Unstable"
},
"proxy": {
"title": "Proxy",
"description": "ให้ jfa-go เชื่อมต่อทุกอย่างผ่าน HTTP/SOCKS5 proxy, การเชื่อมต่อไปยัง Jellyfin จะถูกเชื่อมต่อผ่านทางนี้",
"protocol": "Protocol",
"address": "ที่อยู่ (รวมถึง Port)"
},
"login": {
"title": "เข้าสู่ระบบ",
"description": "เพื่อเข้าถึงหน้าผู้ดูแลระบบ คุณจำเป็นต้องเข้าสู่ระบบผ่านช่องทางด้านล่างนี้:",
"authorizeWithJellyfin": "ยืนยันตัวตนผ่าน Jellyfin/Emby: ข้อมูลเข้าสู่ระบบจะใช้ร่วมกับ Jellyfin, ซึ่งสามารถใช้ร่วมกับผู้ใช้อื่น ๆ ได้",
"authorizeManual": "ชื่อผู้ใช้ และ รหัสผ่าน: ตั้งค่าชื่อผู้ใช้ และ รหัสผ่านด้วยตนเอง",
"adminOnly": "ผู้ดูและระบบเท่านั้น (แนะนำ)",
"allowAll": "อนุญาติให้ผู้ใช้ Jellyfin ทั้งหมดเข้าสู่ระบบได้",
"allowAllDescription": "ไม่แนะนำ, คุณควรอนุญาติเป็นรายบุคคลให้เข้าสู่ระบบหลังจากตั้งค่า",
"authorizeManualUserPageNotice": "ใช้การตั้งค่านี้ จะปิดการใช้งานฟีเจอร์ \"หน้าผู้ใช้\"",
"emailNotice": "อีเมลของคุณสามารถใช้เพื่อรับการแจ้งเตือนได้"
},
"jellyfinEmby": {
"title": "Jellyfin/Emby",
"description": "บัญชีแอดมินจำเป็น เพราะ API ไม่อนุญาติให้สร้างผู้ใช้งานโดยไม่มี API key คุณควรสร้างชื่อผู้ใช้เฉพาะ และเลือก \"อนุญาติผู้ใช้นี้จัดการเซิฟเวอร์นี้ (Allow this user to manage the server)\" นอกเหนือจากนั้น สามารถปิดได้เลย หลังจากตั้งค่าเสร็จสิ้นแล้ว กรอกข้อมูลการเข้าสู่ระบบตรงนี้",
"embyNotice": "การรอบรับ Emby ยังถูกจำกัด และไม่รอบรับการตั้งค่ารหัสผ่านใหม่",
"internal": "ภายใน",
"external": "ภายนอก",
"replaceJellyfin": "ชื่อเซิฟเวอร์",
"replaceJellyfinNotice": "หากกรอก, ชื่อนี้จะถูกเปลี่ยนทุกอย่างที่เกี่ยวกับ 'Jellyfin' ในแอปฯ",
"addressExternalNotice": "ปล่อยว่างหากใช้ที่อยู่เดียวกัน",
"testConnection": "ทดสอบการเชื่อมต่อ"
},
"ombi": {
"title": "Ombi",
"description": "โดยเชื่อมต่อกับ Ombi, ทั้งบัญชี Jellyfin และ Ombi จะถูกสร้างขึ้นเมื่อผู้ใช้เข้าร่วมผ่าน jfa-go, หลังตั้งค่าเสร็จ ไปยังการตั้งค่าเพื่อตั้งค่าโปรไฟล์เริ่มต้นสำหรับผู้ใช้ Ombi ใหม่",
"apiKeyNotice": "หาได้จากแท็บแรกจากการตั้งค่าบน Ombi"
},
"messages": {
"title": "ข้อความ",
"description": "jfa-go สามารถส่งคำขอตั้งค่ารหัสผ่านใหม่ และข้อความต่าง ๆ ผ่าน Email, Discord, Telegram, และ/หรือ Matrix คุณสามารถตั้งค่าอีเมลข้างล่าง และอื่น ๆ สามารถตั้งค่าได้ผ่านการตั้งค่าในภายหลัง ขั้นตอนสามารถหาได้ใน {n} ถ้าคุณไม่ต้องการ, คุณสามารถปิดการตั้งค่าได้"
},
"email": {
"title": "อีเมล",
"description": "jfa-go สามารถส่งคำขอรหัสผ่าน รหัส (PIN) และการแจ้งเตือนอื่น ๆ ผ่านทางอีเมล คุณสามารถเชื่อมต่อไปยัง SMTP server, หรือใช้ {n} API",
"method": "วิธีการส่ง",
"useEmailAsUsername": "ใช้ที่อยู่อีเมลเป็นชื่อผู้ใช้",
"useEmailAsUsernameNotice": "หากเปิด, ผู้ใช้ใหม่จะเข้าสู่ระบบ Jellyfin/Emby ผ่านอีเมลของผู้เขาแทนชื่อผู้ใช้",
"fromAddress": "จากที่อยู่",
"senderName": "ชื่อผู้ส่ง",
"dateFormat": "รูปแบบวันที่",
"dateFormatNotice": "วันที่ตามรูปแบบ strftime, สำหรับข้อมูลเพิ่มเติม ไปที่ {n}",
"encryption": "การเข้ารหัส",
"mailgunApiURL": "ที่อยู่ API"
},
"notifications": {
"title": "การแจ้งเตือนผู้ดูแลระบบ",
"description": "ถ้าเปิดใช้งาน, คุณสามารถเลือก (ต่อคำเชิญ) เพื่อรับข้อความแจ้งเมื่อคำเชิญหมดอายุ หรือผู้ใช้ถูกสร้างขึ้น ถ้าคุณไม่ได้เลือกเข้าสู่ระบบผ่าน Jellyfin, อย่าลืมทิ้งอีเมลติดต่อของคุณด้วย หรือเพิ่มช่องทางอื่น ๆ ในภายหลัง"
},
"userPage": {
"title": "หน้าผู้ใช้งาน",
"description": "หน้าผู้ใช้ (แสดงเป็น \"บัญชีของฉัน\") เป็นหน้าที่ให้ผู้ใช้เข้าถึงข้อมูลเกี่ยวกับบัญชีของเขา เช่น ข้อมูลติดต่อหรือวันหมดอายุ ผู้ใช้สามารถเปลี่ยนรหัสผ่าน, เริ่มต้นการตั้งรหัสผ่านใหม่ หรือ ลิงค์/เปลี่ยนข้อมูลติดต่อได้โดยไม่ต้องถามคุณ เพิ่มเติม สามารถข้อความ Markdown แบบกำหนดเองให้ผู้ใช้ ก่อนหรือหลังเข้าสู่ระบบได้",
"customizeMessages": "กดปุ่มแก้ไขข้าง “บัญชีของฉัน” ในการตั้งค่าเพื่อแก้ไขทีหลัง",
"requiredSettings": "จำเป็นต้องตั้งค่าเข้าสู่ระบบ jfa-go ผ่าน Jellyfin และมั่นใจว่าได้เลือก \"ตั้งค่ารหัสผ่านใหม่ผ่านลิงค์\""
},
"welcomeEmails": {
"title": "ข้อความต้อนรับ",
"description": "ถ้าเปิดใช้งาน, ข้อความจะถูกส่งไปยังผู้ใช้ใหม่พร้อมลิงค์ Jellyfin/Emby และรวมไปถึงชื่อผู้ใช้ของเขา"
},
"inviteEmails": {
"title": "จดหมายคำเชิญ",
"description": "ถ้าเปิดใช้งาน, คุณสามารถส่งคำเชิญไปยังอีเมล, Discord หรือ Matrix ได้โดยตรง เพระาว่าคุณอาจจะใช้ผ่าน Reverse Proxy, คุณจำเป็นต้องกรอกลิงค์ที่สามารถเข้าถึงคำเชิญได้ พิมพ์ URL Base ของคุณต่อท้ายด้วย '/invite'"
},
"passwordResets": {
"title": "การตั้งรหัสผ่านใหม่",
"description": "เมื่อผู้ใช้พยายามตั้งรหัสผ่านใหม่, Jellyfin จะสร้างไฟล์ชื่อ 'passwordreset-*.json' ซึ่งมีรหัส (PIN) อยู่ jfa-go จะอ่านไฟล์และส่งรหัสไปยังผู้ใช้, ถ้าเปิดใช้งาน \"หน้าผู้ใช้\" ก็สามารถตั้งรหัสผ่านใหม่ผ่านทางนั้นได้, โดยใช้ชื่อผู้ใช้, อีเมล หรือช่องทางการติดต่อ",
"pathToJellyfin": "ที่อยู่โฟลเดอร์ไฟล์ตั้งค่า Jellyfin",
"pathToJellyfinNotice": "ถ้าคุณไม่รู้ตำแหน่งโฟล์เดอร์ว่าอยู่ที่ไหน ลองตั้งค่ารหัสผ่าน Jellyfin ใหม่, ป๊อปอัพ '<path to jellyfin>/passwordreset-*.json' จะแสดงขึ้นมา ข้อนี้ไม่จำเป็นหากต้องการให้เปลี่ยนรหัสผ่านด้วยตนเองผ่าน \"หน้าผู้ใช้\"",
"resetLinks": "ส่งลิงค์แทนรหัส (PIN)",
"resetLinksRequiredForUserPage": "จำเป็นสำหรับตั้งค่ารหัสใหม่ด้วยตัวเองผ่านหน้าผู้ใช้",
"resetLinksNotice": "ถ้าเปิดการใช้งานร่วมกับ Ombi, เปิดหัวข้อนี้เพื่อซิงค์รหัส Jellyfin กับ Ombi",
"resetLinksLanguage": "ภาษาหน้าตั้งค่าฯ หลัก",
"setPassword": "ตั้งค่ารหัสผ่านผ่านลิงค์",
"setPasswordNotice": "เปิดการตั้งค่านี้ ผู้ใช้ไม่จำเป็นต้องเปลี่ยนรหัสผ่านผ่านรหัส (PIN) หลังจากตั้งค่าใหม่, กฎการยื่นยันรหัสฯ จะถูกใช้"
},
"passwordValidation": {
"title": "การยืนยันรหัสผ่าน",
"description": "หากเปิดใช้งาน, ชุดกฎการตั้งค่ารหัสผ่านจะแสดงบนหน้าสร้างผู้ใช้งาน เช่น ความยาวรหัสผ่าน ตัวอักษรตัวเล็ก/ตัวใหญ่ เป็นต้น",
"length": "ความยาว",
"uppercase": "ตัวอักษรตัวใหญ่",
"lowercase": "ตัวอักษรตัวเล็ก",
"numbers": "ตัวเลข",
"special": "อักขระพิเศษ (%, *, เป็นต้น)"
},
"helpMessages": {
"title": "ข้อความช่วยเหลือ",
"description": "ข้อความเหล่านี้จะแสดงบนหน้าสร้างผู้ใช้และในรายละเอียดอื่น ๆ",
"contactMessage": "ข้อความติดต่อ",
"contactMessageNotice": "แสดงล่างหน้าทุกหน้ายกเว้นหน้าผู้ดูแลระบบ",
"helpMessage": "ข้อความช่วยเหลือ",
"helpMessageNotice": "แสดงบนหน้าสร้างบัญชี",
"successMessage": "ข้อความสำเร็จ",
"successMessageNotice": "แสดงเมื่อผู้ใช้สร้างบัญชีแล้ว",
"emailMessage": "ข้อความอีเมล",
"emailMessageNotice": "แสดงด้านล่างอีเมล"
}
}

180
lang/setup/tr-TR.json Normal file
View File

@@ -0,0 +1,180 @@
{
"meta": {
"name": "İngilizce (ABD)"
},
"strings": {
"pageTitle": "Kurulum - jfa-go",
"next": "İleri",
"back": "Geri",
"optional": "İsteğe Bağlı",
"serverType": "Sunucu Türü",
"disabled": "Devre Dışı",
"enabled": "Etkin",
"port": "Bağlantı Noktası",
"message": "Mesaj",
"serverAddress": "Sunucu Adresi",
"emailSubject": "E-posta Konusu",
"URL": "URL",
"apiKey": "API Anahtarı",
"error": "Hata",
"errorInvalidUserPass": "Geçersiz kullanıcı adı/şifre.",
"errorNotAdmin": "Kullanıcının sunucuyu yönetmesine izin verilmiyor.",
"errorUserDisabled": "Kullanıcı devre dışı bırakılmış olabilir.",
"error404": "404, dahili URL'yi kontrol edin.",
"errorConnectionRefused": "Bağlantı reddedildi.",
"errorUnknown": "Bilinmeyen hata, uygulama günlüklerini kontrol edin.",
"errorProxy": ""
},
"startPage": {
"welcome": "",
"pressStart": "",
"httpsNotice": "",
"start": ""
},
"endPage": {
"finished": "",
"moreFeatures": "",
"restartReload": "",
"ifFailedLoad": "",
"refreshPage": ""
},
"language": {
"title": "",
"description": "",
"defaultAdminLang": "",
"defaultFormLang": "",
"defaultEmailLang": ""
},
"general": {
"title": "",
"listenAddress": "",
"urlBase": "",
"urlBaseNotice": "",
"externalURL": "",
"externalURLNotice": "",
"lightTheme": "",
"darkTheme": "",
"useHTTPS": "",
"httpsPort": "",
"useHTTPSNotice": "",
"pathToCertificate": "",
"pathToKeyFile": ""
},
"updates": {
"title": "",
"description": "",
"updateChannel": "",
"stable": "",
"unstable": ""
},
"proxy": {
"title": "",
"description": "",
"protocol": "",
"address": ""
},
"login": {
"title": "",
"description": "",
"authorizeWithJellyfin": "",
"authorizeManual": "",
"adminOnly": "",
"allowAll": "",
"allowAllDescription": "",
"authorizeManualUserPageNotice": "",
"emailNotice": ""
},
"jellyfinEmby": {
"title": "",
"description": "",
"embyNotice": "",
"internal": "",
"external": "",
"replaceJellyfin": "",
"replaceJellyfinNotice": "",
"addressExternalNotice": "",
"testConnection": ""
},
"ombi": {
"title": "",
"description": "",
"apiKeyNotice": "",
"stabilityWarning": ""
},
"jellyseerr": {
"title": "",
"description": "",
"importExisting": "",
"importExistingDescription": ""
},
"messages": {
"title": "",
"description": ""
},
"email": {
"title": "",
"description": "",
"method": "",
"useEmailAsUsername": "",
"useEmailAsUsernameNotice": "",
"fromAddress": "",
"senderName": "",
"dateFormat": "",
"dateFormatNotice": "",
"encryption": "",
"mailgunApiURL": ""
},
"notifications": {
"title": "",
"description": ""
},
"userPage": {
"title": "",
"description": "",
"customizeMessages": "",
"requiredSettings": ""
},
"welcomeEmails": {
"title": "",
"description": ""
},
"inviteEmails": {
"title": "",
"description": ""
},
"passwordResets": {
"title": "",
"description": "",
"moreInfo": "",
"pathToJellyfin": "",
"pathToJellyfinNotice": "",
"resetLinks": "",
"resetLinksRequiredForUserPage": "",
"resetLinksNotice": "",
"resetLinksLanguage": "",
"setPassword": "",
"setPasswordNotice": ""
},
"passwordValidation": {
"title": "",
"description": "",
"length": "",
"uppercase": "",
"lowercase": "",
"numbers": "",
"special": ""
},
"helpMessages": {
"title": "",
"description": "",
"markdownMessageNotice": "",
"contactMessage": "",
"contactMessageNotice": "",
"helpMessage": "",
"helpMessageNotice": "",
"successMessage": "",
"successMessageNotice": "",
"emailMessage": "",
"emailMessageNotice": ""
}
}

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