Compare commits

..

283 Commits

Author SHA1 Message Date
Harvey Tindall
788952d0a6 stats: add unfinished query builder
not gonna go any further. This is an unnecessary feature which could
just be a wiki page (and it will).
2025-11-23 16:41:10 +00:00
Harvey Tindall
faf782458f stats: start of some graph stuff
probably won't continue, this feature feels really unnecessary.
2025-11-22 13:40:29 +00:00
Harvey Tindall
863472657b Merge branch 'main' into stats 2025-11-22 12:53:22 +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
d8cb4454b5 stats: beginnings
made a StatCard interface, a NumberCard abstract class, and
(Filtered)CountCard to extend it. Managed by StatsPanel, which has a
static method DefaultLayout(), returning a list of preconfigured default
cards (right now no. of users, invites and users created thru jfa-go). I
intend to allow the user to customize these, maybe. Currently uses hardcoded strings for
words, FIXME! Also ugly. The default layout currently shows in a new "Stats" tab.
Also FIXME: Display in a nice grid with the same stuff used in the
userpage, once we have cards of different sizes (graphs, maybe?)
2025-05-28 12:48:07 +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
Harvey Tindall
632393b88d setup: adjust card width
use a container to kinda fix the max-width. Tried this on admin but it
messed too much up to bother (the accounts table specifically
doesn't like it).
2025-05-14 21:35:08 +01:00
Harvey Tindall
d2da9048d7 setup: add note about changed url
just mentioned that you should check the URL before refreshing if you
changed host/port/subfolder/etc.
2025-05-14 21:16:52 +01:00
Harvey Tindall
f1b56268bb setup: fix url-based navigation
popstate messages from the browser don't have an event.state, and i
don't even know what the overridePopState stuff is trying to do. If
event.state is null, try window.location.hash, or the last part of the
URL path.
2025-05-14 21:15:55 +01:00
Harvey Tindall
acba411c3a build: fix constant re-build of css
tailwind command taking part-bundle and turning it into v3bundle didn't
do anything when no changes occurred, so v3bundle kept on being left
    untouched, and therefore with an old timestamp. part-bundle and
    v3bundle are both deleted before CSS is built, so the tailwind
    command always generates a new file.
2025-05-14 21:11:44 +01:00
Harvey Tindall
f26042a21e config: change base url-related text 2025-05-14 20:23:16 +01:00
Harvey Tindall
0967d471ee urlpaths: seemingly full functionality
various subpath combos seem to work, and trailing slashes from them are
trimmed (including for the empty admin path "/", which is now "" by
default).
2025-05-14 20:09:50 +01:00
Harvey Tindall
302c4c189c build: check and re-copy modified config-base 2025-05-14 19:42:50 +01:00
Harvey Tindall
c52ba2162e config: start adding path parameters
to change the urls of the admin page, the my account page and of
invites. Seems to work, but need to check all the code over and test.
2025-05-13 21:10:40 +01:00
Harvey Tindall
2d98c6cff4 settings: add email test note
just send an announcement to yourself.
2025-05-13 17:31:09 +01:00
Harvey Tindall
d710b9ad4d db: fix valuelogfilesize calc
for some reason I thought it was in kibibytes? no, its in bytes as it
    should be.
2025-05-13 16:58:26 +01:00
Harvey Tindall
5cc97eaf17 goreleaser: fix deprecations
this sucks
2025-05-13 15:32:59 +01:00
Harvey Tindall
dca83dcc8e db: reduce default vlog size to 256M 2025-05-13 15:24:26 +01:00
Harvey Tindall
3c0f3e90d8 Merge branch 'main' of github.com:hrfee/jfa-go 2025-05-13 15:11:00 +01:00
Harvey Tindall
d6f5c91d78 backups: add more info, keep 1 of each version opt
backup's filename format has changed, and includes the commit too now. a
Backup struct for going to/from the filename has been added, and the
option "keep 1 backup from each version" has been added, leaving the
most recent backup from each version always. All pre-this-commit backups
are considered the same "old" version.
2025-05-13 15:07:55 +01:00
Harvey Tindall
0c257b7342 Merge branch 'main' of github.com:hrfee/jfa-go 2025-05-13 14:04:01 +01:00
Harvey Tindall
c5f4098b5b db: add max vlog size setting 2025-05-13 14:01:41 +01:00
Harvey Tindall
0b9206012f Merge branch 'main' of github.com:hrfee/jfa-go 2025-03-15 14:54:54 +00:00
Harvey Tindall
41dff3d5bb user: fix welcome message sent value on NewUserFromAdmin
inverted since WelcomeNewUser returns a bool called "failed", rather
than one indicating success.
2025-03-15 14:54:23 +00:00
Harvey Tindall
8f3f1fcda8 Merge pull request #380 from hrfee/dependabot/npm_and_yarn/nanoid-3.3.8
build(deps): bump nanoid from 3.3.6 to 3.3.8
2025-03-15 14:51:41 +00:00
Harvey Tindall
93ae2ac6d8 Merge pull request #386 from hrfee/dependabot/npm_and_yarn/site/esbuild-0.25.0
build(deps): bump esbuild from 0.18.1 to 0.25.0 in /site
2025-03-15 14:51:31 +00:00
Harvey Tindall
6404fda04d Merge pull request #392 from hrfee/dependabot/go_modules/easyproxy/golang.org/x/net-0.36.0
build(deps): bump golang.org/x/net from 0.23.0 to 0.36.0 in /easyproxy
2025-03-15 14:51:20 +00:00
Harvey Tindall
3133996f33 Merge pull request #376 from Mavyre/main
fix: added discord and telegram linking verification on sign up page
2025-03-15 14:51:00 +00:00
dependabot[bot]
cef84fad10 build(deps): bump golang.org/x/net from 0.23.0 to 0.36.0 in /easyproxy
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.23.0 to 0.36.0.
- [Commits](https://github.com/golang/net/compare/v0.23.0...v0.36.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-13 00:31:49 +00:00
Harvey Tindall
58c2fa3dde scripts: fix typo from PR 388
was /usr/env/bin instead of /usr/bin/env.
2025-02-26 14:09:42 +00:00
Harvey Tindall
56ee54811d Merge pull request #388 from michaelBelsanti/shebangs
scripts: use more portable shebang
2025-02-24 15:45:56 +00:00
quasigod
c1a2fb2d4a scripts: use more portable shebang 2025-02-24 10:40:54 -05:00
dependabot[bot]
0b73e3ff2b build(deps): bump esbuild from 0.18.1 to 0.25.0 in /site
Bumps [esbuild](https://github.com/evanw/esbuild) from 0.18.1 to 0.25.0.
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2023.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.18.1...v0.25.0)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-11 05:27:21 +00:00
dependabot[bot]
0e9a7d0641 build(deps): bump nanoid from 3.3.6 to 3.3.8
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.6 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.6...3.3.8)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-13 02:15:06 +00:00
Bastien Vidé
dda363b344 fix: added discord and telegram linking verification on sign up page 2024-11-22 02:44:18 +01:00
Harvey Tindall
0ccc314833 go: update deps
including mediabrowser, to add new field in Configuration.
2024-11-05 23:49:20 +00:00
Harvey Tindall
da4470bc4f admin: store email on manual account creation
another mistake from the rewrite of account creation stuff. Should fix
issue #374, sorry for taking so long.
2024-11-05 22:54:12 +00:00
Harvey Tindall
11eb907ced html/css: fix overlap of top button row on some pages
row with language, light/dark, logout etc. was overlapping with content
on small screens on some pages, as I forgot to bring over changes made
to the admin page. Also improved some other CSS, and factored out the
language menu into html/lang-select.html.
2024-10-11 17:08:14 +01:00
Harvey Tindall
ea57d657fe form: fix contact details/profile application when using email
confirmation

my rewrite of account-creation stuff had a massive oversight of email
confirmation, the steps done after account creation (email and contact
method storage, referral stuff) were not done on the email confirmation
path. This has been factored out to PostNewUserFromIvnite, and the
ConfirmationKeys store now includes the newUserDTO and the list of
completeContactMethods.
2024-10-11 16:38:19 +01:00
Harvey Tindall
71922212d9 form: fix account creation with no profile
missing "profile != nil" check.
2024-10-11 11:46:40 +01:00
Harvey Tindall
bb41bc3844 fix disable/re-enable text/color on button on accounts
account-disabled-ness was/is derived from a class on the badge in the
account's row, which after a UI change was removed. Now depends on the
correct (and more likely to remain throughout changes) "hidden" class.

For #370.
2024-10-10 13:31:58 +01:00
Harvey Tindall
39f6d14163 admin: hide scrollbar on navbar on chrome
changed overflow-x-scroll to overflow-x-auto, so scrollbar only shows
when needed on chrome. Doesn't occur on firefox or webkit (epiphany), so
didn't notice until I saw others' screenshots.
2024-10-10 13:24:40 +01:00
Harvey Tindall
941367f77b config: MustSetValue all MustInts
so stuff like the previous commit's issue doesn't happen again.
2024-09-04 21:15:19 +01:00
Harvey Tindall
2f4e68969a auth: fix default retry count value
fixes #365, gopkg.in/ini/v1's MustInt(n) only returns `n` if the key's
value was not an integer (i.e. value = foobar). auth_retry_count is not
included in the config, it gets read as zero, and auth isn't attempted
at all.
2024-09-04 20:53:30 +01:00
Harvey Tindall
275d9188bf Merge branch 'main' of github.com:hrfee/jfa-go 2024-09-04 20:42:36 +01:00
Harvey Tindall
4b564d7f4a bump mediabrowser again, log authed account name 2024-09-04 20:03:09 +01:00
Harvey Tindall
c7995cdbba Merge pull request #366 from hrfee/dependabot/npm_and_yarn/site/ws-6.2.3
build(deps): bump ws from 6.2.2 to 6.2.3 in /site
2024-09-04 16:48:56 +00:00
Harvey Tindall
e623897fc1 bump mediabrowser again 2024-09-04 16:01:52 +01:00
Harvey Tindall
ee05f4fc19 bump mediabrowser again 2024-08-29 16:23:44 +01:00
dependabot[bot]
436a1db087 build(deps): bump ws from 6.2.2 to 6.2.3 in /site
Bumps [ws](https://github.com/websockets/ws) from 6.2.2 to 6.2.3.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/6.2.2...6.2.3)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-29 13:31:18 +00:00
Harvey Tindall
f4a7238110 bump mediabrowser version, always verbose
always enable jf.Verbose, because it really should be.
2024-08-29 14:30:16 +01:00
Harvey Tindall
65662c57bc setup: fix config application
recent change means app.ModifyConfig requires app.confiBase, which was
not read in from the YAML file in setup. It is now loaded before the "if
!firstRun" branch.
2024-08-29 13:30:03 +01:00
Harvey Tindall
3559e32c2f accounts: fix profile list for enabling referrals
when I moved the available profile list from GET(/invites) to
GET(/profiles/names), I forgot to remove the multiple places which
called the former and set the (now undefined) profiles value.
2024-08-29 12:14:43 +01:00
Harvey Tindall
a1612949bf accounts: css adjustments
there is now a border between rows, on light mode a dashed line, on dark
a dotted (looks almost solid). Row height has been changed slightly,
too. Label and edit icon are back to being first after the username, and
the edit button is on the left now. Contact dropdowns now overflow out
of the table properly.
2024-08-28 20:55:52 +01:00
Harvey Tindall
ae808c5109 accounts: standardise "text with edit button" component
The sort of thing used for the user's label and their email address is
now implemented by ui.ts/HiddenInputField, and used by the two.
2024-08-28 20:22:25 +01:00
Harvey Tindall
418f3c4566 build: fix crash css/js inlining
when re-doing makefile, I removed the part where CSS is written to
bundle.css, then later moved to v3bundle.css. To solve, crash.html now
just directly requests web/css/v3bundle.css (and web/js/crash.js,
removing the `mv` line in the makefile too).
2024-08-28 15:55:30 +01:00
Harvey Tindall
399ce3b044 activity: Just use window.URLBase
instead of figuring out the full URL. URLs are definitely the most
fragmented and annoying thing about this software.
2024-08-28 15:42:54 +01:00
Harvey Tindall
1aa100dc7d build: dont go build when INTERNAL=off and no changes
all build steps (apart from swagger, which generates go code) are stored
in BUILDDEPS, which is now a dependency of all. if INTERNAL=on,
BUILDDEPS is added to GO_TARGET, so the executable is rebuilt with new
content. Used the .DEFAULT_GOAL feature so I could move all: to the
bottom, where I think it belongs.
2024-08-28 15:24:43 +01:00
Harvey Tindall
6347495b5b auth: use unicode b64 encoding on browser
brought over unicodeB64Encode/Decode from my other filaments project.
Fixes #364.
2024-08-28 14:29:36 +01:00
Harvey Tindall
02f4ba6e8e ts: use pages modules in admin (kinda), change pseudo-links
pseudo-links are now just links, because i'm lazy and it's easier than
fixing an issue. They now take the form `/?invite=code` and
`/accounts/?user=userid`.

ts/modules.tabs.ts is now a wrapper for ts/modules/pages.ts.

Also, fixed no section appearing when visiting the settings tab.
2024-08-28 14:18:52 +01:00
Harvey Tindall
d2e5209832 ts: move "page" stuff to module
not the happiest with it, but it works alright. PageManager is
instantiated, you pass is Page{} objects, which have a (code)name, page
title, and url, and a show, hide, and shouldSkip function, each
returning a bool. The first two are self explanatory, the last tells you
if the page is disabled for some reason (like on setup some are disabled
    if messages are). You can then call load(<(code)name>), or
        prev/next(<name>).
2024-08-27 18:55:28 +01:00
Harvey Tindall
b5dea7755b userpage: add password reset direct link
for #363, adds /my/account/password/reset. Navigate to it to skip
    pressing the "forgot password?" button on the login screen. Works
    with the nice-ish onpopstate override thing I put in setup.ts a
    while ago. Maybe I should make it a module.
2024-08-27 14:56:24 +01:00
Harvey Tindall
848b532b3c ts/modal: dont close when not open!
closing an already closed modal messed it up. Function returns if the
modal has "block" or "animate-fade-in".
2024-08-27 14:55:04 +01:00
Harvey Tindall
a11ac1b1a3 Merge branch 'main' of github.com:hrfee/jfa-go 2024-08-26 20:01:45 +01:00
Harvey Tindall
73197df8d9 Merge pull request #359 from hrfee/dependabot/go_modules/easyproxy/golang.org/x/net-0.23.0
build(deps): bump golang.org/x/net from 0.15.0 to 0.23.0 in /easyproxy
2024-08-26 19:01:32 +00:00
Harvey Tindall
a492c06077 Merge pull request #362 from hrfee/dependabot/npm_and_yarn/word-wrap-1.2.5
build(deps): bump word-wrap from 1.2.3 to 1.2.5
2024-08-26 19:01:20 +00:00
Harvey Tindall
bc66f46d9e Merge pull request #360 from hrfee/dependabot/npm_and_yarn/multi-e3a4bbf809
build(deps): bump ws
2024-08-26 19:01:08 +00:00
Harvey Tindall
eefd350754 Merge pull request #361 from hrfee/dependabot/npm_and_yarn/site/word-wrap-1.2.5
build(deps): bump word-wrap from 1.2.3 to 1.2.5 in /site
2024-08-26 18:59:48 +00:00
dependabot[bot]
494b8b2399 build(deps): bump word-wrap from 1.2.3 to 1.2.5
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.5.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.5)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-26 18:58:58 +00:00
dependabot[bot]
e901ba6bb5 build(deps): bump word-wrap from 1.2.3 to 1.2.5 in /site
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.5.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.5)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-26 18:58:53 +00:00
dependabot[bot]
4455c15bca build(deps): bump ws
Bumps  and [ws](https://github.com/websockets/ws). These dependencies needed to be updated together.

Updates `ws` from 8.13.0 to 8.18.0
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.13.0...8.18.0)

Updates `ws` from 6.2.2 to 8.18.0
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.13.0...8.18.0)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: indirect
- dependency-name: ws
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-26 18:58:24 +00:00
dependabot[bot]
c2bdc67242 build(deps): bump golang.org/x/net from 0.15.0 to 0.23.0 in /easyproxy
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.15.0 to 0.23.0.
- [Commits](https://github.com/golang/net/compare/v0.15.0...v0.23.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-26 18:58:16 +00:00
Harvey Tindall
37545e1e36 Merge pull request #297 from hrfee/dependabot/npm_and_yarn/postcss-8.4.31
build(deps): bump postcss from 8.4.24 to 8.4.31
2024-08-26 18:57:51 +00:00
dependabot[bot]
19495be6e9 build(deps): bump postcss from 8.4.24 to 8.4.31
Bumps [postcss](https://github.com/postcss/postcss) from 8.4.24 to 8.4.31.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.4.24...8.4.31)

---
updated-dependencies:
- dependency-name: postcss
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-26 18:57:04 +00:00
Harvey Tindall
37a062e24d build: change deprecated goreleaser option 2024-08-26 17:47:40 +01:00
Harvey Tindall
a4c60c71ea update go.mods
tried to lower required version, most subpackages worked, however some
other dependency means the main package requires 1.22.
2024-08-26 17:26:04 +01:00
Harvey Tindall
9e9f46d97b build: remove compile_mjml script, no python!
if I had taken a second to actually read the documentation, i'd have
    realized the mjml command can process a bunch of files at once.
    On my machine, cuts time down from ~800ms to ~500ms.
While there are still some scripts using python, none are needed to
build the software anymore, so no python build deps!
2024-08-26 17:01:34 +01:00
Harvey Tindall
f063b970b4 config: migrate to new yaml format
config-base.yaml is almost identical to json version, except there's no "order" field, as
"sections" and "settings" fields are now lists themselves and so Go can
parse the correct order. As such, removed enumerate_config.py. Also,
rewrote scripts/generate_ini.py in Go as scripts/ini/. Config structure
in Go form is now in common/config.go, and is used by jfa-go and the ini
script. app.configBase is now untouched once read from config-base.yaml,
and instead copied to and patched in app.patchedConfig. Patching occurs
at program start and config modification, so GetConfig is now just a
couple of lines. Discord role patching still occurs in GetConfig, as the
available roles can change regularly. Also added new "Disabled" field to
sections, to avoid the nightmare of deleting from an array.
2024-08-26 15:43:28 +01:00
Harvey Tindall
711b817cff auth: add note for self about secure cookies 2024-08-24 15:25:08 +01:00
Harvey Tindall
fcdd4e4518 revert stringResponse change
should have been a separate commit with other changes.
2024-08-24 15:16:50 +01:00
Harvey Tindall
6c30a1ff40 form/admin: don't allow "+" in username/email
Jellyfin doesn't like this.
2024-08-24 15:04:18 +01:00
Harvey Tindall
a7aa3fd53e settings: de-dupe settings
all DOM elements now based off DOMSetting, which encompasses most
functionality. Extending classes (i forgot the terminology) pretty much just pass a
custom "input" element, "hider" element (the one to unfocus). DOMList
and DOMSelect remain slightly more complicated, but are much cleaner
now. Some CSS stuff has been adjusted too.
2024-08-24 13:09:22 +01:00
Harvey Tindall
32161139b2 settings: dependencies of dependencies
`settings-set-${section}-${name}` is now broadcast when a setting's
value is actually changed, while `settings-${section}-${name}` is
broadcast then or if the setting is hidden, i.e. by its parent (i.e.
some "enabled" bool). This allows chains of dependencies to be resolved
visually. When a setting is hidden, the value sent is "false", and when
a setting is shown again, the actual value of it is sent.
2024-08-23 18:50:50 +01:00
Harvey Tindall
7c808b56f7 api: adjust a couple of URIs
adjusted some things, likke changing /newUser to /user/invite.
2024-08-21 20:35:08 +01:00
Harvey Tindall
2057823b7a setup: flex-ify, light/dark, keep page position on reload
got rid of a bunch of m[l/r/x/y]-x tailwind classes and used more
flex-[row/col] gap-2's. UI should be more consistent in general, and
with the admin UI.

The page you were on is actually read from the URL on reload, however
does not keep settings (implemented just for ease of UI editing,
really).

`missing-colors.js` preprocessor script now applies dark prefixes for
<section>s, but like with cards, does not apply a default ~neutral to
those without, so that <section class=""> looks different to <section
class="~neutral">.

Light/dark selector added to setup too, and the actual mode given to the
browser through CSS `color-scheme` is correct, meaning things like textareas, checkboxes and
controls are now colored according to the theme.
2024-08-21 18:31:54 +01:00
Harvey Tindall
e5f79c60ae webhooks: add "user created" webhook
Webhooks send a POST to an admin-supplied URL when something happens,
with relevant information sent in JSON. One has been added for creating
users in Settings > Webhooks > User Created.

Lazily, the portion of GetUsers which generates a respUser has been
factored out, and is called to send the JSON payload.

A stripped-down common.Req method has been added, which is used by the
barebones WebhookSender struct.
2024-08-20 21:45:31 +01:00
Harvey Tindall
8307d3da90 proxy: use for updater
don't know how I missed before.
2024-08-20 20:38:18 +01:00
Harvey Tindall
6bad293f74 config: add support for "list" type
"list" is a list of strings, represented in the .ini as repeated entries
for a field, e.g.
url = myurl1
url = myurl2
Shown in the UI as multiple inputs with delete buttons.
2024-08-20 20:19:32 +01:00
Harvey Tindall
b2771e6cc5 auth: source cookie hostname from jfa_url
instead of just applying the cookie to the hostname you accessed jfa-go
on, it is applied to the one you set in jfa-go. The result is you'll
have to login twice if you access on localhost:8056 instead
of accounts.jellyf.in.
2024-08-13 20:39:06 +01:00
Harvey Tindall
e71d492495 config: migrate "url_base" dupes to "jfa_url"
URL Base now refers to JUST the subfolder portion, i.e. `/accounts` if
you access jfa-go at `http://jellyf.in/accounts`. General > "jfa_url"/"External
jfa-go URL" now refers to the WHOLE URL you access jfa-go at, i.e.
`http://jellyf.in/accounts`. The settings in "invite emails" and
"password resets" have been removed, and a value chosen from the two
applied to "jfa_url". Migration also makes a config backup. Adds a
"deprecated" flag to config-base, which just tells the UI to not show
it (for now). Also added some warnings related to the URL base /
External URL.
2024-08-12 18:53:46 +01:00
Harvey Tindall
0e7245e6b9 update: distinguish E2EE builds
done in the same way as TrayIcon
2024-08-11 20:00:55 +01:00
Harvey Tindall
59e9d457c2 README: Add e2ee explanation, config note
next to TrayIcon one. New note in config tells you why E2EE might be
missing.
2024-08-11 19:03:05 +01:00
Harvey Tindall
48be756e48 build: e2ee as separate build
Forgot that E2EE adds the libolm dependency, which is fine for the
package manager versions (where it is now marked a a dep) and docker, but not the
best for binary distributions. As a result, Linux versions with and
without E2EE are now distributed, the former now including "MatrixE2EE"
in its filename.
2024-08-11 18:53:00 +01:00
Harvey Tindall
ab3989f233 admin: add build tags to "About"
build tags like e2ee and external are now included in the about page.
2024-08-11 17:55:12 +01:00
Harvey Tindall
d2c7bf06f7 build: correct updater on docker 2024-08-11 17:39:58 +01:00
Harvey Tindall
3f59312dfc build: copy data into correct location in dockerfile 2024-08-11 17:27:30 +01:00
Harvey Tindall
d62add0195 build: fix nfpm packaging for e2ee 2024-08-11 17:23:38 +01:00
Harvey Tindall
fd32b73132 build: attempt to fix dockerfile 2024-08-11 17:06:06 +01:00
Harvey Tindall
2d7f44eeec build: use goreleaser within dockerfile
jfa-go-build-docker has also been updated with libolm deps for all
arches, and version.sh now tells goreleaser to do internal or external.
2024-08-11 16:28:21 +01:00
Harvey Tindall
cf5ec3b319 build: fix dockerfile for E2EE 2024-08-10 21:43:16 +01:00
Harvey Tindall
2237286656 build: enable E2EE by default, partially in CI
Makefile enables E2EE by default. Due to the CGO and hence cross
compilers required, only linux amd64/arm64/armhf and windows amd64 is being built with the
feature included. Uses a new jfa-go-build-docker with
arm-linux-gnueabihf-gcc.
2024-08-10 21:11:11 +01:00
Harvey Tindall
3a0f61e324 config: add wiki links
main wiki link included with "about" and "user profiles". Sections with
a relevant page have a linked button next to their title when clicked.
Behaviour added by the "wiki_link" field in the "meta" section of a
config "section".
2024-08-10 20:19:38 +01:00
Harvey Tindall
69569e556a matrix: working E2EE, on by default
mautrix-go now include a cryptohelper package, which solves all my
issues and just works. the setting is now on by default, however
packages are not yet built with it.
2024-08-10 19:31:54 +01:00
Harvey Tindall
86c7551ff8 proxy: use wherever http.Client is, update mautrix
Added a new common.ConfigurableTransport interface which mediabrowser,
ombi, jellyseer, discord, telegram and matrix (i.e.
ThirdPartService/ContactMethodLinker) now all implement. proxies are
bound to them in main.go, Email is still a special case (but from the
previous commit, mailgun does use the proxy).

mautrix/go has been updated, and context.TODO()s stuck everywhere since
I still don't really comprehend why I should use them (FIXME literally).
2024-08-09 21:42:16 +01:00
Harvey Tindall
a52dd26ec6 email: use proxy for mailgun, too
adds proxy transport to the http.Client in the mailgun api client.
2024-08-09 21:11:18 +01:00
Harvey Tindall
6308db495a build: push redoc after build 2024-08-06 21:06:06 +01:00
Harvey Tindall
ef7132bf3d build: de-dupe goreleaser
I don't really know why I duplicated the build process in
.goreleaser.yaml, when I could have just called the Makefile.
2024-08-06 20:47:54 +01:00
Harvey Tindall
790accc007 build: Update dockerfile for new Makefile
added a "precompile" step which takes the place of the "configuration
email swager..." stuff.
2024-08-06 20:34:28 +01:00
Harvey Tindall
2310130e6b api clients: return data, error, no status
jellyseerr already did this, but it's been standardised a little more.

Mediabrowser uses it's own genericErr function and error types due to
being a separate package, while jellyseerr and ombi now share errors
defined in common/.
2024-08-06 14:48:31 +01:00
Harvey Tindall
284312713c web: css adjustments
tailwind classes define what "page-container" previously did, with some
changes. Logout button moved to top of screen.
2024-08-05 21:18:32 +01:00
Harvey Tindall
b40211a6e0 settings: add loader, improve css a bit
Loader appears on about/user profiles area. These two are now shown next
to each other.
on small screens, the sidebar list of sections is displayed without a
card around it, and the top left/right corner buttons on all pages are
correctly aligned with the content.
2024-08-05 20:23:10 +01:00
Harvey Tindall
ce6a5772b1 build: rewrite Makefile properly (incremental builds)
Probably still a little rough around the edges, but supports the actual
use case of GMake, which I believe are called incremental builds. Builds
will only occur when the code is changed, and only the necessary bits
are re-compiled.
2024-08-05 18:46:21 +01:00
Harvey Tindall
86c37fb423 expiry: add option to delete after n days
Much like activities, you can have an expired account disabled, then
deleted after n days (unless the admin intervenes and re-enables the
account).

For #341.
2024-08-05 14:09:02 +01:00
Harvey Tindall
3c3297c8e7 userpage: bump mediabrowser version
so that usernames are compared un-case-sensitively in UserByName. For #354.
2024-08-05 12:37:42 +01:00
Harvey Tindall
a9dc601751 web: fix intermittent "coudln't connect" on page load
was caused by language selector loader, simply added a
"noConnectionError" param to the _get/_post... methods which is enabled
for it.
2024-08-04 21:57:17 +01:00
Harvey Tindall
51e3c37694 build: DBEUG=on disables minification 2024-08-04 21:57:07 +01:00
Harvey Tindall
62e27c394d build: include latest banner automatically
now copied from images/ into static/ on build.
2024-08-04 21:04:20 +01:00
Harvey Tindall
8f3c723b07 systemd: get executable path properly
was just evaluating os.Args[0], which incorrectly points to your current
directory if jfa-go was in your PATH (i.e. you ran `jfa-go` not
`/usr/bin/jfa-go`). Uses Go's os.Executable() now. Fixes #352.
2024-08-04 20:53:09 +01:00
Harvey Tindall
3c28537498 lang: rename ckb.json
hopefully correct.
2024-08-04 19:07:39 +01:00
Harvey Tindall
b0e94a4ef6 merge lang 2024-08-04 19:03:53 +01:00
Harvey Tindall
baeb89b694 setup: add jellyseer, reference wiki for PWR
add jellyseerr section along with ombi, and add a warning about ombi.
A link to the PWR wiki page is given to explain the different methods.
2024-08-04 19:03:00 +01:00
Harvey Tindall
016263894f lang: report section error comes from
doesn't directly report which file caused the error, but this is close
enough.
2024-08-04 18:09:25 +01:00
Muhammad Bayiz
5fe532fb78 translation from Weblate (Kurdish (Central))
Currently translated at 27.9% (19 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/ckb/
2024-08-04 18:23:27 +02:00
Muhammad Bayiz
598859ae31 add translation from Weblate (Kurdish (Central)) 2024-08-04 18:23:27 +02:00
FiSTWHO
d8dcb84870 translation from Weblate (German)
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/de/
2024-08-04 18:23:27 +02:00
Harvey Tindall
28ca02272c discord: also check disabled users in housekeeping daemon
externally disabled users will also be checked and de-roled by the discord daemon.
2024-08-04 15:48:01 +01:00
Harvey Tindall
448955c915 discord: ensure discord housekeeping enable for previous feature
was only enabled when "require unique" was set, now also is if the new
"disable_enable_role" is set.
2024-08-04 15:40:50 +01:00
Harvey Tindall
ffd46ff190 discord: option to add/remove role on enable/disable/deletion
in Settings > Discord, shown when a role is selected in "Apply Role".
The discord housekeeping daemon should pick up users deleted outide of
jfa-go too, so users who delete their own accounts should have their
roles removed (periodically).
2024-08-04 15:37:01 +01:00
Harvey Tindall
44311162a6 users: consolidate methods for disable/enable and deleting users
now both in users.go and shared between the admin UI and daemon. Will be
used for discord role auto-add/deletion.
2024-08-04 15:14:51 +01:00
Harvey Tindall
f289680d98 users: remove notes 2024-08-04 13:57:42 +01:00
Harvey Tindall
280c6e4f16 users: attach Jellyfin ID to contactMethodUser 2024-08-04 13:40:16 +01:00
Harvey Tindall
54e4a51a7f users: huge cleanup/dedupe, interface-based third-party services
shared "newUser" method is now "NewUserPostVerification", and is shared
between all routes which create a jellyfin account. The new
"NewUserFromInvite", "NewUserFromAdmin" and "NewUserFromConfirmationKey"
are smaller as a result. Discord, Telegram, and Matrix now implement the
"ContactMethodLinker" and "ContactMethodUser" interfaces, meaning code
is shared a lot between them in the NewUser methods, and the specifics
are now in their own files. Ombi/Jellyseerr similarly implement a
simpler interface "ThirdPartyService", which simply has ImportUser and
AddContactMethod routes. Note these new interface methods are only used
for user creation as of yet, but could likely be used in other places.
2024-08-03 21:27:46 +01:00
Harvey Tindall
711394232b logmessages: all log strings in one file
EXCEPT: migrations.go, log strings there aren't gonna be repeated
anywhere else, are very specific, and will probably change a lot.
2024-08-01 20:17:05 +01:00
Harvey Tindall
15a317f84f discord: remove old message-based commands
removed !-prefixed, non-native commands, since "slash" commands have
been available for a long while now

separating this from the rest of the logmessages-ing because its a
significant change.
2024-08-01 20:15:54 +01:00
Harvey Tindall
f348262f88 logmessages: finish up to api-users (alphabetically), refactor
.go files done in alphabetical order. Some refactoring done to
checkInvite(s) so they share most code. Also removed some useless debug
lines.
2024-08-01 13:59:24 +01:00
Harvey Tindall
e9b8d970d1 logging: start consolidating log lines
log messages are very fragmented and are often repeated many times throughout the software with small differences.

Messages will be listed in logmessages/, which are simply strings with
formatting directives if necessary. So far, only main.go has been
completed.
2024-07-31 17:45:05 +01:00
Harvey Tindall
fb5d3c4165 remove drone.yml 2024-07-31 16:46:02 +01:00
Harvey Tindall
c442ff5f98 rename daemon files 2024-07-31 16:44:59 +01:00
Harvey Tindall
2d066ea7cd README: update supported version, mention jellyseerr, update ci link,
embed font in banner

support noted for 10.9.8, and jellyseerr mentioned alongside ombi. CI
badge and link updated for Woodpecker.

Font in banner has been converted to path, so should render correctly on
web.
2024-07-31 16:21:35 +01:00
Harvey Tindall
efa113ab5f Merge Jellyseerr Support
Jellyseerr integration (similar to Ombi, but better)
2024-07-31 13:58:37 +00:00
214 changed files with 21045 additions and 20829 deletions

View File

@@ -1,172 +0,0 @@
---
name: jfa-go
kind: pipeline
type: docker
steps:
- name: fetch
image: docker:git
commands:
- git fetch --tags
- name: release
image: hrfee/jfa-go-build-docker:latest
volumes:
- name: ssh_key
path: /id_rsa
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
GITHUB_TOKEN:
from_secret: github_token
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
commands:
- curl -sL https://git.io/goreleaser > ../goreleaser
- chmod +x ../goreleaser
- ./scripts/version.sh ../goreleaser
- wget https://builds.hrfee.pw/upload.py -P ../
- pip3 install requests
- bash -c 'sftp -P 2022 -i /id_rsa -o StrictHostKeyChecking=no root@161.97.102.153:/repo/incoming <<< $"put dist/*.deb"'
- bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "repo-process-deb trusty"'
bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "rm /repo/incoming/*.deb"'
- bash -c 'python3 ../upload.py https://builds.hrfee.pw hrfee jfa-go --tag internal=true'
volumes:
- name: ssh_key
host:
path: /root/.ssh/id_rsa_packaging
trigger:
event:
- tag
---
name: docker-buildx
kind: pipeline
type: docker
steps:
- name: build-deploy
image: appleboy/drone-ssh
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
settings:
host:
from_secret: ssh2_host
username:
from_secret: ssh2_username
port:
from_secret: ssh2_port
envs:
- buildrone_key
key:
from_secret: ssh2_key
command_timeout: 50m
script:
- /mnt/buildx/jfa-go/build.sh stable
- wget https://builds.hrfee.pw/upload.py -O /mnt/buildx/jfa-go/jfa-go/upload.py
- pip3 install requests
- bash -c 'cd /mnt/buildx/jfa-go/jfa-go && python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-stable=true'
- rm -f /mnt/buildx/jfa-go/jfa-go/upload.py
trigger:
event:
- tag
---
name: jfa-go-git
kind: pipeline
type: docker
steps:
- name: build
image: hrfee/jfa-go-build-docker:latest
volumes:
- name: ssh_key
path: /id_rsa
- name: ssh_key2
path: /id_rsa2
commands:
- curl -sL https://git.io/goreleaser > goreleaser
- chmod +x goreleaser
- ./scripts/version.sh ./goreleaser --snapshot --skip=publish --clean
- wget https://builds.hrfee.pw/upload.py
- pip3 install requests
- bash -c 'sftp -i /id_rsa2 -o StrictHostKeyChecking=no root@161.97.102.153:/mnt/redoc <<< $"put docs/swagger.json jfa-go.json"'
- bash -c 'sftp -P 2022 -i /id_rsa -o StrictHostKeyChecking=no root@161.97.102.153:/repo/incoming <<< $"put dist/*.deb"'
# - bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "reprepro -Vb /repo remove trusty-unstable jfa-go"'
# - bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "reprepro -Vb /repo remove trusty-unstable jfa-go-tray"'
- bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "repo-process-deb trusty"'
bash -c 'ssh -i /id_rsa root@161.97.102.153 -p 2022 "rm /repo/incoming/*.deb"'
- bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --upload ./dist/*.zip ./dist/*.rpm ./dist/*.apk --tag internal-git=true'
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
JFA_GO_SNAPSHOT: y
volumes:
- name: ssh_key
host:
path: /root/.ssh/id_rsa_packaging
- name: ssh_key2
host:
path: /root/.ssh/docker-build
trigger:
branch:
- main
- go1.16
event:
exclude:
- pull_request
---
name: docker-buildx-unstable
kind: pipeline
type: docker
steps:
- name: build-deploy
image: appleboy/drone-ssh
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
settings:
host:
from_secret: ssh2_host
username:
from_secret: ssh2_username
port:
from_secret: ssh2_port
envs:
- buildrone_key
key:
from_secret: ssh2_key
command_timeout: 50m
script:
- /mnt/buildx/jfa-go/build.sh
- wget https://builds.hrfee.pw/upload.py -O /mnt/buildx/jfa-go/jfa-go/upload.py
- pip3 install requests
- bash -c 'cd /mnt/buildx/jfa-go/jfa-go && python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-unstable=true'
- rm -f /mnt/buildx/jfa-go/jfa-go/upload.py
trigger:
branch:
- main
event:
exclude:
- pull_request
---
name: jfa-go-pr
kind: pipeline
type: docker
steps:
- name: build
image: hrfee/jfa-go-build-docker:latest
commands:
- curl -sL https://git.io/goreleaser > goreleaser
- chmod +x goreleaser
- ./scripts/version.sh ./goreleaser --snapshot --skip=publish --clean
trigger:
event:
include:
- pull_request

2
.gitignore vendored
View File

@@ -25,3 +25,5 @@ scripts/langmover/lang
scripts/langmover/lang2
scripts/langmover/out
tinyproxy.conf
static/banner.svg
start.sh

View File

@@ -8,52 +8,15 @@ release:
name_template: "v{{.Version}}"
before:
hooks:
- go mod download
- rm -rf data/web
- mkdir -p data/web/css
- bash -c 'cp -r static/* data/web/'
- npm install
- npm install esbuild
- cp node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 data/web/css/
- cp -r html data/
- node scripts/missing-colors.js html data/html
- cp -r lang data/
- cp LICENSE data/
- cp jfa-go.service data/
- python3 scripts/enumerate_config.py -i config/config-base.json -o data/config-base.json
- python3 scripts/generate_ini.py -i config/config-base.json -o data/config-default.ini
- python3 scripts/compile_mjml.py -o data/
- rm -rf tempts
- cp -r ts tempts
- scripts/dark-variant.sh tempts
- scripts/dark-variant.sh tempts/modules
- mkdir -p data/web/js
- npx esbuild --target=es6 --bundle tempts/admin.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/admin.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/user.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/user.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/pwr.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/pwr.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/pwr-pin.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/pwr-pin.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/form.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/form.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/setup.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/setup.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/crash.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/crash.js {{.Env.JFA_GO_MINIFY}}
- bash -c "{{.Env.JFA_GO_COPYTS}}"
- rm -r tempts
- npx esbuild --bundle css/base.css --outfile=./data/web/css/bundle.css --external:remixicon.css --external:../fonts/hanken* --minify
- cp html/crash.html data/
- npx tailwindcss -i data/web/css/bundle.css -o data/bundle.css --content "html/crash.html"
- node scripts/inline.js root data data/crash.html data/crash.html
- rm data/bundle.css
- npx tailwindcss -i data/web/css/bundle.css -o data/web/css/bundle.css
- mv data/crash.html data/html/
- go install github.com/swaggo/swag/cmd/swag@latest
- swag init -g main.go
- mv data/web/css/bundle.css data/web/css/{{.Env.JFA_GO_CSS_VERSION}}bundle.css
- npm i
- make precompile
builds:
- id: notray
dir: ./
env:
- CGO_ENABLED=0
flags:
- -tags={{ .Env.JFA_GO_TAG }}
ldflags:
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater={{.Env.JFA_GO_UPDATER}} {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
goos:
- linux
- darwin
@@ -62,6 +25,24 @@ builds:
- arm
- arm64
- amd64
- id: notray-e2ee
dir: ./
env:
- CGO_ENABLED=1
- CC={{ if eq .Arch "amd64" }}x86_64{{ else if eq .Arch "arm64" }}aarch64{{ else }}{{ .Arch }}{{ end }}-linux-gnu{{ if eq .Arch "arm" }}eabihf{{ end }}-gcc
- CXX={{ if eq .Arch "amd64" }}x86_64{{ else if eq .Arch "arm64" }}aarch64{{ else }}{{ .Arch }}{{ end }}-linux-gnu{{ if eq .Arch "arm" }}eabihf{{ end }}-gcc
- PKG_CONFIG_PATH=/usr/lib/{{ if eq .Arch "amd64" }}x86_64{{ else if eq .Arch "arm64" }}aarch64{{ else }}{{ .Arch }}{{ end }}-linux-gnu{{ if eq .Arch "arm" }}eabihf{{ end }}/pkgconfig:$PKG_CONFIG_PATH
- GOARM={{ if eq .Arch "arm" }}7{{ end }}
flags:
- -tags=e2ee,{{ .Env.JFA_GO_TAG }}
ldflags:
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater={{.Env.JFA_GO_UPDATER}} {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
goos:
- linux
goarch:
- arm
- arm64
- amd64
- id: windows-tray
dir: ./
env:
@@ -69,9 +50,9 @@ builds:
- CC=x86_64-w64-mingw32-gcc
- CXX=x86_64-w64-mingw32-g++
flags:
- -tags=tray
- -tags=tray,{{ .Env.JFA_GO_TAG }}
ldflags:
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}" -H=windowsgui
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater={{.Env.JFA_GO_UPDATER}} {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}" -H=windowsgui
goos:
- windows
goarch:
@@ -82,20 +63,20 @@ builds:
- CGO_ENABLED=1
- CC=x86_64-linux-gnu-gcc
- CXX=x86_64-linux-gnu-gcc
- PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH
- PKG_CONFIG_PATH=/usr/lib/{{ if eq .Arch "amd64" }}x86_64{{ else if eq .Arch "arm64" }}aarch64{{ else }}{{ .Arch }}{{ end }}-linux-gnu{{ if eq .Arch "arm" }}eabihf{{ end }}/pkgconfig:$PKG_CONFIG_PATH
flags:
- -tags=tray
- -tags=tray,e2ee,{{ .Env.JFA_GO_TAG }}
ldflags:
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater={{.Env.JFA_GO_UPDATER}} {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
goos:
- linux
goarch:
- amd64
archives:
- id: windows-tray
builds:
ids:
- windows-tray
format: zip
formats: [ "zip" ]
name_template: >-
{{ .ProjectName }}_{{ .Version }}_TrayIcon_
{{- if eq .Os "darwin" }}macOS
@@ -103,9 +84,9 @@ archives:
{{- if eq .Arch "amd64" }}x86_64
{{- else }}{{ .Arch }}{{ end }}
- id: linux-tray
builds:
ids:
- linux-tray
format: zip
formats: [ "zip" ]
name_template: >-
{{ .ProjectName }}_{{ .Version }}_TrayIcon_
{{- if eq .Os "darwin" }}macOS
@@ -113,19 +94,29 @@ archives:
{{- if eq .Arch "amd64" }}x86_64
{{- else }}{{ .Arch }}{{ end }}
- id: notray
builds:
ids:
- notray
format: zip
formats: [ "zip" ]
name_template: >-
{{ .ProjectName }}_{{ .Version }}_
{{- if eq .Os "darwin" }}macOS
{{- else }}{{- title .Os }}{{ end }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else }}{{ .Arch }}{{ end }}
- id: notray-e2ee
ids:
- notray-e2ee
formats: [ "zip" ]
name_template: >-
{{ .ProjectName }}_{{ .Version }}_MatrixE2EE_
{{- if eq .Os "darwin" }}macOS
{{- else }}{{- title .Os }}{{ end }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else }}{{ .Arch }}{{ end }}
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "0.0.0-{{ .Env.JFA_GO_NFPM_EPOCH }}"
version_template: "0.0.0-{{ .Env.JFA_GO_NFPM_EPOCH }}"
changelog:
sort: asc
filters:
@@ -142,8 +133,8 @@ nfpms:
license: MIT
vendor: hrfee.dev
version_metadata: git
builds:
- notray
ids:
- notray-e2ee
contents:
- src: ./LICENSE
dst: /usr/share/licenses/jfa-go
@@ -151,6 +142,16 @@ nfpms:
- apk
- deb
- rpm
overrides:
deb:
dependencies:
- libolm-dev
rpm:
dependencies:
- libolm
apk:
dependencies:
- olm
- id: tray
file_name_template: '{{ .ProjectName }}{{ if .IsSnapshot }}-git{{ end }}_TrayIcon_{{ .Arch }}_{{ if .IsSnapshot }}{{ .ShortCommit }}{{ else }}v{{ .Version }}{{ end }}'
package_name: jfa-go-tray
@@ -160,7 +161,7 @@ nfpms:
license: MIT
vendor: hrfee.dev
version_metadata: git
builds:
ids:
- linux-tray
contents:
- src: ./LICENSE
@@ -177,9 +178,12 @@ nfpms:
- jfa-go
dependencies:
- libayatana-appindicator
- libolm-dev
rpm:
dependencies:
- libappindicator-gtk3
- libolm
apk:
dependencies:
- libayatana-appindicator
- olm

View File

@@ -12,14 +12,24 @@ clone:
depth: 0
steps:
- name: redoc
- name: precompile
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"'
- 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:
@@ -30,6 +40,14 @@ steps:
- curl -sfL https://goreleaser.com/static/run > goreleaser
- chmod +x goreleaser
- ./scripts/version.sh ./goreleaser --snapshot --skip=publish --clean
- 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:

View File

@@ -5,7 +5,6 @@ when:
steps:
- name: build
image: docker.io/woodpeckerci/plugin-docker-buildx
secrets: [ BUILT_BY ]
settings:
username:
from_secret: DOCKER_USERNAME
@@ -16,7 +15,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

@@ -5,7 +5,6 @@ when:
steps:
- name: build
image: docker.io/woodpeckerci/plugin-docker-buildx
secrets: [ BUILT_BY ]
settings:
username:
from_secret: DOCKER_USERNAME
@@ -16,7 +15,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

@@ -1,34 +1,26 @@
# Use this instead if hrfee/jfa-go-build-docker doesn't support your architecture
# FROM --platform=$BUILDPLATFORM golang:latest AS support
FROM --platform=$BUILDPLATFORM hrfee/jfa-go-build-docker AS support
FROM --platform=$BUILDPLATFORM docker.io/hrfee/jfa-go-build-docker:latest AS support
# FROM --platform=$BUILDPLATFORM jfa-go-bd AS support
ARG BUILT_BY
ENV JFA_GO_BUILT_BY=$BUILT_BY
COPY . /opt/build
# Uncomment this if hrfee/jfa-go-build-docker doesn't support your architecture
# RUN apt-get update -y \
# && apt-get install build-essential python3-pip -y \
# && (curl -sL https://deb.nodesource.com/setup_current.x | bash -) \
# && apt-get install nodejs
RUN (cd /opt/build; make configuration npm email typescript variants-html bundle-css inline-css swagger copy INTERNAL=off GOESBUILD=on) \
&& sed -i 's#id="password_resets-watch_directory" placeholder="/config/jellyfin"#id="password_resets-watch_directory" value="/jf" disabled#g' /opt/build/build/data/html/setup.html
# RUN curl -sfL https://goreleaser.com/static/run > /goreleaser && chmod +x /goreleaser
RUN cd /opt/build; INTERNAL=off UPDATER=docker ./scripts/version.sh /goreleaser build --snapshot --skip=validate --clean --id notray-e2ee
RUN mv /opt/build/dist/*_linux_arm_6 /opt/build/dist/placeholder_linux_arm
RUN sed -i 's#id="password_resets-watch_directory" placeholder="/config/jellyfin"#id="password_resets-watch_directory" value="/jf" disabled#g' /opt/build/build/data/html/setup.html
FROM --platform=$BUILDPLATFORM golang:latest AS build
FROM golang:bookworm AS final
ARG TARGETARCH
ENV GOARCH=$TARGETARCH
ARG BUILT_BY
ENV BUILTBY=$BUILT_BY
COPY --from=support /opt/build /opt/build
COPY --from=support /opt/build/dist/*_linux_${TARGETARCH}* /opt/jfa-go
COPY --from=support /opt/build/build/data /opt/jfa-go/data
RUN (cd /opt/build; make compile INTERNAL=off UPDATER=docker)
FROM golang:latest
COPY --from=build /opt/build/build /opt/jfa-go
RUN apt-get update -y && apt-get install libolm-dev -y
EXPOSE 8056
EXPOSE 8057
CMD [ "/opt/jfa-go/jfa-go", "-data", "/data" ]

180
Makefile
View File

@@ -1,3 +1,6 @@
.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
ifeq ($(GOESBUILD), on)
ESBUILD := esbuild
@@ -7,6 +10,7 @@ endif
GOBINARY ?= go
CSSVERSION ?= v3
CSS_BUNDLE = $(DATA)/web/css/$(CSSVERSION)bundle.css
VERSION ?= $(shell git describe --exact-match HEAD 2> /dev/null || echo vgit)
VERSION := $(shell echo $(VERSION) | sed 's/v//g')
@@ -25,14 +29,16 @@ endif
INTERNAL ?= on
TRAY ?= off
E2EE ?= off
E2EE ?= on
TAGS := -tags "
ifeq ($(INTERNAL), on)
DATA := data
COMPDEPS := $(BUILDDEPS)
else
DATA := build/data
TAGS := $(TAGS) external
COMPDEPS :=
endif
ifeq ($(TRAY), on)
@@ -53,17 +59,19 @@ endif
DEBUG ?= off
ifeq ($(DEBUG), on)
SOURCEMAP := --sourcemap
MINIFY :=
TYPECHECK := npx tsc -noEmit --project ts/tsconfig.json
# jank
COPYTS := rm -r $(DATA)/web/js/ts; cp -r tempts $(DATA)/web/js/ts
UNCSS := cp $(DATA)/web/css/bundle.css $(DATA)/bundle.css
UNCSS := cp $(CSS_BUNDLE) $(DATA)/bundle.css
# TAILWIND := --content ""
else
LDFLAGS := -s -w $(LDFLAGS)
SOURCEMAP :=
MINIFY := --minify
COPYTS :=
TYPECHECK :=
UNCSS := npx tailwindcss -i $(DATA)/web/css/bundle.css -o $(DATA)/bundle.css --content "html/crash.html"
UNCSS := npx tailwindcss -i $(CSS_BUNDLE) -o $(DATA)/bundle.css --content "html/crash.html"
# UNCSS := npx uncss $(DATA)/crash.html --csspath web/css --output $(DATA)/bundle.css
TAILWIND :=
endif
@@ -89,100 +97,140 @@ 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
npm:
$(info installing npm dependencies)
npm install $(NPMOPTS)
CONFIG_BASE = config/config-base.yaml
configuration:
$(info Fixing config-base)
-mkdir -p $(DATA)
python3 scripts/enumerate_config.py -i config/config-base.json -o $(DATA)/config-base.json
# CONFIG_DESCRIPTION = $(DATA)/config-base.json
CONFIG_DEFAULT = $(DATA)/config-default.ini
# $(CONFIG_DESCRIPTION) &: $(CONFIG_BASE)
# $(info Fixing config-base)
# -mkdir -p $(DATA)
$(DATA):
mkdir -p $(DATA)/web/js
mkdir -p $(DATA)/web/css
$(CONFIG_DEFAULT): $(CONFIG_BASE)
$(info Generating config-default.ini)
python3 scripts/generate_ini.py -i config/config-base.json -o $(DATA)/config-default.ini
go run scripts/ini/main.go -in $(CONFIG_BASE) -out $(DATA)/config-default.ini
email:
configuration: $(CONFIG_DEFAULT)
EMAIL_SRC = $(wildcard mail/*)
EMAIL_TARGET = $(DATA)/confirmation.html
$(EMAIL_TARGET): $(EMAIL_SRC)
$(info Generating email html)
python3 scripts/compile_mjml.py -o $(DATA)/
npx mjml mail/*.mjml -o $(DATA)/
$(info Copying plaintext mail)
cp mail/*.txt $(DATA)/
typescript:
TYPESCRIPT_FULLSRC = $(shell find ts/ -type f -name "*.ts")
TYPESCRIPT_SRC = $(wildcard ts/*.ts)
TYPESCRIPT_TEMPSRC = $(TYPESCRIPT_SRC:ts/%=tempts/%)
# TYPESCRIPT_TARGET = $(patsubst %.ts,%.js,$(subst tempts/,./$(DATA)/web/js/,$(TYPESCRIPT_TEMPSRC)))
TYPESCRIPT_TARGET = $(DATA)/web/js/admin.js
$(TYPESCRIPT_TARGET): $(TYPESCRIPT_FULLSRC) ts/tsconfig.json
$(TYPECHECK)
$(adding dark variants to typescript)
rm -rf tempts
cp -r ts tempts
$(adding dark variants to typescript)
scripts/dark-variant.sh tempts
scripts/dark-variant.sh tempts/modules
$(info compiling typescript)
mkdir -p $(DATA)/web/js
$(ESBUILD) --target=es6 --bundle tempts/admin.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/admin.js --minify
$(ESBUILD) --target=es6 --bundle tempts/user.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/user.js --minify
$(ESBUILD) --target=es6 --bundle tempts/pwr.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/pwr.js --minify
$(ESBUILD) --target=es6 --bundle tempts/pwr-pin.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/pwr-pin.js --minify
$(ESBUILD) --target=es6 --bundle tempts/form.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/form.js --minify
$(ESBUILD) --target=es6 --bundle tempts/setup.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/setup.js --minify
$(ESBUILD) --target=es6 --bundle tempts/crash.ts --outfile=./$(DATA)/crash.js --minify
$(foreach tempsrc,$(TYPESCRIPT_TEMPSRC),$(ESBUILD) --target=es6 --bundle $(tempsrc) $(SOURCEMAP) --outfile=$(patsubst %.ts,%.js,$(subst tempts/,./$(DATA)/web/js/,$(tempsrc))) $(MINIFY);)
$(COPYTS)
swagger:
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
compile:
$(info Downloading deps)
$(GOBINARY) mod download
$(info Building)
mkdir -p build
$(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o build/jfa-go
compress:
upx --lzma build/jfa-go
bundle-css:
mkdir -p $(DATA)/web/css
$(info copying fonts)
cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 $(DATA)/web/css/
$(info bundling css)
$(ESBUILD) --bundle css/base.css --outfile=$(DATA)/web/css/bundle.css --external:remixicon.css --external:../fonts/hanken* --minify
npx tailwindcss -i $(DATA)/web/css/bundle.css -o $(DATA)/web/css/bundle.css $(TAILWIND)
# npx postcss -o $(DATA)/web/css/bundle.css $(DATA)/web/css/bundle.css
inline-css:
cp html/crash.html $(DATA)/crash.html
$(UNCSS)
node scripts/inline.js root $(DATA) $(DATA)/crash.html $(DATA)/crash.html
rm $(DATA)/bundle.css
variants-html:
VARIANTS_SRC = $(wildcard html/*.html)
VARIANTS_TARGET = $(DATA)/html/admin.html
$(VARIANTS_TARGET): $(VARIANTS_SRC)
$(info copying html)
cp -r html $(DATA)/
$(info adding dark variants to html)
node scripts/missing-colors.js html $(DATA)/html
copy:
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/%)
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_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/
$(info bundling css)
rm -f $(CSS_TARGET) $(CSS_FULLTARGET)
$(ESBUILD) --bundle css/base.css --outfile=$(CSS_TARGET) --external:remixicon.css --external:../fonts/hanken* --minify
npx tailwindcss -i $(CSS_TARGET) -o $(CSS_FULLTARGET) $(TAILWIND)
rm $(CSS_TARGET)
# mv $(CSS_BUNDLE) $(DATA)/web/css/$(CSSVERSION)bundle.css
# npx postcss -o $(CSS_TARGET) $(CSS_TARGET)
INLINE_SRC = html/crash.html
INLINE_TARGET = $(DATA)/crash.html
$(INLINE_TARGET): $(CSS_FULLTARGET) $(INLINE_SRC)
cp html/crash.html $(DATA)/crash.html
$(UNCSS) # generates $(DATA)/bundle.css for us
node scripts/inline.js root $(DATA) $(DATA)/crash.html $(DATA)/crash.html
rm $(DATA)/bundle.css
LANG_SRC = $(shell find ./lang)
LANG_TARGET = $(LANG_SRC:lang/%=$(DATA)/lang/%)
STATIC_SRC = $(wildcard static/*)
STATIC_TARGET = $(STATIC_SRC:static/%=$(DATA)/web/%)
COPY_SRC = images/banner.svg jfa-go.service LICENSE $(LANG_SRC) $(STATIC_SRC)
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)/
$(info copying crash page)
mv $(DATA)/crash.html $(DATA)/html/
cp $(DATA)/crash.html $(DATA)/html/
$(info copying static data)
mkdir -p $(DATA)/web
cp images/banner.svg static/banner.svg
cp -r static/* $(DATA)/web/
$(info copying systemd service)
cp jfa-go.service $(DATA)/
$(info copying language files)
cp -r lang $(DATA)/
cp LICENSE $(DATA)/
mv $(DATA)/web/css/bundle.css $(DATA)/web/css/$(CSSVERSION)bundle.css
# internal-files:
# python3 scripts/embed.py internal
#
# external-files:
# python3 scripts/embed.py external
# -mkdir -p build
# $(info copying internal data into build/)
# cp -r data build/
BUILDDEPS := $(DATA) $(CONFIG_DEFAULT) $(EMAIL_TARGET) $(COPY_TARGET) $(SWAGGER_TARGET) $(INLINE_TARGET) $(CSS_FULLTARGET) $(TYPESCRIPT_TARGET)
precompile: $(BUILDDEPS)
COMPDEPS =
ifeq ($(INTERNAL), on)
COMPDEPS = $(BUILDDEPS)
endif
GO_SRC = $(shell find ./ -name "*.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)
test: $(BUILDDEPS) $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
$(GOBINARY) test -ldflags="$(LDFLAGS)" $(TAGS) -p 1
all: $(BUILDDEPS) $(GO_TARGET)
compress:
upx --lzma $(GO_TARGET)
install:
cp -r build $(DESTDIR)/jfa-go
@@ -194,6 +242,6 @@ clean:
-rm docs/docs.go docs/swagger.json docs/swagger.yaml
go clean
quick: configuration typescript variants-html bundle-css inline-css copy compile
all: configuration npm email typescript variants-html bundle-css inline-css swagger copy compile
npm:
$(info installing npm dependencies)
npm install $(NPMOPTS)

View File

@@ -1,5 +1,5 @@
![jfa-go](images/banner.svg)
[![Build Status](https://drone.hrfee.dev/api/badges/hrfee/jfa-go/status.svg?ref=refs/heads/main)](https://drone.hrfee.dev/hrfee/jfa-go)
[![Build Status](https://ci.hrfee.dev/api/badges/3/status.svg)](https://ci.hrfee.dev/repos/3)
[![Docker Hub](https://img.shields.io/docker/pulls/hrfee/jfa-go?label=docker)](https://hub.docker.com/r/hrfee/jfa-go)
[![Translation status](https://weblate.jfa-go.com/widgets/jfa-go/-/svg-badge.svg)](https://weblate.jfa-go.com/engage/jfa-go/)
[![Docs/Wiki](https://img.shields.io/static/v1?label=documentation&message=jfa-go.com&color=informational)](https://wiki.jfa-go.com)
@@ -13,13 +13,14 @@
Studies mean I can't work on this project a lot outside of breaks, however I hope i'll be able to fit in general support and things like bug fixes into my time. New features and such will likely come in short bursts throughout the year (if they do at all).
#### Does/Will it still work?
jfa-go currently works on Jellyfin 10.8.13, the latest version as of 26/12/23. 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.
* [Wizarr](https://github.com/Wizarrrr/wizarr) focuses on invites, and also includes some Discord & Ombi integration.
* [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) is a fork of Overseerr, which can manage users and mainly acts as an Ombi alternative.
* [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) is a fork of Overseerr which can manage users and mainly acts as an Ombi alternative.
* [jfa-go now integrates with Jellyseerr, much like Ombi, but better.](https://github.com/hrfee/jfa-go/pull/351)
* [Organizr](https://github.com/causefx/Organizr) doesn't focus on Jellyfin, but allows putting self-hosted services into "tabs" on a central page, and allows creating users, which lets one control who can access what.
---
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) (and [Emby](https://emby.media/) as 2nd class) that provides invite-based account creation as well as other features that make one's instance much easier to manage.
@@ -32,7 +33,7 @@ jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jelly
* Password validation: Ensure users choose a strong password.
* CAPTCHAs and contact method verificatoin can be enabled to avoid bots.
* ⌛ User expiry: Specify a validity period, and new users accounts will be disabled/deleted after it. The period can be manually extended too.
* 🔗 Ombi Integration: Automatically creates Ombi accounts for new users using their email address and login details, and your own defined set of permissions. See [wiki](https://wiki.jfa-go.com/docs/ombi/) for a warning on this one.
* 🔗 Ombi/Jellyseerr Integration: Automatically creates and synchronizes details for new accounts. Supports setting permissions with the Profiles feature. **Ombi integration use is risky, see [wiki](https://wiki.jfa-go.com/docs/ombi/)**.
* Account management: Bulk or individually; apply settings, delete, disable/enable, send messages and much more.
* 📣 Announcements: Bulk message your users with announcements about your server.
* Telegram/Discord/Matrix Integration: Verify users via a chat bot, and send Password Resets, Announcements, etc. through it.
@@ -58,6 +59,8 @@ jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jelly
**Note**: `TrayIcon` builds include a tray icon to start/stop/restart, and an option to automatically start when you log-in to your computer. For Linux users, these builds depend on the `libappindicator3-1`/`libappindicator-gtk3`/`libappindicator` package for Debian/Ubuntu, Fedora, and Alpine respectively.
`MatrixE2EE` builds (and Linux `TrayIcon` builds) include support for end-to-end encryption for the Matrix bot, but require the `libolm(-dev)` dependency. `.deb/.rpm/.apk` packages list this dependency, and docker images include it.
##### [Docker](https://hub.docker.com/r/hrfee/jfa-go)
```sh
docker create \
@@ -87,7 +90,7 @@ sudo apt-get update
# For servers
sudo apt-get install jfa-go
# ------
# For desktops/servers with GUI (has dependencies)
# For desktops/servers with GUI (may pull in lots of dependencies)
sudo apt-get install jfa-go-tray
# ------
```

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

@@ -2,35 +2,10 @@ package main
import (
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"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:
@@ -57,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":
@@ -85,73 +86,82 @@ func activitySourceToString(v ActivitySource) string {
return "anon"
}
// @Summary Get the requested set of activities, Paginated, filtered and sorted.
// 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
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("Failed to read activities from DB: %v\n", err)
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, status, err := app.jf.UserByID(act.UserID, false); status == 200 && err == nil {
resp.Activities[i].Username = user.Name
}
if (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != "" {
user, status, err := app.jf.UserByID(act.Source, false)
if status == 200 && err == nil {
resp.Activities[i].SourceUsername = user.Name
}
}
}
@@ -172,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
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 {
@@ -185,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
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

@@ -4,10 +4,9 @@ import (
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
)
// @Summary Creates a backup of the database.
@@ -32,10 +31,10 @@ func (app *appContext) CreateBackup(gc *gin.Context) {
func (app *appContext) GetBackup(gc *gin.Context) {
fname := gc.Param("fname")
// Hopefully this is enough to ensure the path isn't malicious. Hidden behind bearer auth anyway so shouldn't matter too much I guess.
ok := (strings.HasPrefix(fname, BACKUP_PREFIX) || strings.HasPrefix(fname, BACKUP_UPLOAD_PREFIX+BACKUP_PREFIX)) && strings.HasSuffix(fname, BACKUP_SUFFIX)
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(fname, BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX), BACKUP_SUFFIX))
if !ok || err != nil || t.IsZero() {
app.debug.Printf("Ignoring backup DL request due to fname: %v\n", err)
b := Backup{}
err := b.FromString(fname)
if err != nil || b.Date.IsZero() {
app.debug.Printf(lm.IgnoreInvalidFilename, fname, err)
respondBool(400, false, gc)
return
}
@@ -61,7 +60,8 @@ func (app *appContext) GetBackups(gc *gin.Context) {
resp.Backups[i].Name = item.Name()
fullpath := filepath.Join(path, item.Name())
resp.Backups[i].Path = fullpath
resp.Backups[i].Date = backups.dates[i].Unix()
resp.Backups[i].Date = backups.info[i].Date.Unix()
resp.Backups[i].Commit = backups.info[i].Commit
fstat, err := os.Stat(fullpath)
if err == nil {
resp.Backups[i].Size = fileSize(fstat.Size())
@@ -80,10 +80,10 @@ func (app *appContext) GetBackups(gc *gin.Context) {
func (app *appContext) RestoreLocalBackup(gc *gin.Context) {
fname := gc.Param("fname")
// Hopefully this is enough to ensure the path isn't malicious. Hidden behind bearer auth anyway so shouldn't matter too much I guess.
ok := strings.HasPrefix(fname, BACKUP_PREFIX) && strings.HasSuffix(fname, BACKUP_SUFFIX)
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(fname, BACKUP_PREFIX), BACKUP_SUFFIX))
if !ok || err != nil || t.IsZero() {
app.debug.Printf("Ignoring backup DL request due to fname: %v\n", err)
b := Backup{}
err := b.FromString(fname)
if err != nil || b.Date.IsZero() {
app.debug.Printf(lm.IgnoreInvalidFilename, fname, err)
respondBool(400, false, gc)
return
}
@@ -103,15 +103,16 @@ func (app *appContext) RestoreLocalBackup(gc *gin.Context) {
func (app *appContext) RestoreBackup(gc *gin.Context) {
file, err := gc.FormFile("backups-file")
if err != nil {
app.err.Printf("Failed to get file from form data: %v\n", err)
app.err.Printf(lm.FailedGetUpload, err)
respondBool(400, false, gc)
return
}
app.debug.Printf("Got uploaded file \"%s\"\n", file.Filename)
app.debug.Printf(lm.GetUpload, file.Filename)
path := app.config.Section("backups").Key("path").String()
fullpath := filepath.Join(path, BACKUP_UPLOAD_PREFIX+BACKUP_PREFIX+time.Now().Local().Format(BACKUP_DATEFMT)+BACKUP_SUFFIX)
b := Backup{Upload: true}
fullpath := filepath.Join(path, b.String())
gc.SaveUploadedFile(file, fullpath)
app.debug.Printf("Saved to \"%s\"\n", fullpath)
app.debug.Printf(lm.Write, fullpath)
LOADBAK = fullpath
app.restart(gc)
}

View File

@@ -1,6 +1,7 @@
package main
import (
"errors"
"fmt"
"strconv"
"strings"
@@ -8,6 +9,7 @@ import (
"time"
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/itchyny/timefmt-go"
"github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4"
@@ -29,6 +31,7 @@ func GenerateInviteCode() string {
return inviteCode
}
// checkInvites performs general housekeeping on invites, i.e. deleting expired ones and cleaning captcha data.
func (app *appContext) checkInvites() {
currentTime := time.Now()
for _, data := range app.storage.GetInvites() {
@@ -52,60 +55,11 @@ func (app *appContext) checkInvites() {
if !currentTime.After(expiry) {
continue
}
app.debug.Printf("Housekeeping: Deleting old invite %s", data.Code)
// Disable referrals for the user if UseReferralExpiry is enabled, so no new ones are made.
if data.IsReferral && data.UseReferralExpiry && data.ReferrerJellyfinID != "" {
user, ok := app.storage.GetEmailsKey(data.ReferrerJellyfinID)
if ok {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(data.ReferrerJellyfinID, user)
}
}
notify := data.Notify
if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
app.debug.Printf("%s: Expiry notification", data.Code)
var wait sync.WaitGroup
for address, settings := range notify {
if !settings["notify-expiry"] {
continue
}
wait.Add(1)
go func(addr string) {
defer wait.Done()
msg, err := app.email.constructExpiry(data.Code, data, app, false)
if err != nil {
app.err.Printf("%s: Failed to construct expiry notification: %v", data.Code, err)
} else {
// Check whether notify "address" is an email address of Jellyfin ID
if strings.Contains(addr, "@") {
err = app.email.send(msg, addr)
} else {
err = app.sendByID(msg, addr)
}
if err != nil {
app.err.Printf("%s: Failed to send expiry notification: %v", data.Code, err)
} else {
app.info.Printf("Sent expiry notification to %s", addr)
}
}
}(address)
}
wait.Wait()
}
app.storage.DeleteInvitesKey(data.Code)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeleteInvite,
SourceType: ActivityDaemon,
InviteCode: data.Code,
Value: data.Label,
Time: time.Now(),
}, nil, false)
app.deleteExpiredInvite(data)
}
}
// checkInvite checks the validity of a specific invite, optionally removing it if invalid(ated).
func (app *appContext) checkInvite(code string, used bool, username string) bool {
currentTime := time.Now()
inv, match := app.storage.GetInvitesKey(code)
@@ -114,54 +68,8 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
}
expiry := inv.ValidTill
if currentTime.After(expiry) {
app.debug.Printf("Housekeeping: Deleting old invite %s", code)
notify := inv.Notify
if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
app.debug.Printf("%s: Expiry notification", code)
var wait sync.WaitGroup
for address, settings := range notify {
if !settings["notify-expiry"] {
continue
}
wait.Add(1)
go func(addr string) {
defer wait.Done()
msg, err := app.email.constructExpiry(code, inv, app, false)
if err != nil {
app.err.Printf("%s: Failed to construct expiry notification: %v", code, err)
} else {
// Check whether notify "address" is an email address of Jellyfin ID
if strings.Contains(addr, "@") {
err = app.email.send(msg, addr)
} else {
err = app.sendByID(msg, addr)
}
if err != nil {
app.err.Printf("%s: Failed to send expiry notification: %v", code, err)
} else {
app.info.Printf("Sent expiry notification to %s", addr)
}
}
}(address)
}
wait.Wait()
}
if inv.IsReferral && inv.ReferrerJellyfinID != "" && inv.UseReferralExpiry {
user, ok := app.storage.GetEmailsKey(inv.ReferrerJellyfinID)
if ok {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(inv.ReferrerJellyfinID, user)
}
}
app.deleteExpiredInvite(inv)
match = false
app.storage.DeleteInvitesKey(code)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeleteInvite,
SourceType: ActivityDaemon,
InviteCode: code,
Value: inv.Label,
Time: time.Now(),
}, nil, false)
} else if used {
del := false
newInv := inv
@@ -187,6 +95,68 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
return match
}
func (app *appContext) deleteExpiredInvite(data Invite) {
app.debug.Printf(lm.DeleteOldInvite, data.Code)
// Disable referrals for the user if UseReferralExpiry is enabled, so no new ones are made.
if data.IsReferral && data.UseReferralExpiry && data.ReferrerJellyfinID != "" {
user, ok := app.storage.GetEmailsKey(data.ReferrerJellyfinID)
if ok {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(data.ReferrerJellyfinID, user)
app.InvalidateWebUserCache()
}
}
wait := app.sendAdminExpiryNotification(data)
app.storage.DeleteInvitesKey(data.Code)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeleteInvite,
SourceType: ActivityDaemon,
InviteCode: data.Code,
Value: data.Label,
Time: time.Now(),
}, nil, false)
if wait != nil {
wait.Wait()
}
}
func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup {
notify := data.Notify
if !emailEnabled || !app.config.Section("notifications").Key("enabled").MustBool(false) || len(notify) == 0 {
return nil
}
var wait sync.WaitGroup
for address, settings := range notify {
if !settings["notify-expiry"] {
continue
}
wait.Add(1)
go func(addr string) {
defer wait.Done()
msg, err := app.email.constructExpiry(data, false)
if err != nil {
app.err.Printf(lm.FailedConstructExpiryAdmin, data.Code, err)
} else {
// Check whether notify "address" is an email address or Jellyfin ID
if strings.Contains(addr, "@") {
err = app.email.send(msg, addr)
} else {
err = app.sendByID(msg, addr)
}
if err != nil {
app.err.Printf(lm.FailedSendExpiryAdmin, data.Code, addr, err)
} else {
app.info.Printf(lm.SentExpiryAdmin, data.Code, addr)
}
}
}(address)
}
return &wait
}
// @Summary Create a new invite.
// @Produce json
// @Param generateInviteDTO body generateInviteDTO true "New invite request object"
@@ -196,7 +166,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
// @tags Invites
func (app *appContext) GenerateInvite(gc *gin.Context) {
var req generateInviteDTO
app.debug.Println("Generating new invite")
app.debug.Println(lm.GenerateInvite)
gc.BindJSON(&req)
currentTime := time.Now()
validTill := currentTime.AddDate(0, req.Months, req.Days)
@@ -227,42 +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 := ""
app.debug.Printf("%s: Sending invite message", invite.Code)
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
users := app.discord.GetUsers(req.SendTo)
if len(users) == 0 {
invite.SendTo = fmt.Sprintf("Failed: User not found: \"%s\"", req.SendTo)
} else if len(users) > 1 {
invite.SendTo = fmt.Sprintf("Failed: Multiple users found: \"%s\"", 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 {
invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo)
app.err.Printf("%s: Failed to construct invite message: %v", 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("Failed to send to %s", req.SendTo)
app.err.Printf("%s: %s: %v", invite.Code, invite.SendTo, err)
// 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("%s: Sent invite email to \"%s\"", 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)
}
}
}
}
@@ -290,6 +265,46 @@ 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
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
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
@@ -297,7 +312,6 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
// @Security Bearer
// @tags Invites
func (app *appContext) GetInvites(gc *gin.Context) {
app.debug.Println("Invites requested")
currentTime := time.Now()
app.checkInvites()
var invites []inviteDTO
@@ -330,9 +344,9 @@ 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("Failed to parse usedBy time: %v", err)
app.err.Printf(lm.FailedParseTime, err)
}
unix = date.Unix()
}
@@ -347,7 +361,6 @@ func (app *appContext) GetInvites(gc *gin.Context) {
invite.SendTo = inv.SendTo
}
if len(inv.Notify) != 0 {
// app.err.Printf("%s has notify section: %+v, you are %s\n", inv.Code, inv.Notify, gc.GetString("jfId"))
var addressOrID string
if app.config.Section("ui").Key("jellyfin_login").MustBool(false) {
addressOrID = gc.GetString("jfId")
@@ -365,23 +378,8 @@ func (app *appContext) GetInvites(gc *gin.Context) {
}
invites = append(invites, invite)
}
fullProfileList := app.storage.GetProfiles()
profiles := make([]string, len(fullProfileList))
if len(profiles) != 0 {
defaultProfile := app.storage.GetDefaultProfile()
profiles[0] = defaultProfile.Name
i := 1
if len(fullProfileList) > 1 {
app.storage.db.ForEach(badgerhold.Where("Name").Ne(profiles[0]), func(p *Profile) error {
profiles[i] = p.Name
i++
return nil
})
}
}
resp := getInvitesDTO{
Profiles: profiles,
Invites: invites,
Invites: invites,
}
gc.JSON(200, resp)
}
@@ -393,14 +391,13 @@ func (app *appContext) GetInvites(gc *gin.Context) {
// @Failure 500 {object} stringResponse
// @Router /invites/profile [post]
// @Security Bearer
// @tags Profiles & Settings
// @tags Invites
func (app *appContext) SetProfile(gc *gin.Context) {
var req inviteProfileDTO
gc.BindJSON(&req)
app.debug.Printf("%s: Setting profile to \"%s\"", req.Invite, req.Profile)
// "" means "Don't apply profile"
if _, ok := app.storage.GetProfileKey(req.Profile); !ok && req.Profile != "" {
app.err.Printf("%s: Profile \"%s\" not found", req.Invite, req.Profile)
app.err.Printf(lm.FailedGetProfile, req.Profile)
respond(500, "Profile not found", gc)
return
}
@@ -424,11 +421,11 @@ func (app *appContext) SetNotify(gc *gin.Context) {
gc.BindJSON(&req)
changed := false
for code, settings := range req {
app.debug.Printf("%s: Notification settings change requested", code)
invite, ok := app.storage.GetInvitesKey(code)
if !ok {
app.err.Printf("%s Notification setting change failed: Invalid code", code)
respond(400, "Invalid invite code", gc)
msg := fmt.Sprintf(lm.InvalidInviteCode, code)
app.err.Println(msg)
respond(400, msg, gc)
return
}
var address string
@@ -436,9 +433,8 @@ func (app *appContext) SetNotify(gc *gin.Context) {
if jellyfinLogin {
var addressAvailable bool = app.getAddressOrName(gc.GetString("jfId")) != ""
if !addressAvailable {
app.err.Printf("%s: Couldn't find contact method for admin. Make sure one is set.", code)
app.debug.Printf("%s: User ID \"%s\"", code, gc.GetString("jfId"))
respond(500, "Missing user contact method", gc)
app.err.Printf(lm.FailedGetContactMethod, gc.GetString("jfId"))
respond(500, fmt.Sprintf(lm.FailedGetContactMethod, "admin"), gc)
return
}
address = gc.GetString("jfId")
@@ -453,15 +449,12 @@ func (app *appContext) SetNotify(gc *gin.Context) {
} /*else {
if _, ok := invite.Notify[address]["notify-expiry"]; !ok {
*/
if _, ok := settings["notify-expiry"]; ok && invite.Notify[address]["notify-expiry"] != settings["notify-expiry"] {
invite.Notify[address]["notify-expiry"] = settings["notify-expiry"]
app.debug.Printf("%s: Set \"notify-expiry\" to %t for %s", code, settings["notify-expiry"], address)
changed = true
}
if _, ok := settings["notify-creation"]; ok && invite.Notify[address]["notify-creation"] != settings["notify-creation"] {
invite.Notify[address]["notify-creation"] = settings["notify-creation"]
app.debug.Printf("%s: Set \"notify-creation\" to %t for %s", code, settings["notify-creation"], address)
changed = true
for _, notifyType := range []string{"notify-expiry", "notify-creation"} {
if _, ok := settings[notifyType]; ok && invite.Notify[address][notifyType] != settings[notifyType] {
invite.Notify[address][notifyType] = settings[notifyType]
app.debug.Printf(lm.SetAdminNotify, notifyType, settings[notifyType], address)
changed = true
}
}
if changed {
app.storage.SetInvitesKey(code, invite)
@@ -480,7 +473,6 @@ func (app *appContext) SetNotify(gc *gin.Context) {
func (app *appContext) DeleteInvite(gc *gin.Context) {
var req deleteInviteDTO
gc.BindJSON(&req)
app.debug.Printf("%s: Deletion requested", req.Code)
inv, ok := app.storage.GetInvitesKey(req.Code)
if ok {
app.storage.DeleteInvitesKey(req.Code)
@@ -495,10 +487,10 @@ func (app *appContext) DeleteInvite(gc *gin.Context) {
Time: time.Now(),
}, gc, false)
app.info.Printf("%s: Invite deleted", req.Code)
app.info.Printf(lm.DeleteInvite, req.Code)
respondBool(200, true, gc)
return
}
app.err.Printf("%s: Deletion failed: Invalid code", req.Code)
app.err.Printf(lm.FailedDeleteInvite, req.Code, "invalid code")
respond(400, "Code doesn't exist", gc)
}

View File

@@ -1,10 +1,13 @@
package main
import (
"fmt"
"net/url"
"strconv"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
)
// @Summary Get a list of Jellyseerr users.
@@ -15,14 +18,12 @@ import (
// @Security Bearer
// @tags Jellyseerr
func (app *appContext) JellyseerrUsers(gc *gin.Context) {
app.debug.Println("Jellyseerr users requested")
users, err := app.js.GetUsers()
if err != nil {
app.err.Printf("Failed to get users from Jellyseerr: %v", err)
app.err.Printf(lm.FailedGetUsers, lm.Jellyseerr, err)
respond(500, "Couldn't get users", gc)
return
}
app.debug.Printf("Jellyseerr users retrieved: %d", len(users))
userlist := make([]ombiUser, len(users))
i := 0
for _, u := range users {
@@ -60,14 +61,14 @@ func (app *appContext) SetJellyseerrProfile(gc *gin.Context) {
}
u, err := app.js.UserByID(jellyseerrID)
if err != nil {
app.err.Printf("Couldn't get user from Jellyseerr: %v", err)
app.err.Printf(lm.FailedGetUsers, lm.Jellyseerr, err)
respond(500, "Couldn't get user", gc)
return
}
profile.Jellyseerr.User = u.UserTemplate
n, err := app.js.GetNotificationPreferencesByID(jellyseerrID)
if err != nil {
app.err.Printf("Couldn't get user's notification prefs from Jellyseerr: %v", err)
app.err.Printf(lm.FailedGetJellyseerrNotificationPrefs, gc.Param("id"), err)
respond(500, "Couldn't get user notification prefs", gc)
return
}
@@ -98,3 +99,67 @@ func (app *appContext) DeleteJellyseerrProfile(gc *gin.Context) {
app.storage.SetProfileKey(profileName, profile)
respondBool(204, true, gc)
}
type JellyseerrWrapper struct {
*jellyseerr.Jellyseerr
}
func (js *JellyseerrWrapper) ImportUser(jellyfinID string, req newUserDTO, profile Profile) (err error, ok bool) {
// Gets existing user (not possible) or imports the given user.
_, err = js.MustGetUser(jellyfinID)
if err != nil {
return
}
ok = true
err = js.ApplyTemplateToUser(jellyfinID, profile.Jellyseerr.User)
if err != nil {
err = fmt.Errorf(lm.FailedApplyTemplate, "user", lm.Jellyseerr, jellyfinID, err)
return
}
err = js.ApplyNotificationsTemplateToUser(jellyfinID, profile.Jellyseerr.Notifications)
if err != nil {
err = fmt.Errorf(lm.FailedApplyTemplate, "notifications", lm.Jellyseerr, jellyfinID, err)
return
}
return
}
func (js *JellyseerrWrapper) AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (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 discordEnabled && discord != nil {
contactMethods[jellyseerr.FieldDiscord] = discord.ID
contactMethods[jellyseerr.FieldDiscordEnabled] = req.DiscordContact
}
if telegramEnabled && discord != nil {
contactMethods[jellyseerr.FieldTelegram] = telegram.ChatID
contactMethods[jellyseerr.FieldTelegramEnabled] = req.TelegramContact
}
if len(contactMethods) > 0 {
err = js.ModifyNotifications(jellyfinID, contactMethods)
if err != nil {
// app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
return
}
}
return
}
func (js *JellyseerrWrapper) Name() string { return lm.Jellyseerr }
func (js *JellyseerrWrapper) Enabled(app *appContext, profile *Profile) bool {
return profile != nil && profile.Jellyseerr.Enabled && app.config.Section("jellyseerr").Key("enabled").MustBool(false)
}

View File

@@ -1,11 +1,11 @@
package main
import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
)
@@ -22,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")
@@ -72,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)
@@ -122,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("Failed to get custom message with ID \"%s\"", id)
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})",
})
}
@@ -270,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.
@@ -328,10 +267,11 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
jellyseerr.FieldTelegram: tgUser.ChatID,
jellyseerr.FieldTelegramEnabled: tgUser.Contact,
}); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
linkExistingOmbiDiscordTelegram(app)
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -361,11 +301,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
tgUser.Contact = req.Telegram
app.storage.SetTelegramKey(req.ID, tgUser)
if change {
msg := ""
if !req.Telegram {
msg = " not"
}
app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg)
app.debug.Printf(lm.SetContactPrefForService, lm.Telegram, tgUser.Username, req.Telegram)
jsPrefs[jellyseerr.FieldTelegramEnabled] = req.Telegram
}
}
@@ -374,11 +310,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
dcUser.Contact = req.Discord
app.storage.SetDiscordKey(req.ID, dcUser)
if change {
msg := ""
if !req.Discord {
msg = " not"
}
app.debug.Printf("Discord: User \"%s\" will%s be notified through Discord.", dcUser.Username, msg)
app.debug.Printf(lm.SetContactPrefForService, lm.Discord, dcUser.Username, req.Discord)
jsPrefs[jellyseerr.FieldDiscordEnabled] = req.Discord
}
}
@@ -387,11 +319,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
mxUser.Contact = req.Matrix
app.storage.SetMatrixKey(req.ID, mxUser)
if change {
msg := ""
if !req.Matrix {
msg = " not"
}
app.debug.Printf("Matrix: User \"%s\" will%s be notified through Matrix.", mxUser.UserID, msg)
app.debug.Printf(lm.SetContactPrefForService, lm.Matrix, mxUser.UserID, req.Matrix)
}
}
if email, ok := app.storage.GetEmailsKey(req.ID); ok {
@@ -399,20 +327,17 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
email.Contact = req.Email
app.storage.SetEmailsKey(req.ID, email)
if change {
msg := ""
if !req.Email {
msg = " not"
}
app.debug.Printf("\"%s\" will%s be notified via Email.", email.Addr, msg)
app.debug.Printf(lm.SetContactPrefForService, lm.Email, email.Addr, req.Email)
jsPrefs[jellyseerr.FieldEmailEnabled] = req.Email
}
}
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
err := app.js.ModifyNotifications(req.ID, jsPrefs)
if err != nil {
app.err.Printf("Failed to sync contact prefs with Jellyseerr: %v", err)
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
}
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -429,7 +354,7 @@ func (app *appContext) TelegramVerified(gc *gin.Context) {
respondBool(200, ok, gc)
}
// @Summary Returns true/false on whether or not a telegram PIN was verified. Requires invite code.
// @Summary Returns true/false on whether or not a telegram PIN was verified. Requires invite code. NOTE: "/invite" might have been changed in Settings > URL Paths.
// @Produce json
// @Success 200 {object} boolResponse
// @Success 401 {object} boolResponse
@@ -446,14 +371,14 @@ func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) {
pin := gc.Param("pin")
token, ok := app.telegram.TokenVerified(pin)
if ok && app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(token.Username) {
app.discord.DeleteVerifiedUser(pin)
app.discord.DeleteVerifiedToken(pin)
respondBool(400, false, gc)
return
}
respondBool(200, ok, gc)
}
// @Summary Returns true/false on whether or not a discord PIN was verified. Requires invite code.
// @Summary Returns true/false on whether or not a discord PIN was verified. Requires invite code. NOTE: "/invite" might have been changed in Settings > URL Paths.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 401 {object} boolResponse
@@ -469,7 +394,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
}
pin := gc.Param("pin")
user, ok := app.discord.UserVerified(pin)
if ok && app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(user.ID) {
if ok && app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(user.MethodID().(string)) {
delete(app.discord.verifiedTokens, pin)
respondBool(400, false, gc)
return
@@ -477,7 +402,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
respondBool(200, ok, gc)
}
// @Summary Returns a 10-minute, one-use Discord server invite
// @Summary Returns a 10-minute, one-use Discord server invite. NOTE: "/invite" might have been changed in Settings > URL Paths.
// @Produce json
// @Success 200 {object} DiscordInviteDTO
// @Failure 400 {object} boolResponse
@@ -487,7 +412,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
// @Router /invite/{invCode}/discord/invite [get]
// @tags Other
func (app *appContext) DiscordServerInvite(gc *gin.Context) {
if app.discord.inviteChannelName == "" {
if app.discord.InviteChannel.Name == "" {
respondBool(400, false, gc)
return
}
@@ -504,7 +429,7 @@ func (app *appContext) DiscordServerInvite(gc *gin.Context) {
gc.JSON(200, DiscordInviteDTO{invURL, iconURL})
}
// @Summary Generate and send a new PIN to a specified Matrix user.
// @Summary Generate and send a new PIN to a specified Matrix user. NOTE: "/invite" might have been changed in Settings > URL Paths.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 400 {object} stringResponse
@@ -543,7 +468,7 @@ func (app *appContext) MatrixSendPIN(gc *gin.Context) {
respondBool(200, true, gc)
}
// @Summary Check whether a matrix PIN is valid, and mark the token as verified if so. Requires invite code.
// @Summary Check whether a matrix PIN is valid, and mark the token as verified if so. Requires invite code. NOTE: "/invite" might have been changed in Settings > URL Paths.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 401 {object} boolResponse
@@ -555,7 +480,7 @@ func (app *appContext) MatrixSendPIN(gc *gin.Context) {
func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
code := gc.Param("invCode")
if _, ok := app.storage.GetInvitesKey(code); !ok {
app.debug.Println("Matrix: Invite code was invalid")
app.debug.Printf(lm.InvalidInviteCode, code)
respondBool(401, false, gc)
return
}
@@ -563,12 +488,12 @@ func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
pin := gc.Param("pin")
user, ok := app.matrix.tokens[pin]
if !ok {
app.debug.Println("Matrix: PIN not found")
app.debug.Printf(lm.InvalidPIN, pin)
respondBool(200, false, gc)
return
}
if user.User.UserID != userID {
app.debug.Println("Matrix: User ID of PIN didn't match")
app.debug.Printf(lm.UnauthorizedPIN, pin)
respondBool(200, false, gc)
return
}
@@ -596,18 +521,18 @@ func (app *appContext) MatrixLogin(gc *gin.Context) {
}
token, err := app.matrix.generateAccessToken(req.Homeserver, req.Username, req.Password)
if err != nil {
app.err.Printf("Matrix: Failed to generate token: %v", err)
app.err.Printf(lm.FailedGenerateToken, err)
respond(401, "Unauthorized", gc)
return
}
tempConfig, _ := ini.Load(app.configPath)
tempConfig, _ := ini.ShadowLoad(app.configPath)
matrix := tempConfig.Section("matrix")
matrix.Key("enabled").SetValue("true")
matrix.Key("homeserver").SetValue(req.Homeserver)
matrix.Key("token").SetValue(token)
matrix.Key("user_id").SetValue(req.Username)
if err := tempConfig.SaveTo(app.configPath); err != nil {
app.err.Printf("Failed to save config to \"%s\": %v", app.configPath, err)
app.err.Printf(lm.FailedWriting, app.configPath, err)
respondBool(500, false, gc)
return
}
@@ -629,20 +554,19 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
if app.storage.GetMatrix() == nil {
app.storage.deprecatedMatrix = matrixStore{}
}
roomID, encrypted, err := app.matrix.CreateRoom(req.UserID)
roomID, err := app.matrix.CreateRoom(req.UserID)
if err != nil {
app.err.Printf("Matrix: Failed to create room: %v", err)
app.err.Printf(lm.FailedCreateRoom, err)
respondBool(500, false, gc)
return
}
app.storage.SetMatrixKey(req.JellyfinID, MatrixUser{
UserID: req.UserID,
RoomID: string(roomID),
Lang: "en-us",
Contact: true,
Encrypted: encrypted,
UserID: req.UserID,
RoomID: string(roomID),
Lang: "en-us",
Contact: true,
})
app.matrix.isEncrypted[roomID] = encrypted
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -701,7 +625,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
jellyseerr.FieldDiscord: req.DiscordID,
jellyseerr.FieldDiscordEnabled: true,
}); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
@@ -714,6 +638,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
}, gc, false)
linkExistingOmbiDiscordTelegram(app)
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -739,7 +664,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier,
jellyseerr.FieldDiscordEnabled: false,
}); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
@@ -751,6 +676,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
Time: time.Now(),
}, gc, false)
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -775,7 +701,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier,
jellyseerr.FieldTelegramEnabled: false,
}); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
@@ -787,6 +713,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
Time: time.Now(),
}, gc, false)
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -816,5 +743,6 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) {
Time: time.Now(),
}, gc, false)
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}

View File

@@ -3,43 +3,53 @@ package main
import (
"fmt"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/common"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/jfa-go/ombi"
"github.com/hrfee/mediabrowser"
)
func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, error) {
ombiUsers, code, err := app.ombi.GetUsers()
if err != nil || code != 200 {
return nil, code, err
}
jfUser, code, err := app.jf.UserByID(jfID, false)
if err != nil || code != 200 {
return nil, code, err
func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, error) {
jfUser, err := app.jf.UserByID(jfID, false)
if err != nil {
return nil, err
}
username := jfUser.Name
email := ""
if e, ok := app.storage.GetEmailsKey(jfID); ok {
email = e.Addr
}
user, err := app.ombi.getUser(username, email)
return user, err
}
func (ombi *OmbiWrapper) getUser(username string, email string) (map[string]interface{}, error) {
ombiUsers, err := ombi.GetUsers()
if err != nil {
return nil, err
}
for _, ombiUser := range ombiUsers {
ombiAddr := ""
if a, ok := ombiUser["emailAddress"]; ok && a != nil {
ombiAddr = a.(string)
}
if ombiUser["userName"].(string) == username || (ombiAddr == email && email != "") {
return ombiUser, code, err
return ombiUser, err
}
}
return nil, 400, fmt.Errorf("couldn't find user")
// Gets a generic "not found" type error
return nil, common.GenericErr(404, err)
}
// Returns a user with the given name who has been imported from Jellyfin/Emby by Ombi
func (app *appContext) getOmbiImportedUser(name string) (map[string]interface{}, int, error) {
func (ombi *OmbiWrapper) getImportedUser(name string) (map[string]interface{}, error) {
// Ombi User Types: 3/4 = Emby, 5 = Jellyfin
ombiUsers, code, err := app.ombi.GetUsers()
if err != nil || code != 200 {
return nil, code, err
ombiUsers, err := ombi.GetUsers()
if err != nil {
return nil, err
}
for _, ombiUser := range ombiUsers {
if ombiUser["userName"].(string) == name {
@@ -52,10 +62,11 @@ func (app *appContext) getOmbiImportedUser(name string) (map[string]interface{},
} else if uType != 3 && uType != 4 { // Emby
continue
}
return ombiUser, code, err
return ombiUser, err
}
}
return nil, 400, fmt.Errorf("couldn't find user")
// Gets a generic "not found" type error
return nil, common.GenericErr(404, err)
}
// @Summary Get a list of Ombi users.
@@ -66,10 +77,9 @@ func (app *appContext) getOmbiImportedUser(name string) (map[string]interface{},
// @Security Bearer
// @tags Ombi
func (app *appContext) OmbiUsers(gc *gin.Context) {
app.debug.Println("Ombi users requested")
users, status, err := app.ombi.GetUsers()
if err != nil || status != 200 {
app.err.Printf("Failed to get users from Ombi (%d): %v", status, err)
users, err := app.ombi.GetUsers()
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Ombi, err)
respond(500, "Couldn't get users", gc)
return
}
@@ -103,9 +113,9 @@ func (app *appContext) SetOmbiProfile(gc *gin.Context) {
respondBool(400, false, gc)
return
}
template, code, err := app.ombi.TemplateByID(req.ID)
if err != nil || code != 200 || len(template) == 0 {
app.err.Printf("Couldn't get user from Ombi (%d): %v", code, err)
template, err := app.ombi.TemplateByID(req.ID)
if err != nil || len(template) == 0 {
app.err.Printf(lm.FailedGetUsers, lm.Ombi, err)
respond(500, "Couldn't get user", gc)
return
}
@@ -136,7 +146,11 @@ func (app *appContext) DeleteOmbiProfile(gc *gin.Context) {
respondBool(204, true, gc)
}
func (app *appContext) applyOmbiProfile(user map[string]interface{}, profile map[string]interface{}) (status int, err error) {
type OmbiWrapper struct {
*ombi.Ombi
}
func (ombi *OmbiWrapper) applyProfile(user map[string]interface{}, profile map[string]interface{}) (err error) {
for k, v := range profile {
switch v.(type) {
case map[string]interface{}, []interface{}:
@@ -147,6 +161,63 @@ func (app *appContext) applyOmbiProfile(user map[string]interface{}, profile map
}
}
}
status, err = app.ombi.ModifyUser(user)
err = ombi.ModifyUser(user)
return
}
func (ombi *OmbiWrapper) ImportUser(jellyfinID string, req newUserDTO, profile Profile) (err error, ok bool) {
errors, err := ombi.NewUser(req.Username, req.Password, req.Email, profile.Ombi)
var ombiUser map[string]interface{}
if err != nil {
// Check if on the off chance, Ombi's user importer has already added the account.
ombiUser, err = ombi.getImportedUser(req.Username)
if err == nil {
// app.info.Println(lm.Ombi + " " + lm.UserExists)
profile.Ombi["password"] = req.Password
err = ombi.applyProfile(ombiUser, profile.Ombi)
if err != nil {
err = fmt.Errorf(lm.FailedApplyProfile, lm.Ombi, req.Username, err)
}
} else {
if len(errors) != 0 {
err = fmt.Errorf("%v, %s", err, strings.Join(errors, ", "))
}
return
}
}
ok = true
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)
if err != nil {
return
}
if discordEnabled || telegramEnabled {
dID := ""
tUser := ""
if discord != nil {
dID = discord.ID
}
if telegram != nil {
tUser = telegram.Username
}
var resp string
resp, err = ombi.SetNotificationPrefs(ombiUser, dID, tUser)
if err != nil {
if resp != "" {
err = fmt.Errorf("%v, %s", err, resp)
}
return
}
}
return
}
func (ombi *OmbiWrapper) Name() string { return lm.Ombi }
func (ombi *OmbiWrapper) Enabled(app *appContext, profile *Profile) bool {
return profile != nil && profile.Ombi != nil && len(profile.Ombi) != 0 && app.config.Section("ombi").Key("enabled").MustBool(false)
}

View File

@@ -1,20 +1,48 @@
package main
import (
"fmt"
"time"
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/timshannon/badgerhold/v4"
)
// @Summary Get a list of profiles
// @Summary Get the names of all available profile.
// @Produce json
// @Success 200 {object} getProfileNamesDTO
// @Router /profiles/names [get]
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) GetProfileNames(gc *gin.Context) {
fullProfileList := app.storage.GetProfiles()
profiles := make([]string, len(fullProfileList))
if len(profiles) != 0 {
defaultProfile := app.storage.GetDefaultProfile()
profiles[0] = defaultProfile.Name
i := 1
if len(fullProfileList) > 1 {
app.storage.db.ForEach(badgerhold.Where("Name").Ne(profiles[0]), func(p *Profile) error {
profiles[i] = p.Name
i++
return nil
})
}
}
resp := getProfileNamesDTO{
Profiles: profiles,
}
gc.JSON(200, resp)
}
// @Summary Get all available profiles, indexed by their names.
// @Produce json
// @Success 200 {object} getProfilesDTO
// @Router /profiles [get]
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) GetProfiles(gc *gin.Context) {
app.debug.Println("Profiles requested")
out := getProfilesDTO{
DefaultProfile: app.storage.GetDefaultProfile().Name,
Profiles: map[string]profileDTO{},
@@ -52,10 +80,11 @@ func (app *appContext) GetProfiles(gc *gin.Context) {
func (app *appContext) SetDefaultProfile(gc *gin.Context) {
req := profileChangeDTO{}
gc.BindJSON(&req)
app.info.Printf("Setting default profile to \"%s\"", req.Name)
app.info.Printf(lm.SetDefaultProfile, req.Name)
if _, ok := app.storage.GetProfileKey(req.Name); !ok {
app.err.Printf("Profile not found: \"%s\"", req.Name)
respond(500, "Profile not found", gc)
msg := fmt.Sprintf(lm.FailedGetProfile, req.Name)
app.err.Println(msg)
respond(500, msg, gc)
return
}
app.storage.db.ForEach(&badgerhold.Query{}, func(profile *Profile) error {
@@ -79,13 +108,12 @@ func (app *appContext) SetDefaultProfile(gc *gin.Context) {
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) CreateProfile(gc *gin.Context) {
app.info.Println("Profile creation requested")
var req newProfileDTO
gc.BindJSON(&req)
app.jf.CacheExpiry = time.Now()
user, status, err := app.jf.UserByID(req.ID, false)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to get user from Jellyfin (%d): %v", status, err)
app.InvalidateJellyfinCache()
user, err := app.jf.UserByID(req.ID, false)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Couldn't get user", gc)
return
}
@@ -94,12 +122,12 @@ func (app *appContext) CreateProfile(gc *gin.Context) {
Policy: user.Policy,
Homescreen: req.Homescreen,
}
app.debug.Printf("Creating profile from user \"%s\"", user.Name)
app.debug.Printf(lm.CreateProfileFromUser, user.Name)
if req.Homescreen {
profile.Configuration = user.Configuration
profile.Displayprefs, status, err = app.jf.GetDisplayPreferences(req.ID)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to get DisplayPrefs (%d): %v", status, err)
profile.Displayprefs, err = app.jf.GetDisplayPreferences(req.ID)
if err != nil {
app.err.Printf(lm.FailedGetJellyfinDisplayPrefs, req.ID, err)
respond(500, "Couldn't get displayprefs", gc)
return
}
@@ -145,13 +173,13 @@ func (app *appContext) EnableReferralForProfile(gc *gin.Context) {
inv, ok := app.storage.GetInvitesKey(invCode)
if !ok {
respond(400, "Invalid invite code", gc)
app.err.Printf("\"%s\": Failed to enable referrals: invite not found", profileName)
app.err.Printf(lm.InvalidInviteCode, invCode)
return
}
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respond(400, "Invalid profile", gc)
app.err.Printf("\"%s\": Failed to enable referrals: profile not found", profileName)
app.err.Printf(lm.FailedGetProfile, profileName)
return
}

View File

@@ -1,6 +1,7 @@
package main
import (
"fmt"
"net/http"
"os"
"strings"
@@ -9,6 +10,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
"github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4"
)
@@ -27,9 +29,9 @@ func (app *appContext) MyDetails(gc *gin.Context) {
Id: gc.GetString("jfId"),
}
user, status, err := app.jf.UserByID(resp.Id, false)
if status != 200 || err != nil {
app.err.Printf("Failed to get Jellyfin user (%d): %+v\n", status, err)
user, err := app.jf.UserByID(resp.Id, false)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Failed to get user", gc)
return
}
@@ -133,8 +135,9 @@ func (app *appContext) SetMyContactMethods(gc *gin.Context) {
func (app *appContext) LogoutUser(gc *gin.Context) {
cookie, err := gc.Cookie("user-refresh")
if err != nil {
app.debug.Printf("Couldn't get cookies: %s", err)
respond(500, "Couldn't fetch cookies", gc)
msg := fmt.Sprintf(lm.FailedGetCookies, "user-refresh", err)
app.debug.Println(msg)
respond(500, msg, gc)
return
}
app.invalidTokens = append(app.invalidTokens, cookie)
@@ -161,9 +164,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
var target ConfirmationTarget
var id string
fail := func() {
gcHTML(gc, 404, "404.html", gin.H{
"cssClass": app.cssClass,
"cssVersion": cssVersion,
app.gcHTML(gc, 404, "404.html", OtherPage, gin.H{
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}
@@ -174,21 +175,21 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
}
token, err := jwt.Parse(key, checkToken)
if err != nil {
app.err.Printf("Failed to parse key: %s", err)
app.err.Printf(lm.FailedParseJWT, err)
fail()
// respond(500, "unknownError", gc)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
app.err.Printf("Failed to parse key: %s", err)
app.err.Println(lm.FailedCastJWT)
fail()
// respond(500, "unknownError", gc)
return
}
expiry := time.Unix(int64(claims["exp"].(float64)), 0)
if !(ok && token.Valid && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) {
app.err.Printf("Invalid key")
app.err.Println(lm.InvalidJWT)
fail()
// respond(400, "invalidKey", gc)
return
@@ -198,7 +199,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
// Perform an Action
if target == NoOp {
gc.Redirect(http.StatusSeeOther, "/my/account")
gc.Redirect(http.StatusSeeOther, PAGES.MyAccount)
return
} else if target == UserEmailChange {
app.modifyEmail(id, claims["email"].(string))
@@ -212,8 +213,8 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
Time: time.Now(),
}, gc, true)
app.info.Println("Email list modified")
gc.Redirect(http.StatusSeeOther, "/my/account")
app.info.Printf(lm.UserEmailAdjusted, gc.GetString("jfId"))
gc.Redirect(http.StatusSeeOther, PAGES.MyAccount)
return
}
}
@@ -231,7 +232,6 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
func (app *appContext) ModifyMyEmail(gc *gin.Context) {
var req ModifyMyEmailDTO
gc.BindJSON(&req)
app.debug.Println("Email modification requested")
if !strings.ContainsRune(req.Email, '@') {
respond(400, "Invalid Email Address", gc)
return
@@ -251,26 +251,26 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET")))
if err != nil {
app.err.Printf("Failed to generate confirmation token: %v", err)
app.err.Printf(lm.FailedSignJWT, err)
respond(500, "errorUnknown", gc)
return
}
if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) {
user, status, err := app.jf.UserByID(id, false)
user, err := app.jf.UserByID(id, false)
name := ""
if status == 200 && err == nil {
if err == nil {
name = user.Name
}
app.debug.Printf("%s: Email confirmation required", id)
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("%s: Failed to construct confirmation email: %v", name, err)
app.err.Printf(lm.FailedConstructConfirmationEmail, id, err)
} else if err := app.email.send(msg, req.Email); err != nil {
app.err.Printf("%s: Failed to send user confirmation email: %v", name, err)
app.err.Printf(lm.FailedSendConfirmationEmail, id, req.Email, err)
} else {
app.info.Printf("%s: Sent user confirmation email to \"%s\"", name, req.Email)
app.err.Printf(lm.SentConfirmationEmail, id, req.Email)
}
return
}
@@ -290,7 +290,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
// @Security Bearer
// @tags User Page
func (app *appContext) MyDiscordServerInvite(gc *gin.Context) {
if app.discord.inviteChannelName == "" {
if app.discord.InviteChannel.Name == "" {
respondBool(400, false, gc)
return
}
@@ -338,7 +338,7 @@ func (app *appContext) GetMyPIN(gc *gin.Context) {
func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
pin := gc.Param("pin")
dcUser, ok := app.discord.AssignedUserVerified(pin, gc.GetString("jfId"))
app.discord.DeleteVerifiedUser(pin)
app.discord.DeleteVerifiedToken(pin)
if !ok {
respondBool(200, false, gc)
return
@@ -358,7 +358,7 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
jellyseerr.FieldDiscord: dcUser.ID,
jellyseerr.FieldDiscordEnabled: dcUser.Contact,
}); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
@@ -413,7 +413,7 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
jellyseerr.FieldTelegram: tgUser.ChatID,
jellyseerr.FieldTelegramEnabled: tgUser.Contact,
}); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
@@ -477,12 +477,12 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
pin := gc.Param("pin")
user, ok := app.matrix.tokens[pin]
if !ok {
app.debug.Println("Matrix: PIN not found")
app.debug.Printf(lm.InvalidPIN, pin)
respondBool(200, false, gc)
return
}
if user.User.UserID != userID {
app.debug.Println("Matrix: User ID of PIN didn't match")
app.debug.Printf(lm.UnauthorizedPIN, pin)
respondBool(200, false, gc)
return
}
@@ -523,7 +523,7 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier,
jellyseerr.FieldDiscordEnabled: false,
}); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
@@ -551,7 +551,7 @@ func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier,
jellyseerr.FieldTelegramEnabled: false,
}); err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
@@ -606,7 +606,6 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
contactMethodAllowed := app.config.Section("user_page").Key("allow_pwr_contact_method").MustBool(true)
address := gc.Param("address")
if address == "" {
app.debug.Println("Ignoring empty request for PWR")
cancel.Stop()
respondBool(400, false, gc)
return
@@ -616,7 +615,7 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
jfUser, ok := app.ReverseUserSearch(address, usernameAllowed, emailAllowed, contactMethodAllowed)
if !ok {
app.debug.Printf("Ignoring PWR request: User not found")
app.debug.Printf(lm.FailedGetUsers, lm.Jellyfin, "no results")
for range timerWait {
respondBool(204, true, gc)
@@ -626,7 +625,7 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
}
pwr, err = app.GenInternalReset(jfUser.ID)
if err != nil {
app.err.Printf("Failed to get user from Jellyfin: %v", err)
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
for range timerWait {
respondBool(204, true, gc)
return
@@ -644,19 +643,19 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
Username: pwr.Username,
Expiry: pwr.Expiry,
Internal: true,
}, app, false,
}, false,
)
if err != nil {
app.err.Printf("Failed to construct password reset message for \"%s\": %v", pwr.Username, err)
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
for range timerWait {
respondBool(204, true, gc)
return
}
return
} else if err := app.sendByID(msg, jfUser.ID); err != nil {
app.err.Printf("Failed to send password reset message to \"%s\": %v", address, err)
app.err.Printf(lm.FailedSendPWRMessage, pwr.Username, "?", err)
} else {
app.info.Printf("Sent password reset message to \"%s\"", address)
app.info.Printf(lm.SentPWRMessage, pwr.Username, "?")
}
for range timerWait {
respondBool(204, true, gc)
@@ -683,25 +682,24 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
validation := app.validator.validate(req.New)
for _, val := range validation {
if !val {
app.debug.Printf("%s: Change password failed: Invalid password", gc.GetString("jfId"))
gc.JSON(400, validation)
return
}
}
user, status, err := app.jf.UserByID(gc.GetString("jfId"), false)
if status != 200 || err != nil {
app.err.Printf("Failed to change password: couldn't find user (%d): %+v", status, err)
user, err := app.jf.UserByID(gc.GetString("jfId"), false)
if err != nil {
app.err.Printf(lm.FailedGetUser, gc.GetString("jfId"), lm.Jellyfin, err)
respondBool(500, false, gc)
return
}
// Authenticate as user to confirm old password.
user, status, err = app.authJf.Authenticate(user.Name, req.Old)
if status != 200 || err != nil {
user, err = app.authJf.Authenticate(user.Name, req.Old)
if err != nil {
respondBool(401, false, gc)
return
}
status, err = app.jf.SetPassword(gc.GetString("jfId"), req.Old, req.New)
if (status != 200 && status != 204) || err != nil {
err = app.jf.SetPassword(gc.GetString("jfId"), req.Old, req.New)
if err != nil {
respondBool(500, false, gc)
return
}
@@ -716,18 +714,18 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
if app.config.Section("ombi").Key("enabled").MustBool(false) {
func() {
ombiUser, status, err := app.getOmbiUser(gc.GetString("jfId"))
if status != 200 || err != nil {
app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", user.Name, status, err)
ombiUser, err := app.getOmbiUser(gc.GetString("jfId"))
if err != nil {
app.err.Printf(lm.FailedGetUser, user.Name, lm.Ombi, err)
return
}
ombiUser["password"] = req.New
status, err = app.ombi.ModifyUser(ombiUser)
if status != 200 || err != nil {
app.err.Printf("Failed to set password for ombi user \"%s\" (%d): %v", ombiUser["userName"], status, err)
err = app.ombi.ModifyUser(ombiUser)
if err != nil {
app.err.Printf(lm.FailedChangePassword, lm.Ombi, ombiUser["userName"], err)
return
}
app.debug.Printf("Reset password for ombi user \"%s\"", ombiUser["userName"])
app.debug.Printf(lm.ChangePassword, lm.Ombi, ombiUser["userName"])
}()
}
cookie, err := gc.Cookie("user-refresh")
@@ -735,7 +733,7 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
app.invalidTokens = append(app.invalidTokens, cookie)
gc.SetCookie("refresh", "invalid", -1, "/my", gc.Request.URL.Hostname(), true, true)
} else {
app.debug.Printf("Couldn't get cookies: %s", err)
app.debug.Printf(lm.FailedGetCookies, "user-refresh", err)
}
respondBool(204, true, gc)
}
@@ -761,7 +759,7 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
err = app.storage.db.Get(user.ReferralTemplateKey, &inv)
if !ok || err != nil || user.ReferralTemplateKey == "" {
app.debug.Printf("Ignoring referral request, couldn't find template.")
app.debug.Printf(lm.FailedGetReferralTemplate, user.ReferralTemplateKey, err)
respondBool(400, false, gc)
return
}
@@ -782,6 +780,7 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
// If UseReferralExpiry is enabled, we delete it and return nothing.
app.storage.DeleteInvitesKey(inv.Code)
if inv.UseReferralExpiry {
app.debug.Printf(lm.DeleteOldReferral, inv.Code)
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
if ok {
user.ReferralTemplateKey = ""
@@ -791,11 +790,13 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
respondBool(400, false, gc)
return
}
app.debug.Printf(lm.RenewOldReferral, inv.Code)
inv.Code = GenerateInviteCode()
inv.Created = time.Now()
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,

File diff suppressed because it is too large Load Diff

273
api.go
View File

@@ -1,10 +1,13 @@
package main
import (
"fmt"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/common"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser"
"github.com/itchyny/timefmt-go"
"github.com/lithammer/shortuuid/v3"
@@ -33,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
}
@@ -122,14 +116,14 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
}
}
if !valid || req.PIN == "" {
app.info.Printf("%s: Password reset failed: Invalid password", req.PIN)
app.info.Printf(lm.FailedChangePassword, lm.Jellyfin, "?", lm.InvalidPassword)
gc.JSON(400, validation)
return
}
isInternal := false
if captcha && !app.verifyCaptcha(req.PIN, req.PIN, req.CaptchaText, true) {
app.info.Printf("%s: PWR Failed: Captcha Incorrect", req.PIN)
app.info.Printf(lm.FailedChangePassword, lm.Jellyfin, "?", lm.IncorrectCaptcha)
respond(400, "errorCaptcha", gc)
return
}
@@ -138,7 +132,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
if reset, ok := app.internalPWRs[req.PIN]; ok {
isInternal = true
if time.Now().After(reset.Expiry) {
app.info.Printf("Password reset failed: PIN \"%s\" has expired", reset.PIN)
app.info.Printf(lm.FailedChangePassword, lm.Jellyfin, "?", fmt.Sprintf(lm.ExpiredPIN, reset.PIN))
respondBool(401, false, gc)
delete(app.internalPWRs, req.PIN)
return
@@ -146,18 +140,18 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
userID = reset.ID
username = reset.Username
status, err := app.jf.ResetPasswordAdmin(userID)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Password Reset failed (%d): %v", status, err)
respondBool(status, false, gc)
err := app.jf.ResetPasswordAdmin(userID)
if err != nil {
app.err.Printf(lm.FailedChangePassword, lm.Jellyfin, userID, err)
respondBool(500, false, gc)
return
}
delete(app.internalPWRs, req.PIN)
} else {
resp, status, err := app.jf.ResetPassword(req.PIN)
if status != 200 || err != nil || !resp.Success {
app.err.Printf("Password Reset failed (%d): %v", status, err)
respondBool(status, false, gc)
resp, err := app.jf.ResetPassword(req.PIN)
if err != nil || !resp.Success {
app.err.Printf(lm.FailedChangePassword, lm.Jellyfin, userID, err)
respondBool(500, false, gc)
return
}
if req.Password == "" || len(resp.UsersReset) == 0 {
@@ -168,15 +162,14 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
}
var user mediabrowser.User
var status int
var err error
if isInternal {
user, status, err = app.jf.UserByID(userID, false)
user, err = app.jf.UserByID(userID, false)
} else {
user, status, err = app.jf.UserByName(username, false)
user, err = app.jf.UserByName(username, false)
}
if status != 200 || err != nil {
app.err.Printf("Failed to get user \"%s\" (%d): %v", username, status, err)
if err != nil {
app.err.Printf(lm.FailedGetUser, userID, lm.Jellyfin, err)
respondBool(500, false, gc)
return
}
@@ -193,199 +186,126 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
if isInternal {
prevPassword = ""
}
status, err = app.jf.SetPassword(user.ID, prevPassword, req.Password)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to change password for \"%s\" (%d): %v", username, status, err)
err = app.jf.SetPassword(user.ID, prevPassword, req.Password)
if err != nil {
app.err.Printf(lm.FailedChangePassword, lm.Jellyfin, user.ID, err)
respondBool(500, false, gc)
return
}
if app.config.Section("ombi").Key("enabled").MustBool(false) {
// Silently fail for changing ombi passwords
// This makes no sense so has been commented out.
// It probably did at some point in the past.
/* Silently fail for changing ombi passwords
if (status != 200 && status != 204) || err != nil {
app.err.Printf("Failed to get user \"%s\" from jellyfin/emby (%d): %v", username, status, err)
app.err.Printf(lm.FailedGetUser, user.ID, lm.Jellyfin, err)
respondBool(200, true, gc)
return
}
ombiUser, status, err := app.getOmbiUser(user.ID)
if status != 200 || err != nil {
app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", username, status, err)
} */
ombiUser, err := app.getOmbiUser(user.ID)
if err != nil {
app.err.Printf(lm.FailedGetUser, user.ID, lm.Ombi, err)
respondBool(200, true, gc)
return
}
ombiUser["password"] = req.Password
status, err = app.ombi.ModifyUser(ombiUser)
if status != 200 || err != nil {
app.err.Printf("Failed to set password for ombi user \"%s\" (%d): %v", ombiUser["userName"], status, err)
err = app.ombi.ModifyUser(ombiUser)
if err != nil {
app.err.Printf(lm.FailedChangePassword, lm.Ombi, user.ID, err)
respondBool(200, true, gc)
return
}
app.debug.Printf("Reset password for ombi user \"%s\"", ombiUser["userName"])
app.debug.Printf(lm.ChangePassword, lm.Ombi, user.ID)
}
respondBool(200, true, gc)
}
// @Summary Get jfa-go configuration.
// @Produce json
// @Success 200 {object} settings "Uses the same format as config-base.json"
// @Success 200 {object} common.Config "Uses the same format as config-base.json"
// @Router /config [get]
// @Security Bearer
// @tags Configuration
func (app *appContext) GetConfig(gc *gin.Context) {
app.info.Println("Config requested")
resp := app.configBase
// Load language options
formOptions := app.storage.lang.User.getOptions()
fl := resp.Sections["ui"].Settings["language-form"]
fl.Options = formOptions
fl.Value = app.config.Section("ui").Key("language-form").MustString("en-us")
pwrOptions := app.storage.lang.PasswordReset.getOptions()
pl := resp.Sections["password_resets"].Settings["language"]
pl.Options = pwrOptions
pl.Value = app.config.Section("password_resets").Key("language").MustString("en-us")
adminOptions := app.storage.lang.Admin.getOptions()
al := resp.Sections["ui"].Settings["language-admin"]
al.Options = adminOptions
al.Value = app.config.Section("ui").Key("language-admin").MustString("en-us")
emailOptions := app.storage.lang.Email.getOptions()
el := resp.Sections["email"].Settings["language"]
el.Options = emailOptions
el.Value = app.config.Section("email").Key("language").MustString("en-us")
telegramOptions := app.storage.lang.Email.getOptions()
tl := resp.Sections["telegram"].Settings["language"]
tl.Options = telegramOptions
tl.Value = app.config.Section("telegram").Key("language").MustString("en-us")
if updater == "" {
delete(resp.Sections, "updates")
for i, v := range resp.Order {
if v == "updates" {
resp.Order = append(resp.Order[:i], resp.Order[i+1:]...)
break
}
}
}
if PLATFORM == "windows" {
delete(resp.Sections["smtp"].Settings, "ssl_cert")
for i, v := range resp.Sections["smtp"].Order {
if v == "ssl_cert" {
sect := resp.Sections["smtp"]
sect.Order = append(sect.Order[:i], sect.Order[i+1:]...)
resp.Sections["smtp"] = sect
}
}
}
if !MatrixE2EE() {
delete(resp.Sections["matrix"].Settings, "encryption")
for i, v := range resp.Sections["matrix"].Order {
if v == "encryption" {
sect := resp.Sections["matrix"]
sect.Order = append(sect.Order[:i], sect.Order[i+1:]...)
resp.Sections["matrix"] = sect
}
}
}
for sectName, section := range resp.Sections {
for settingName, setting := range section.Settings {
val := app.config.Section(sectName).Key(settingName)
s := resp.Sections[sectName].Settings[settingName]
switch setting.Type {
case "text", "email", "select", "password", "note":
s.Value = val.MustString("")
case "number":
s.Value = val.MustInt(0)
case "bool":
s.Value = val.MustBool(false)
}
resp.Sections[sectName].Settings[settingName] = s
}
}
if discordEnabled {
r, err := app.discord.ListRoles()
if err == nil {
roles := make([][2]string, len(r)+1)
roles[0] = [2]string{"", "None"}
for i, role := range r {
roles[i+1] = role
}
s := resp.Sections["discord"].Settings["apply_role"]
s.Options = roles
resp.Sections["discord"].Settings["apply_role"] = s
}
app.PatchConfigDiscordRoles()
}
resp.Sections["ui"].Settings["language-form"] = fl
resp.Sections["ui"].Settings["language-admin"] = al
resp.Sections["email"].Settings["language"] = el
resp.Sections["password_resets"].Settings["language"] = pl
resp.Sections["telegram"].Settings["language"] = tl
resp.Sections["discord"].Settings["language"] = tl
resp.Sections["matrix"].Settings["language"] = tl
// if setting := resp.Sections["invite_emails"].Settings["url_base"]; setting.Value == "" {
// setting.Value = strings.TrimSuffix(resp.Sections["password_resets"].Settings["url_base"].Value.(string), "/invite")
// resp.Sections["invite_emails"].Settings["url_base"] = setting
// }
// if setting := resp.Sections["password_resets"].Settings["url_base"]; setting.Value == "" {
// setting.Value = strings.TrimSuffix(resp.Sections["invite_emails"].Settings["url_base"].Value.(string), "/invite")
// resp.Sections["password_resets"].Settings["url_base"] = setting
// }
gc.JSON(200, resp)
gc.JSON(200, app.patchedConfig)
}
// @Summary Modify app config.
// @Produce json
// @Param appConfig body configDTO true "Config split into sections as in config.ini, all values as strings."
// @Param appConfig body configDTO true "Config split into sections as in config.ini, all values as strings (lists split with | delimiter)."
// @Success 200 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Router /config [post]
// @Security Bearer
// @tags Configuration
func (app *appContext) ModifyConfig(gc *gin.Context) {
app.info.Println("Config modification requested")
var req configDTO
gc.BindJSON(&req)
// Load a new config, as we set various default values in app.config that shouldn't be stored.
tempConfig, _ := ini.Load(app.configPath)
for section, settings := range req {
if section != "restart-program" {
_, err := tempConfig.GetSection(section)
tempConfig, _ := ini.ShadowLoad(app.configPath)
for _, section := range app.configBase.Sections {
ns, ok := req[section.Section]
if !ok {
continue
}
newSection := ns.(map[string]any)
iniSection, err := tempConfig.GetSection(section.Section)
if err != nil {
iniSection, err = tempConfig.NewSection(section.Section)
if err != nil {
tempConfig.NewSection(section)
app.err.Printf(lm.FailedModifyConfig, app.configPath, err)
respond(500, err.Error(), gc)
return
}
for setting, value := range settings.(map[string]interface{}) {
if section == "email" && setting == "method" && value == "disabled" {
value = ""
}
if (section == "discord" || section == "matrix") && setting == "language" {
tempConfig.Section("telegram").Key("language").SetValue(value.(string))
} else if value.(string) != app.config.Section(section).Key(setting).MustString("") {
tempConfig.Section(section).Key(setting).SetValue(value.(string))
}
for _, setting := range section.Settings {
newValue, ok := newSection[setting.Setting]
if !ok {
continue
}
// Patch disabled to actually be an empty string
if section.Section == "email" && setting.Setting == "method" && newValue == "disabled" {
newValue = ""
}
// Copy language preference for chatbots to root one in "telegram"
if (section.Section == "discord" || section.Section == "matrix") && setting.Setting == "language" {
iniSection.Key("language").SetValue(newValue.(string))
} else if setting.Type == common.ListType {
splitValues := strings.Split(newValue.(string), "|")
// Delete the key first to get rid of any shadow values
iniSection.DeleteKey(setting.Setting)
for i, v := range splitValues {
if i == 0 {
iniSection.Key(setting.Setting).SetValue(v)
} else {
iniSection.Key(setting.Setting).AddShadow(v)
}
}
} else if newValue.(string) != iniSection.Key(setting.Setting).MustString("") {
iniSection.Key(setting.Setting).SetValue(newValue.(string))
}
}
}
tempConfig.Section("").Key("first_run").SetValue("false")
if err := tempConfig.SaveTo(app.configPath); err != nil {
app.err.Printf("Failed to save config to \"%s\": %v", app.configPath, err)
app.err.Printf(lm.FailedWriting, app.configPath, err)
respond(500, err.Error(), gc)
return
}
app.debug.Println("Config saved")
app.info.Printf(lm.ModifyConfig, app.configPath)
gc.JSON(200, map[string]bool{"success": true})
if req["restart-program"] != nil && req["restart-program"].(bool) {
app.info.Println("Restarting...")
if TRAY {
TRAYRESTART <- true
} else {
RESTART <- true
}
// Safety Sleep (Ensure shutdown tasks get done)
time.Sleep(time.Second)
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.
if _, ok := req["password_validation"]; ok {
app.debug.Println("Reinitializing validator")
validatorConf := ValidatorConf{
"length": app.config.Section("password_validation").Key("min_length").MustInt(0),
"uppercase": app.config.Section("password_validation").Key("upper").MustInt(0),
@@ -425,12 +345,13 @@ func (app *appContext) CheckUpdate(gc *gin.Context) {
// @tags Configuration
func (app *appContext) ApplyUpdate(gc *gin.Context) {
if !app.update.CanUpdate {
respond(400, "Update is manual", gc)
app.info.Printf(lm.FailedApplyUpdate, lm.UpdateManual)
respond(400, lm.UpdateManual, gc)
return
}
err := app.update.update()
if err != nil {
app.err.Printf("Failed to apply update: %v", err)
app.err.Printf(lm.FailedApplyUpdate, err)
respondBool(500, false, gc)
return
}
@@ -452,8 +373,9 @@ func (app *appContext) ApplyUpdate(gc *gin.Context) {
func (app *appContext) Logout(gc *gin.Context) {
cookie, err := gc.Cookie("refresh")
if err != nil {
app.debug.Printf("Couldn't get cookies: %s", err)
respond(500, "Couldn't fetch cookies", gc)
msg := fmt.Sprintf(lm.FailedGetCookies, "refresh", err)
app.debug.Println(msg)
respond(500, msg, gc)
return
}
app.invalidTokens = append(app.invalidTokens, cookie)
@@ -526,11 +448,7 @@ func (app *appContext) ServeLang(gc *gin.Context) {
// @Security Bearer
// @tags Other
func (app *appContext) restart(gc *gin.Context) {
app.info.Println("Restarting...")
err := app.Restart()
if err != nil {
app.err.Printf("Couldn't restart, try restarting manually: %v", err)
}
app.Restart()
}
// @Summary Returns the last 100 lines of the log.
@@ -544,6 +462,7 @@ func (app *appContext) GetLog(gc *gin.Context) {
// no need to syscall.exec anymore!
func (app *appContext) Restart() error {
app.info.Println(lm.Restarting)
if TRAY {
TRAYRESTART <- true
} else {

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

136
auth.go
View File

@@ -2,6 +2,7 @@ package main
import (
"encoding/base64"
"errors"
"fmt"
"os"
"strings"
@@ -9,6 +10,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
)
@@ -38,9 +40,15 @@ 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) }
// CreateToken returns a web token as well as a refresh token, which can be used to obtain new tokens.
func CreateToken(userId, jfId string, admin bool) (string, string, error) {
var token, refresh string
@@ -72,32 +80,26 @@ func (app *appContext) decodeValidateAuthHeader(gc *gin.Context) (claims jwt.Map
ok = false
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
if header[0] != "Bearer" {
app.debug.Println("Invalid authorization header")
app.authLog(lm.InvalidAuthHeader)
respond(401, "Unauthorized", gc)
return
}
token, err := jwt.Parse(string(header[1]), checkToken)
if err != nil {
app.debug.Printf("Auth denied: %s", err)
app.authLog(fmt.Sprintf(lm.FailedParseJWT, err))
respond(401, "Unauthorized", gc)
return
}
claims, ok = token.Claims.(jwt.MapClaims)
if !ok {
app.debug.Println("Invalid JWT")
app.authLog(lm.FailedCastJWT)
respond(401, "Unauthorized", gc)
return
}
expiryUnix := int64(claims["exp"].(float64))
if err != nil {
app.debug.Printf("Auth denied: %s", err)
respond(401, "Unauthorized", gc)
ok = false
return
}
expiry := time.Unix(expiryUnix, 0)
if !(ok && token.Valid && claims["type"].(string) == "bearer" && expiry.After(time.Now())) {
app.debug.Printf("Auth denied: Invalid token")
app.authLog(lm.InvalidJWT)
// app.debug.Printf("Expiry: %+v, OK: %t, Valid: %t, ClaimType: %s\n", expiry, ok, token.Valid, claims["type"].(string))
respond(401, "Unauthorized", gc)
ok = false
@@ -115,7 +117,7 @@ func (app *appContext) authenticate(gc *gin.Context) {
}
isAdminToken := claims["admin"].(bool)
if !isAdminToken {
app.debug.Printf("Auth denied: Token was not for admin access")
app.authLog(lm.NonAdminToken)
respond(401, "Unauthorized", gc)
return
}
@@ -130,14 +132,20 @@ func (app *appContext) authenticate(gc *gin.Context) {
}
}
if !match {
app.debug.Printf("Couldn't find user ID \"%s\"", userID)
app.authLog(fmt.Sprintf(lm.NonAdminUser, userID))
respond(401, "Unauthorized", gc)
return
}
gc.Set("jfId", jfID)
gc.Set("userId", userID)
gc.Set("userMode", false)
app.debug.Println("Auth succeeded")
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()
}
@@ -160,7 +168,7 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool)
password = creds[1]
ok = false
if username == "" || password == "" {
app.logIpDebug(gc, userpage, "Auth denied: blank username/password")
app.logIpDebug(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.EmptyUserOrPass))
respond(401, "Unauthorized", gc)
return
}
@@ -168,21 +176,45 @@ 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, status, err := app.authJf.Authenticate(username, password)
if status != 200 || err != nil {
if status == 401 || status == 400 {
app.logIpInfo(gc, userpage, "Auth denied: Invalid username/password (Jellyfin)")
user, err := app.authJf.Authenticate(username, password)
if err != nil {
if errors.As(err, &mediabrowser.ErrUnauthorized{}) {
app.logIpInfo(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.InvalidUserOrPass))
respond(401, "Unauthorized", gc)
return
}
if status == 403 {
app.logIpInfo(gc, userpage, "Auth denied: Jellyfin account disabled")
} else if errors.As(err, &mediabrowser.ErrForbidden{}) {
app.logIpInfo(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.UserDisabled))
respond(403, "yourAccountWasDisabled", gc)
return
}
app.err.Printf("Auth failed: Couldn't authenticate with Jellyfin (%d/%s)", status, err)
app.authLog(fmt.Sprintf(lm.FailedAuthJellyfin, app.jf.Server, 0, err))
respond(500, "Jellyfin error", gc)
return
}
@@ -199,7 +231,7 @@ func (app *appContext) validateJellyfinCredentials(username, password string, gc
// @tags Auth
// @Security getTokenAuth
func (app *appContext) getTokenLogin(gc *gin.Context) {
app.logIpInfo(gc, false, "Token requested (login attempt)")
app.logIpInfo(gc, false, fmt.Sprintf(lm.RequestingToken, lm.TokenLoginAttempt))
username, password, ok := app.decodeValidateLoginHeader(gc, false)
if !ok {
return
@@ -209,13 +241,12 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
for _, user := range app.adminUsers {
if user.Username == username && user.Password == password {
match = true
app.debug.Println("Found existing user")
userID = user.UserID
break
}
}
if !app.jellyfinLogin && !match {
app.logIpInfo(gc, false, "Auth denied: Invalid username/password")
app.logIpInfo(gc, false, fmt.Sprintf(lm.FailedAuthRequest, lm.InvalidUserOrPass))
respond(401, "Unauthorized", gc)
return
}
@@ -225,34 +256,30 @@ 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.debug.Printf("Auth denied: Users \"%s\" isn't admin", 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()
newUser := User{
UserID: userID,
}
app.debug.Printf("Token generated for user \"%s\"", username)
app.debug.Printf(lm.GenerateToken, username)
app.adminUsers = append(app.adminUsers, newUser)
}
token, refresh, err := CreateToken(userID, jfID, true)
if err != nil {
app.err.Printf("getToken failed: Couldn't generate token (%s)", err)
app.err.Printf(lm.FailedGenerateToken, err)
respond(500, "Couldn't generate token", gc)
return
}
host := gc.Request.URL.Hostname()
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)
gc.JSON(200, getTokenDTO{token})
}
@@ -261,35 +288,29 @@ func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName s
ok = false
cookie, err := gc.Cookie(cookieName)
if err != nil || cookie == "" {
app.debug.Printf("getTokenRefresh denied: Couldn't get token: %s", err)
app.authLog(fmt.Sprintf(lm.FailedGetCookies, cookieName, err))
respond(400, "Couldn't get token", gc)
return
}
for _, token := range app.invalidTokens {
if cookie == token {
app.debug.Println("getTokenRefresh: Invalid token")
respond(401, "Invalid token", gc)
app.authLog(lm.LocallyInvalidatedJWT)
respond(401, lm.InvalidJWT, gc)
return
}
}
token, err := jwt.Parse(cookie, checkToken)
if err != nil {
app.debug.Println("getTokenRefresh: Invalid token")
respond(400, "Invalid token", gc)
app.authLog(fmt.Sprintf(lm.FailedParseJWT, err))
respond(400, lm.InvalidJWT, gc)
return
}
claims, ok = token.Claims.(jwt.MapClaims)
expiryUnix := int64(claims["exp"].(float64))
if err != nil {
app.debug.Printf("getTokenRefresh: Invalid token expiry: %s", err)
respond(401, "Invalid token", gc)
ok = false
return
}
expiry := time.Unix(expiryUnix, 0)
if !(ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now())) {
app.debug.Printf("getTokenRefresh: Invalid token: %+v", err)
respond(401, "Invalid token", gc)
app.authLog(lm.InvalidJWT)
respond(401, lm.InvalidJWT, gc)
ok = false
return
}
@@ -304,7 +325,7 @@ func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName s
// @Router /token/refresh [get]
// @tags Auth
func (app *appContext) getTokenRefresh(gc *gin.Context) {
app.logIpInfo(gc, false, "Token requested (refresh token)")
app.logIpInfo(gc, false, fmt.Sprintf(lm.RequestingToken, lm.TokenRefresh))
claims, ok := app.decodeValidateRefreshCookie(gc, "refresh")
if !ok {
return
@@ -313,11 +334,12 @@ func (app *appContext) getTokenRefresh(gc *gin.Context) {
jfID := claims["jfid"].(string)
jwt, refresh, err := CreateToken(userID, jfID, true)
if err != nil {
app.err.Printf("getTokenRefresh failed: Couldn't generate token (%s)", err)
app.err.Printf(lm.FailedGenerateToken, err)
respond(500, "Couldn't generate token", gc)
return
}
host := gc.Request.URL.Hostname()
// host := gc.Request.URL.Hostname()
host := app.ExternalDomainNoPort(gc)
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
gc.JSON(200, getTokenDTO{jwt})
}

View File

@@ -5,40 +5,135 @@ import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
lm "github.com/hrfee/jfa-go/logmessages"
)
const (
BACKUP_PREFIX = "jfa-go-db-"
BACKUP_PREFIX = "jfa-go-db"
BACKUP_PREFIX_OLD = "jfa-go-db-"
BACKUP_COMMIT_PREFIX = "-c-"
BACKUP_DATE_PREFIX = "-d-"
BACKUP_UPLOAD_PREFIX = "upload-"
BACKUP_DATEFMT = "2006-01-02T15-04-05"
BACKUP_SUFFIX = ".bak"
)
type Backup struct {
Date time.Time
Commit string
Upload bool
}
func (b Backup) IsZero() bool { return b.Date.IsZero() && b.Commit == "" && b.Upload == false }
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_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 {
t := b.Date
if t.IsZero() {
t = time.Now()
}
out := BACKUP_PREFIX
if b.Upload {
out = BACKUP_UPLOAD_PREFIX + out
}
if b.Commit != "" {
out += BACKUP_COMMIT_PREFIX + b.Commit
}
out += BACKUP_DATE_PREFIX + t.Local().Format(BACKUP_DATEFMT) + BACKUP_SUFFIX
return out
}
func (b *Backup) FromString(f string) error {
of := f
if strings.HasPrefix(f, BACKUP_UPLOAD_PREFIX) {
b.Upload = true
f = f[len(BACKUP_UPLOAD_PREFIX):]
}
if !strings.HasPrefix(f, BACKUP_PREFIX) {
return fmt.Errorf("file doesn't have correct prefix (\"%s\")", BACKUP_PREFIX)
}
f = f[len(BACKUP_PREFIX):]
if !strings.HasSuffix(f, BACKUP_SUFFIX) {
return fmt.Errorf("file doesn't have correct suffix (\"%s\")", BACKUP_SUFFIX)
}
for range 2 {
if strings.HasPrefix(f, BACKUP_COMMIT_PREFIX) {
f = f[len(BACKUP_COMMIT_PREFIX):]
commitEnd := strings.Index(f, BACKUP_DATE_PREFIX)
if commitEnd == -1 {
commitEnd = strings.Index(f, BACKUP_SUFFIX)
}
if commitEnd == -1 {
return fmt.Errorf("end of commit (\"%s\" or \"%s\") not found in \"%s\"", BACKUP_DATE_PREFIX, BACKUP_PREFIX, f)
}
b.Commit = f[:commitEnd]
f = f[commitEnd:]
} else if strings.HasPrefix(f, BACKUP_DATE_PREFIX) {
f = f[len(BACKUP_DATE_PREFIX):]
dateEnd := strings.Index(f, BACKUP_COMMIT_PREFIX)
if dateEnd == -1 {
dateEnd = strings.Index(f, BACKUP_SUFFIX)
}
if dateEnd == -1 {
return fmt.Errorf("end of date (\"%s\" or \"%s\") not found in \"%s\"", BACKUP_COMMIT_PREFIX, BACKUP_PREFIX, f)
}
t, err := time.Parse(BACKUP_DATEFMT, f[:dateEnd])
if err != nil {
return err
}
b.Date = t
f = f[dateEnd:]
}
}
if b.Date.IsZero() {
return b.FromOldString(of)
}
return nil
}
func (b *Backup) FromOldString(f string) error {
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(f, BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX+"-"), BACKUP_SUFFIX))
if err != nil {
return fmt.Errorf(lm.FailedParseTime, err)
}
b.Date = t
return nil
}
type BackupList struct {
files []os.DirEntry
dates []time.Time
info []Backup
count int
}
func (bl BackupList) Len() int { return len(bl.files) }
func (bl BackupList) Swap(i, j int) {
bl.files[i], bl.files[j] = bl.files[j], bl.files[i]
bl.dates[i], bl.dates[j] = bl.dates[j], bl.dates[i]
bl.info[i], bl.info[j] = bl.info[j], bl.info[i]
}
func (bl BackupList) Less(i, j int) bool {
// Push non-backup files to the end of the array,
// Since they didn't have a date parsed.
if bl.dates[i].IsZero() {
if bl.info[i].Date.IsZero() {
return false
}
if bl.dates[j].IsZero() {
if bl.info[j].Date.IsZero() {
return true
}
// Sort by oldest first
return bl.dates[j].After(bl.dates[i])
return bl.info[j].Date.After(bl.info[i].Date)
}
// Get human-readable file size from f.Size() result.
@@ -60,28 +155,29 @@ func (app *appContext) getBackups() *BackupList {
path := app.config.Section("backups").Key("path").String()
err := os.MkdirAll(path, 0755)
if err != nil {
app.err.Printf("Failed to create backup directory \"%s\": %v\n", path, err)
app.err.Printf(lm.FailedCreateDir, path, err)
return nil
}
items, err := os.ReadDir(path)
if err != nil {
app.err.Printf("Failed to read backup directory \"%s\": %v\n", path, err)
app.err.Printf(lm.FailedReading, path, err)
return nil
}
backups := &BackupList{}
backups.files = items
backups.dates = make([]time.Time, len(items))
backups.info = make([]Backup, len(items))
backups.count = 0
for i, item := range items {
// Even though Backup{} can parse and check validity, still check if the file ends in .bak, we don't need to print an error if a file isn't a .bak.
if item.IsDir() || !(strings.HasSuffix(item.Name(), BACKUP_SUFFIX)) {
continue
}
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(item.Name(), BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX), BACKUP_SUFFIX))
if err != nil {
app.debug.Printf("Failed to parse backup filename \"%s\": %v\n", item.Name(), err)
b := Backup{}
if err := b.FromString(item.Name()); err != nil {
app.debug.Printf(lm.FailedParseBackup, item.Name(), err)
continue
}
backups.dates[i] = t
backups.info[i] = b
backups.count++
}
return backups
@@ -89,48 +185,81 @@ func (app *appContext) getBackups() *BackupList {
func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) {
toKeep := app.config.Section("backups").Key("keep_n_backups").MustInt(20)
fname := BACKUP_PREFIX + time.Now().Local().Format(BACKUP_DATEFMT) + BACKUP_SUFFIX
keepPreviousVersions := app.config.Section("backups").Key("keep_previous_version_backup").MustBool(true)
b := Backup{Commit: commit}
fname := b.String()
path := app.config.Section("backups").Key("path").String()
backups := app.getBackups()
if backups == nil {
return
}
toDelete := backups.count + 1 - toKeep
if toDelete > 0 || keepPreviousVersions {
sort.Sort(backups)
}
backupsByCommit := map[string]int{}
if keepPreviousVersions {
// Count backups by commit
for _, b := range backups.info {
if b.IsZero() {
continue
}
// If b.Commit is empty, the backup is pre-versions-in-backup-names.
// Still use the empty string as a key, considering these as a single version.
count, ok := backupsByCommit[b.Commit]
if !ok {
count = 0
}
count += 1
backupsByCommit[b.Commit] = count
}
}
// fmt.Printf("toDelete: %d, backCount: %d, keep: %d, length: %d\n", toDelete, backups.count, toKeep, len(backups.files))
if toDelete > 0 && toDelete <= backups.count {
sort.Sort(backups)
for _, item := range backups.files[:toDelete] {
for i := range toDelete {
backupsRemaining, ok := backupsByCommit[backups.info[i].Commit]
app.debug.Println("item", backups.files[i], "remaining", backupsRemaining)
if keepPreviousVersions && ok && backupsRemaining <= 1 {
continue
}
item := backups.files[i]
fullpath := filepath.Join(path, item.Name())
app.debug.Printf("Deleting old backup \"%s\"\n", item.Name())
err := os.Remove(fullpath)
if err != nil {
app.err.Printf("Failed to delete old backup \"%s\": %v\n", fullpath, err)
app.err.Printf(lm.FailedDeleteOldBackup, fullpath, err)
return
}
app.debug.Printf(lm.DeleteOldBackup, fullpath)
if keepPreviousVersions && ok {
backupsRemaining -= 1
backupsByCommit[backups.info[i].Commit] = backupsRemaining
}
}
}
fullpath := filepath.Join(path, fname)
f, err := os.Create(fullpath)
if err != nil {
app.err.Printf("Failed to open backup file \"%s\": %v\n", fullpath, err)
app.err.Printf(lm.FailedOpen, fullpath, err)
return
}
defer f.Close()
_, err = app.storage.db.Badger().Backup(f, 0)
if err != nil {
app.err.Printf("Failed to create backup: %v\n", err)
app.err.Printf(lm.FailedCreateBackup, err)
return
}
fstat, err := f.Stat()
if err != nil {
app.err.Printf("Failed to get info on new backup: %v\n", err)
app.err.Printf(lm.FailedStat, fullpath, err)
return
}
fileDetails.Size = fileSize(fstat.Size())
fileDetails.Name = fname
fileDetails.Path = fullpath
// fmt.Printf("Created backup %+v\n", fileDetails)
app.debug.Printf(lm.CreateBackup, fileDetails)
return
}
@@ -138,26 +267,28 @@ func (app *appContext) loadPendingBackup() {
if LOADBAK == "" {
return
}
oldPath := filepath.Join(app.dataPath, "db-"+string(time.Now().Unix())+"-pre-"+filepath.Base(LOADBAK))
app.info.Printf("Moving existing database to \"%s\"\n", oldPath)
oldPath := filepath.Join(app.dataPath, "db-"+strconv.FormatInt(time.Now().Unix(), 10)+"-pre-"+filepath.Base(LOADBAK))
err := os.Rename(app.storage.db_path, oldPath)
if err != nil {
app.err.Fatalf("Failed to move existing database: %v\n", err)
app.err.Fatalf(lm.FailedMoveOldDB, oldPath, err)
}
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 {
app.err.Fatalf("Failed to open backup file \"%s\": %v\n", LOADBAK, err)
app.err.Fatalf(lm.FailedOpen, LOADBAK, err)
}
err = app.storage.db.Badger().Load(f, 256)
f.Close()
if err != nil {
app.err.Fatalf("Failed to restore backup file \"%s\": %v\n", LOADBAK, err)
app.err.Fatalf(lm.FailedRestoreDB, LOADBAK, err)
}
app.info.Printf("Restored backup \"%s\".", LOADBAK)
app.info.Printf(lm.RestoreDB, LOADBAK)
LOADBAK = ""
}
@@ -165,9 +296,9 @@ func newBackupDaemon(app *appContext) *GenericDaemon {
interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute
d := NewGenericDaemon(interval, app,
func(app *appContext) {
app.debug.Println("Backups: Creating backup")
app.makeBackup()
},
)
d.Name("Backup")
return d
}

57
backups_test.go Normal file
View File

@@ -0,0 +1,57 @@
package main
import (
"testing"
"time"
)
func testBackupParse(f string, a Backup, t *testing.T) {
b := Backup{}
err := b.FromString(f)
if err != nil {
t.Fatalf("error: %+v", err)
}
if !b.Equals(a) {
t.Fatalf("not equal: %+v != %+v", b, a)
}
}
func TestBackupParserOld(t *testing.T) {
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_OLD + "2023-12-21T21-08-00" + BACKUP_SUFFIX
A2 := Backup{
Upload: true,
}
A2.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
testBackupParse(Q2, A2, t)
}
func TestBackupParserUploadDate(t *testing.T) {
Q3 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX + BACKUP_DATE_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX
A3 := Backup{
Upload: true,
}
A3.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
testBackupParse(Q3, A3, t)
}
func TestBackupParserUploadCommitDate(t *testing.T) {
Q4 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX + BACKUP_COMMIT_PREFIX + "testcommit" + BACKUP_DATE_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX
A4 := Backup{
Commit: "testcommit",
Upload: true,
}
A4.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
testBackupParse(Q4, A4, t)
}
func TestBackupParserDateCommit(t *testing.T) {
Q5 := BACKUP_PREFIX + BACKUP_DATE_PREFIX + "2023-12-21T21-08-00" + BACKUP_COMMIT_PREFIX + "testcommit" + BACKUP_SUFFIX
A5 := Backup{
Commit: "testcommit",
}
A5.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
testBackupParse(Q5, A5, t)
}

View File

@@ -1,8 +1,19 @@
package common
import (
"bytes"
"compress/gzip"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
lm "github.com/hrfee/jfa-go/logmessages"
)
// TimeoutHandler recovers from an http timeout or panic.
@@ -12,7 +23,7 @@ type TimeoutHandler func()
func NewTimeoutHandler(name, addr string, noFail bool) TimeoutHandler {
return func() {
if r := recover(); r != nil {
out := fmt.Sprintf("Failed to authenticate with %s @ \"%s\": Timed out", name, addr)
out := fmt.Sprintf(lm.FailedAuth, name, addr, 0, lm.TimedOut)
if noFail {
log.Print(out)
} else {
@@ -21,3 +32,135 @@ func NewTimeoutHandler(name, addr string, noFail bool) TimeoutHandler {
}
}
}
// most 404 errors are from UserNotFound, so this generic error doesn't really need any detail.
type ErrNotFound error
type ErrUnauthorized struct{}
func (err ErrUnauthorized) Error() string {
return lm.Unauthorized
}
type ErrForbidden struct{}
func (err ErrForbidden) Error() string {
return lm.Forbidden
}
var (
NotFound ErrNotFound = errors.New(lm.NotFound)
)
type ErrUnknown struct {
code int
}
func (err ErrUnknown) Error() string {
msg := fmt.Sprintf(lm.FailedGenericWithCode, err.code)
return msg
}
// GenericErr returns an error appropriate to the given HTTP status (or actual error, if given).
func GenericErr(status int, err error) error {
if err != nil {
return err
}
switch status {
case 200, 204, 201:
return nil
case 401, 400:
return ErrUnauthorized{}
case 404:
return NotFound
case 403:
return ErrForbidden{}
default:
return ErrUnknown{code: status}
}
}
func GenericErrFromResponse(resp *http.Response, err error) error {
if resp == nil {
return ErrUnknown{code: -2}
}
return GenericErr(resp.StatusCode, err)
}
type ConfigurableTransport interface {
// SetTransport sets the http.Transport to use for requests. Can be used to set a proxy.
SetTransport(t *http.Transport)
}
// Stripped down-ish version of rough http request function used in most of the API clients.
func Req(httpClient *http.Client, timeoutHandler TimeoutHandler, mode string, uri string, data any, queryParams url.Values, headers map[string]string, response bool) (string, int, error) {
var params []byte
if data != nil {
params, _ = json.Marshal(data)
}
if qp := queryParams.Encode(); qp != "" {
uri += "?" + qp
}
var req *http.Request
if data != nil {
req, _ = http.NewRequest(mode, uri, bytes.NewBuffer(params))
} else {
req, _ = http.NewRequest(mode, uri, nil)
}
req.Header.Add("Content-Type", "application/json")
for name, value := range headers {
req.Header.Add(name, value)
}
resp, err := httpClient.Do(req)
if resp == nil {
return "", 0, err
}
err = GenericErr(resp.StatusCode, err)
if timeoutHandler != nil {
defer timeoutHandler()
}
var responseText string
defer resp.Body.Close()
if response || err != nil {
responseText, err = decodeResp(resp)
if err != nil {
return responseText, resp.StatusCode, err
}
}
if err != nil {
var msg any
err = json.Unmarshal([]byte(responseText), &msg)
if err != nil {
return responseText, resp.StatusCode, err
}
if msg != nil {
err = fmt.Errorf("got %d: %+v", resp.StatusCode, msg)
}
return responseText, resp.StatusCode, err
}
return responseText, resp.StatusCode, err
}
func decodeResp(resp *http.Response) (string, error) {
var out io.Reader
switch resp.Header.Get("Content-Encoding") {
case "gzip":
out, _ = gzip.NewReader(resp.Body)
default:
out = resp.Body
}
buf := new(strings.Builder)
_, err := io.Copy(buf, out)
if err != nil {
return "", err
}
return buf.String(), nil
}
// 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.
}

62
common/config.go Normal file
View File

@@ -0,0 +1,62 @@
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"`
}
type Option [2]string
type SettingType string
var (
BoolType SettingType = "bool"
SelectType SettingType = "select"
TextType SettingType = "text"
PasswordType SettingType = "password"
NumberType SettingType = "number"
NoteType SettingType = "note"
EmailType SettingType = "email"
ListType SettingType = "list"
)
type Setting struct {
Setting string `json:"setting" yaml:"setting" example:"my_setting"`
Name string `json:"name" yaml:"name" example:"My Setting"`
Description string `json:"description" yaml:"description"`
Required bool `json:"required" yaml:"required"`
RequiresRestart bool `json:"requires_restart" yaml:"requires_restart"`
Advanced bool `json:"advanced,omitempty" yaml:"advanced,omitempty"`
Type SettingType `json:"type" yaml:"type"` // Type (string, number, bool, etc.)
Value any `json:"value" yaml:"value"`
Options []Option `json:"options,omitempty" yaml:"options,omitempty"`
DependsTrue string `json:"depends_true,omitempty" yaml:"depends_true,omitempty"` // If specified, this field is enabled when the specified bool setting is enabled.
DependsFalse string `json:"depends_false,omitempty" yaml:"depends_false,omitempty"` // If specified, opposite behaviour of DependsTrue.
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"`
}
type Section struct {
Section string `json:"section" yaml:"section" example:"my_section"`
Meta SectionMeta `json:"meta" yaml:"meta"`
Settings []Setting `json:"settings" yaml:"settings"`
}
type Config struct {
Sections []Section `json:"sections" yaml:"sections"`
}
func (c *Config) removeSection(section string) {
for i, v := range c.Sections {
if v.Section == section {
c.Sections = append(c.Sections[:i], c.Sections[i+1:]...)
break
}
}
}

View File

@@ -1,3 +1,7 @@
module github.com/hrfee/jfa-go/common
go 1.15
replace github.com/hrfee/jfa-go/logmessages => ../logmessages
go 1.18
require github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a

541
config.go
View File

@@ -3,23 +3,40 @@ package main
import (
"fmt"
"io/fs"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"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
func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
val := app.config.Section(sect).Key(key).MustString("")
// 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 (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:")
}
@@ -27,135 +44,284 @@ 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) loadConfig() error {
var err error
app.config, err = ini.Load(app.configPath)
func (config *Config) MustSetURLPath(section, key, val string) {
if !strings.HasPrefix(val, "/") && val != "" {
val = "/" + val
}
config.MustSetValue(section, key, val)
}
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 == "/" {
if removeSingleSlash {
return ""
}
return path
}
return strings.TrimSuffix(path, "/")
}
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
}
app.MustSetValue("jellyfin", "public_server", app.config.Section("jellyfin").Key("server").String())
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.MustSetValue("ui", "redirect_url", app.config.Section("jellyfin").Key("public_server").String())
// app.debug.Printf("Request: %+v\n", gc.Request)
if UseProxyHost && gc.Request.Host != "" {
return proto + gc.Request.Host + PAGES.Base
}
return externalURI
}
for _, key := range app.config.Section("files").Keys() {
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
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" {
logs.err.Printf(lm.BadURLBase, PAGES.Base)
}
logs.info.Printf(lm.SubpathBlockMessage, PAGES.Base, PAGES.Admin, PAGES.MyAccount, PAGES.Form)
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.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/")
app.config.Section("email").Key("no_username").SetValue(strconv.FormatBool(app.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")
app.MustSetValue("invite_emails", "email_html", "jfa-go:"+"invite-email.html")
app.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")
app.MustSetValue("notifications", "expiry_html", "jfa-go:"+"expired.html")
app.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")
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("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)
// 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 externalURI == "" {
if UseProxyHost {
logs.err.Println(lm.NoExternalHost + lm.LoginWontSave + lm.SetExternalHostDespiteUseProxyHost)
} else {
logs.err.Println(lm.NoExternalHost + lm.LoginWontSave)
}
}
u, err := url.Parse(externalURI)
if err == nil {
externalDomain = u.Hostname()
}
config.Section("email").Key("no_username").SetValue(strconv.FormatBool(config.Section("email").Key("no_username").MustBool(false)))
// 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")
// config.MustSetValue("invite_emails", "email_html", "jfa-go:"+"invite-email.html")
// config.MustSetValue("invite_emails", "email_text", "jfa-go:"+"invite-email.txt")
// config.MustSetValue("email_confirmation", "email_html", "jfa-go:"+"confirmation.html")
// config.MustSetValue("email_confirmation", "email_text", "jfa-go:"+"confirmation.txt")
// config.MustSetValue("notifications", "expiry_html", "jfa-go:"+"expired.html")
// config.MustSetValue("notifications", "expiry_text", "jfa-go:"+"expired.txt")
// config.MustSetValue("notifications", "created_html", "jfa-go:"+"created.html")
// config.MustSetValue("notifications", "created_text", "jfa-go:"+"created.txt")
// 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("matrix", "topic", "Jellyfin notifications")
app.MustSetValue("matrix", "show_on_reg", "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("discord", "show_on_reg", "true")
fnameSettingSuffix := []string{"html", "text"}
fnameExtension := []string{"html", "txt"}
app.MustSetValue("telegram", "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("backups", "every_n_minutes", "1440")
app.MustSetValue("backups", "path", filepath.Join(app.dataPath, "backups"))
app.MustSetValue("backups", "keep_n_backups", "20")
config.MustSetValue("smtp", "hello_hostname", "localhost")
config.MustSetValue("smtp", "cert_validation", "true")
config.MustSetValue("smtp", "auth_type", "4")
config.MustSetValue("smtp", "port", "465")
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))
config.MustSetValue("activity_log", "keep_n_records", "1000")
config.MustSetValue("activity_log", "delete_after_days", "90")
LOGIP = app.config.Section("advanced").Key("log_ips").MustBool(false)
LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false)
sc := config.Section("discord").Key("start_command").MustString("start")
config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
// These two settings are pretty much the same
url1 := app.config.Section("invite_emails").Key("url_base").String()
url2 := app.config.Section("password_resets").Key("url_base").String()
app.MustSetValue("password_resets", "url_base", strings.TrimSuffix(url1, "/invite"))
app.MustSetValue("invite_emails", "url_base", url2)
config.MustSetValue("email", "collect", "true")
config.MustSetValue("matrix", "topic", "Jellyfin notifications")
config.MustSetValue("matrix", "show_on_reg", "true")
config.MustSetValue("discord", "show_on_reg", "true")
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 {
fmt.Println("SETALLTRUE")
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
@@ -164,25 +330,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("Failed to initialize Proxy: %v\n", 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)
config.proxyConfig = nil
config.proxyTransport = nil
} else {
logs.info.Printf(lm.InitProxy, config.proxyConfig.Addr)
}
app.proxyEnabled = true
}
app.MustSetValue("updates", "enabled", "true")
releaseChannel := app.config.Section("updates").Key("channel").String()
if app.config.Section("updates").Key("enabled").MustBool(false) {
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" {
@@ -191,9 +396,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 == "" {
@@ -202,30 +407,114 @@ 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("")
if substituteStrings != "" {
v := app.config.Section("ui").Key("success_message")
v.SetValue(strings.ReplaceAll(v.String(), "Jellyfin", substituteStrings))
}
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.email = NewEmailer(config, app.storage, app.LoggerSet)
}
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)
}
app.config.ReloadDependents(app)
app.info.Printf(lm.LoadConfig, app.configPath)
}
func (app *appContext) PatchConfigBase() {
conf := app.configBase
// Load language options
formOptions := app.storage.lang.User.getOptions()
pwrOptions := app.storage.lang.PasswordReset.getOptions()
adminOptions := app.storage.lang.Admin.getOptions()
emailOptions := app.storage.lang.Email.getOptions()
telegramOptions := app.storage.lang.Email.getOptions()
for i, section := range app.configBase.Sections {
if section.Section == "updates" && updater == "" {
section.Meta.Disabled = true
}
for j, setting := range section.Settings {
if section.Section == "ui" {
if setting.Setting == "language-form" {
setting.Options = formOptions
setting.Value = "en-us"
} else if setting.Setting == "language-admin" {
setting.Options = adminOptions
setting.Value = "en-us"
}
} else if section.Section == "password_resets" {
if setting.Setting == "language" {
setting.Options = pwrOptions
setting.Value = "en-us"
}
} else if section.Section == "email" {
if setting.Setting == "language" {
setting.Options = emailOptions
setting.Value = "en-us"
}
} else if section.Section == "telegram" {
if setting.Setting == "language" {
setting.Options = telegramOptions
setting.Value = "en-us"
}
} else if section.Section == "smtp" {
if setting.Setting == "ssl_cert" && PLATFORM == "windows" {
// Not accurate but the effect is hiding the option, which we want.
setting.Deprecated = true
}
} else if section.Section == "matrix" {
if setting.Setting == "encryption" && !MatrixE2EE() {
// Not accurate but the effect is hiding the option, which we want.
setting.Deprecated = true
}
}
val := app.config.Section(section.Section).Key(setting.Setting)
switch setting.Type {
case "list":
setting.Value = val.StringsWithShadows("|")
case "text", "email", "select", "password", "note":
setting.Value = val.MustString("")
case "number":
setting.Value = val.MustInt(0)
case "bool":
setting.Value = val.MustBool(false)
}
section.Settings[j] = setting
}
conf.Sections[i] = section
}
app.patchedConfig = conf
}
func (app *appContext) PatchConfigDiscordRoles() {
if !discordEnabled {
return
}
r, err := app.discord.ListRoles()
if err != nil {
return
}
roles := make([]common.Option, len(r)+1)
roles[0] = common.Option{"", "None"}
for i, role := range r {
roles[i+1] = role
}
for i, section := range app.patchedConfig.Sections {
if section.Section != "discord" {
continue
}
for j, setting := range section.Settings {
if setting.Setting != "apply_role" {
continue
}
setting.Options = roles
section.Settings[j] = setting
}
app.patchedConfig.Sections[i] = section
}
}

View File

@@ -1,4 +0,0 @@
### fixconfig
Python's `json` library retains the order of data in a JSON file, which meant settings sent to the web page would be in the right order. Go's `encoding/json` and maps do not retain order, so `enumerate/enumerate_config.py` opens the json file, and for each section, adds an "order" array which tells the web page in which order to display settings.
Specify the input and output files with `-i` and `-o` respectively.

1
config/README.txt Normal file
View File

@@ -0,0 +1 @@
The two python scripts here, `config-json-to-new-yaml.py` and `gen-rough-schema.py` were used to convert the old format, which was stored in a JSON file, to the new format in YAML. The latter script is used to get the possible values for settings and sections, so they could be properly defined in common/config.go.

File diff suppressed because it is too large Load Diff

1688
config/config-base.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
from ruamel.yaml import YAML
import json
from pathlib import Path
import sys
yaml = YAML()
# c = yaml.load(Path(sys.argv[len(sys.argv)-1]))
with open(sys.argv[len(sys.argv)-1], 'r') as f:
c = json.load(f)
c.pop("order")
c1 = c.copy()
c1["sections"] = []
for section in c["sections"]:
codeSection = { "section": section }
s = codeSection | c["sections"][section]
s.pop("order")
c1["sections"].append(s)
c2 = c.copy()
c2["sections"] = []
for section in c1["sections"]:
sArray = []
for setting in section["settings"]:
codeSetting = { "setting": setting }
s = codeSetting | section["settings"][setting]
sArray.append(s)
section["settings"] = sArray
c2["sections"].append(section)
yaml.dump(c2, sys.stdout)

View File

@@ -0,0 +1,40 @@
import json
import sys
sectionSchema = {}
metaSchema = {}
settingSchema = {}
typeValues = {}
# c = yaml.load(Path(sys.argv[len(sys.argv)-1]))
with open(sys.argv[len(sys.argv)-1], 'r') as f:
c = json.load(f)
for section in c["sections"]:
for key in c["sections"][section]:
sectionSchema[key] = True
for key in c["sections"][section]["meta"]:
metaSchema[key] = c["sections"][section]["meta"][key]
for setting in c["sections"][section]["settings"]:
for field in c["sections"][section]["settings"][setting]:
settingSchema[field] = c["sections"][section]["settings"][setting][field]
typeValues[c["sections"][section]["settings"][setting]["type"]] = True
print("Section Content:")
for v in sectionSchema:
print(v)
print("---")
print("Meta Schema")
for v in metaSchema:
print(v, "=", type(metaSchema[v]))
print("---")
print("Setting Schema")
for v in settingSchema:
print(v, "=", type(settingSchema[v]))
print("---")
print("Possible Types")
for v in typeValues:
print(v)

View File

@@ -18,6 +18,8 @@
--bg-light: #fff;
--bg-dark: #101010;
color-scheme: light;
}
.light {
@@ -26,6 +28,7 @@
.dark {
--settings-section-button-filter: 80%;
color-scheme: dark !important;
}
.dark body {
@@ -62,18 +65,7 @@ html:not(.dark) .card.\@low:not(.\~neutral):not(.\~positive):not(.\~urge):not(.\
display: initial;
}
.page-container {
margin: 5% 20% 5% 20%;
}
@media (max-width: 1100px) {
.page-container {
margin: 2%;
margin-top: 5rem;
}
}
@media screen and (max-width: 1000px) {
@media screen and (max-width: 1024px) {
:root {
font-size: 0.9rem;
}
@@ -467,3 +459,30 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
margin-bottom: -0.5rem;
padding-bottom: 0.5rem;
}
section.section:not(.\~neutral) {
background-color: inherit;
}
@layer components {
.switch {
@apply flex flex-row gap-1 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

@@ -27,6 +27,12 @@
right: 0;
}
.tooltip.above .content {
bottom: 2.5rem;
left: 0;
right: 0;
}
.tooltip.darker .content {
background-color: rgba(0, 0, 0, 0.8);
}

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,30 +1,35 @@
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"
)
type DiscordDaemon struct {
Stopped bool
ShutdownChannel chan string
bot *dg.Session
username string
tokens map[string]VerifToken // Map of pins to tokens.
verifiedTokens map[string]DiscordUser // Map of token pins to discord users.
channelID, channelName, inviteChannelID, inviteChannelName string
guildID string
serverChannelName, serverName string
users map[string]DiscordUser // Map of user IDs to users. Added to on first interaction, and loaded from app.storage.discord on start.
roleID string
app *appContext
commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string)
commandIDs []string
commandDescriptions []*dg.ApplicationCommand
Stopped bool
ShutdownChannel chan string
bot *dg.Session
username string
tokens map[string]VerifToken // Map of pins to tokens.
verifiedTokens map[string]DiscordUser // Map of token pins to discord users.
Channel, InviteChannel struct{ ID, Name string }
guildID string
serverChannelName, serverName string
users map[string]DiscordUser // Map of user IDs to users. Added to on first interaction, and loaded from app.storage.discord on start.
roleID string
app *appContext
commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string)
commandIDs []string
commandDescriptions []*dg.ApplicationCommand
retryOpts *common.MustAuthenticateOptions
}
func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
@@ -56,9 +61,24 @@ 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
}
// SetTransport sets the http.Transport to use for requests. Can be used to set a proxy.
func (d *DiscordDaemon) SetTransport(t *http.Transport) {
d.bot.Client.Transport = t
}
// NewAuthToken generates an 8-character pin in the form "A1-2B-CD".
func (d *DiscordDaemon) NewAuthToken() string {
pin := genAuthToken()
@@ -91,15 +111,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.messageHandler)
func (d *DiscordDaemon) Run() {
ro := common.MustAuthenticateOptions{}
ro = *d.retryOpts
ro.Counter = 0
d.run(&ro)
}
d.bot.AddHandler(d.commandHandler)
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("Discord: Failed to start daemon: %v", 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 {
@@ -116,28 +148,31 @@ func (d *DiscordDaemon) run() {
d.guildID = d.bot.State.Guilds[len(d.bot.State.Guilds)-1].ID
guild, err := d.bot.Guild(d.guildID)
if err != nil {
d.app.err.Printf("Discord: Failed to get guild: %v", err)
d.app.err.Printf(lm.FailedGetDiscordGuild, err)
}
d.serverChannelName = guild.Name
d.serverName = guild.Name
if channel := d.app.config.Section("discord").Key("channel").String(); channel != "" {
d.channelName = channel
d.Channel.Name = channel
d.serverChannelName += "/" + channel
}
if d.app.config.Section("discord").Key("provide_invite").MustBool(false) {
if invChannel := d.app.config.Section("discord").Key("invite_channel").String(); invChannel != "" {
d.inviteChannelName = invChannel
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.
@@ -145,7 +180,7 @@ func (d *DiscordDaemon) ListRoles() (roles [][2]string, err error) {
var r []*dg.Role
r, err = d.bot.GuildRoles(d.guildID)
if err != nil {
d.app.err.Printf("Discord: Failed to get roles: %v", err)
d.app.err.Printf(lm.FailedGetDiscordRoles, err)
return
}
for _, role := range r {
@@ -168,44 +203,62 @@ func (d *DiscordDaemon) ApplyRole(userID string) error {
return d.bot.GuildMemberRoleAdd(d.guildID, userID, d.roleID)
}
// RemoveRole removes the member role to the given user if set.
func (d *DiscordDaemon) RemoveRole(userID string) error {
if d.roleID == "" {
return nil
}
return d.bot.GuildMemberRoleRemove(d.guildID, userID, d.roleID)
}
// SetRoleDisabled removes the role if "disabled", and applies if "!disabled".
func (d *DiscordDaemon) SetRoleDisabled(userID string, disabled bool) (err error) {
if disabled {
err = d.RemoveRole(userID)
} else {
err = d.ApplyRole(userID)
}
return
}
// NewTempInvite creates an invite link, and returns the invite URL, as well as the URL for the server icon.
func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconURL string) {
var inv *dg.Invite
var err error
if d.inviteChannelName == "" {
d.app.err.Println("Discord: Cannot create invite without channel specified in settings.")
if d.InviteChannel.Name == "" {
d.app.err.Printf(lm.FailedCreateDiscordInviteChannel, lm.InviteChannelEmpty)
return
}
if d.inviteChannelID == "" {
if d.InviteChannel.ID == "" {
channels, err := d.bot.GuildChannels(d.guildID)
if err != nil {
d.app.err.Printf("Discord: Couldn't get channel list: %v", err)
d.app.err.Printf(lm.FailedGetDiscordChannels, err)
return
}
found := false
for _, channel := range channels {
// channel, err := d.bot.Channel(ch.ID)
// if err != nil {
// d.app.err.Printf("Discord: Couldn't get channel: %v", err)
// d.app.err.Printf(lm.FailedGetDiscordChannel, ch.ID, err)
// return
// }
if channel.Name == d.inviteChannelName {
d.inviteChannelID = channel.ID
if channel.Name == d.InviteChannel.Name {
d.InviteChannel.ID = channel.ID
found = true
break
}
}
if !found {
d.app.err.Printf("Discord: Couldn't find invite channel \"%s\"", d.inviteChannelName)
d.app.err.Printf(lm.FailedGetDiscordChannel, d.InviteChannel.Name, lm.NotFound)
return
}
}
// channel, err := d.bot.Channel(d.inviteChannelID)
// if err != nil {
// d.app.err.Printf("Discord: Couldn't get invite channel: %v", err)
// d.app.err.Printf(lm.FailedGetDiscordChannel, d.inviteChannelID, err)
// return
// }
inv, err = d.bot.ChannelInviteCreate(d.inviteChannelID, dg.Invite{
inv, err = d.bot.ChannelInviteCreate(d.InviteChannel.ID, dg.Invite{
// Guild: d.bot.State.Guilds[len(d.bot.State.Guilds)-1],
// Channel: channel,
// Inviter: d.bot.State.User,
@@ -214,13 +267,13 @@ func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconU
Temporary: false,
})
if err != nil {
d.app.err.Printf("Discord: Failed to create invite: %v", err)
d.app.err.Printf(lm.FailedGenerateDiscordInvite, err)
return
}
inviteURL = "https://discord.gg/" + inv.Code
guild, err := d.bot.Guild(d.guildID)
if err != nil {
d.app.err.Printf("Discord: Failed to get guild: %v", err)
d.app.err.Printf(lm.FailedGetDiscordGuild, err)
return
}
iconURL = guild.IconURL("256")
@@ -255,7 +308,7 @@ func (d *DiscordDaemon) GetUsers(username string) []*dg.Member {
1000,
)
if err != nil {
d.app.err.Printf("Discord: Failed to get members: %v", err)
d.app.err.Printf(lm.FailedGetDiscordGuildMembers, err)
return nil
}
hasDiscriminator := strings.Contains(username, "#")
@@ -285,7 +338,7 @@ func (d *DiscordDaemon) GetUsers(username string) []*dg.Member {
func (d *DiscordDaemon) NewUser(ID string) (user DiscordUser, ok bool) {
u, err := d.bot.User(ID)
if err != nil {
d.app.err.Printf("Discord: Failed to get user: %v", err)
d.app.err.Printf(lm.FailedGetUser, ID, lm.Discord, err)
return
}
user.ID = ID
@@ -294,7 +347,7 @@ func (d *DiscordDaemon) NewUser(ID string) (user DiscordUser, ok bool) {
user.Discriminator = u.Discriminator
channel, err := d.bot.UserChannelCreate(ID)
if err != nil {
d.app.err.Printf("Discord: Failed to create DM channel: %v", err)
d.app.err.Printf(lm.FailedCreateDiscordDMChannel, ID, err)
return
}
user.ChannelID = channel.ID
@@ -309,7 +362,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"),
@@ -381,7 +434,7 @@ func (d *DiscordDaemon) registerCommands() {
d.commandDescriptions[1].Options[0].Choices = make([]*dg.ApplicationCommandOptionChoice, len(d.app.storage.lang.Telegram))
i := 0
for code := range d.app.storage.lang.Telegram {
d.app.debug.Printf("Discord: registering lang choice \"%s\":\"%s\"\n", d.app.storage.lang.Telegram[code].Meta.Name, code)
d.app.debug.Printf(lm.RegisterDiscordChoice, lm.Lang, d.app.storage.lang.Telegram[code].Meta.Name+":"+code)
d.commandDescriptions[1].Options[0].Choices[i] = &dg.ApplicationCommandOptionChoice{
Name: d.app.storage.lang.Telegram[code].Meta.Name,
Value: code,
@@ -392,7 +445,7 @@ func (d *DiscordDaemon) registerCommands() {
profiles := d.app.storage.GetProfiles()
d.commandDescriptions[3].Options[3].Choices = make([]*dg.ApplicationCommandOptionChoice, len(profiles))
for i, profile := range profiles {
d.app.debug.Printf("Discord: registering profile choice \"%s\"", profile.Name)
d.app.debug.Printf(lm.RegisterDiscordChoice, lm.Profile, profile.Name)
d.commandDescriptions[3].Options[3].Choices[i] = &dg.ApplicationCommandOptionChoice{
Name: profile.Name,
Value: profile.Name,
@@ -406,26 +459,46 @@ 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("Discord: Cannot create command \"%s\": %v", cmd.Name, err)
d.app.err.Printf(lm.FailedRegisterDiscordCommand, cmd.Name, err)
} else {
d.app.debug.Printf("Discord: registered command \"%s\"", cmd.Name)
d.app.debug.Printf(lm.RegisterDiscordCommand, cmd.Name)
d.commandIDs[i] = command.ID
}
}
} */
}
func (d *DiscordDaemon) deregisterCommands() {
existingCommands, err := d.bot.ApplicationCommands(d.bot.State.User.ID, d.guildID)
if err != nil {
d.app.err.Printf("Discord: Failed to get commands: %v", err)
d.app.err.Printf(lm.FailedGetDiscordCommands, err)
return
}
for _, cmd := range existingCommands {
if err := d.bot.ApplicationCommandDelete(d.bot.State.User.ID, d.guildID, cmd.ID); err != nil {
d.app.err.Printf("Discord: Failed to deregister command: %v", err)
d.app.err.Printf(lm.FailedDeregDiscordCommand, cmd.Name, err)
}
}
}
@@ -436,7 +509,7 @@ func (d *DiscordDaemon) UpdateCommands() {
profiles := d.app.storage.GetProfiles()
d.commandDescriptions[3].Options[3].Choices = make([]*dg.ApplicationCommandOptionChoice, len(profiles))
for i, profile := range profiles {
d.app.debug.Printf("Discord: registering profile choice \"%s\"", profile.Name)
d.app.debug.Printf(lm.RegisterDiscordChoice, lm.Profile, profile.Name)
d.commandDescriptions[3].Options[3].Choices[i] = &dg.ApplicationCommandOptionChoice{
Name: profile.Name,
Value: profile.Name,
@@ -444,7 +517,7 @@ func (d *DiscordDaemon) UpdateCommands() {
}
cmd, err := d.bot.ApplicationCommandEdit(d.bot.State.User.ID, d.guildID, d.commandIDs[3], d.commandDescriptions[3])
if err != nil {
d.app.err.Printf("Discord: Failed to update profile list: %v\n", err)
d.app.err.Printf(lm.FailedRegisterDiscordChoices, lm.Profile, err)
} else {
d.commandIDs[3] = cmd.ID
}
@@ -452,19 +525,20 @@ func (d *DiscordDaemon) UpdateCommands() {
func (d *DiscordDaemon) commandHandler(s *dg.Session, i *dg.InteractionCreate) {
if h, ok := d.commandHandlers[i.ApplicationCommandData().Name]; ok {
if i.GuildID != "" && d.channelName != "" {
if d.channelID == "" {
if i.GuildID != "" && d.Channel.Name != "" {
if d.Channel.ID == "" {
channel, err := s.Channel(i.ChannelID)
if err != nil {
d.app.err.Printf("Discord: Couldn't get channel, will monitor all: %v", err)
d.channelName = ""
d.app.err.Printf(lm.FailedGetDiscordChannel, i.ChannelID, err)
d.app.err.Println(lm.MonitorAllDiscordChannels)
d.Channel.Name = ""
}
if channel.Name == d.channelName {
d.channelID = channel.ID
if channel.Name == d.Channel.Name {
d.Channel.ID = channel.ID
}
}
if d.channelID != i.ChannelID {
d.app.debug.Printf("Discord: Ignoring message as not in specified channel")
if d.Channel.ID != i.ChannelID {
d.app.debug.Printf(lm.IgnoreOutOfChannelMessage, lm.Discord)
return
}
}
@@ -486,7 +560,7 @@ func (d *DiscordDaemon) commandHandler(s *dg.Session, i *dg.InteractionCreate) {
func (d *DiscordDaemon) cmdStart(s *dg.Session, i *dg.InteractionCreate, lang string) {
channel, err := s.UserChannelCreate(i.Interaction.Member.User.ID)
if err != nil {
d.app.err.Printf("Discord: Failed to create private channel with \"%s\": %v", i.Interaction.Member.User.Username, err)
d.app.err.Printf(lm.FailedCreateDiscordDMChannel, i.Interaction.Member.User.ID, err)
return
}
user := d.MustGetUser(channel.ID, i.Interaction.Member.User.ID, i.Interaction.Member.User.Discriminator, i.Interaction.Member.User.Username)
@@ -503,7 +577,7 @@ func (d *DiscordDaemon) cmdStart(s *dg.Session, i *dg.InteractionCreate, lang st
},
})
if err != nil {
d.app.err.Printf("Discord: Failed to send reply: %v", err)
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
return
}
}
@@ -521,7 +595,7 @@ func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang stri
},
})
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", i.Interaction.Member.User.Username, err)
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
}
delete(d.tokens, pin)
return
@@ -535,7 +609,7 @@ func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang stri
},
})
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", i.Interaction.Member.User.Username, err)
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
}
dcUser := d.users[i.Interaction.Member.User.ID]
dcUser.JellyfinID = user.JellyfinID
@@ -566,7 +640,7 @@ func (d *DiscordDaemon) cmdLang(s *dg.Session, i *dg.InteractionCreate, lang str
},
})
if err != nil {
d.app.err.Printf("Discord: Failed to send reply: %v", err)
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
return
}
}
@@ -575,12 +649,27 @@ func (d *DiscordDaemon) cmdLang(s *dg.Session, i *dg.InteractionCreate, lang str
func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang string) {
channel, err := s.UserChannelCreate(i.Interaction.Member.User.ID)
if err != nil {
d.app.err.Printf("Discord: Failed to create private channel with \"%s\": %v", i.Interaction.Member.User.Username, err)
d.app.err.Printf(lm.FailedCreateDiscordDMChannel, i.Interaction.Member.User.ID, err)
return
}
requester := d.MustGetUser(channel.ID, i.Interaction.Member.User.ID, i.Interaction.Member.User.Discriminator, i.Interaction.Member.User.Username)
d.users[i.Interaction.Member.User.ID] = requester
recipient := i.ApplicationCommandData().Options[0].UserValue(s)
// 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()
@@ -588,14 +677,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 {
d.app.err.Printf("Failed to verify admin")
}
if !requesterEmail.Admin {
d.app.err.Printf("User is not admin")
//add response message
// 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))
sendResponse("noPermission")
return
}
@@ -629,7 +714,7 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
ValidTill: validTill,
UserLabel: userLabel,
Profile: "Default",
Label: fmt.Sprintf("Discord: %s", RenderDiscordUsername(recipient)),
Label: fmt.Sprintf("%s: %s", lm.Discord, RenderDiscordUsername(recipient)),
}
if profileName != "" {
if _, ok := d.app.storage.GetProfileKey(profileName); ok {
@@ -637,193 +722,47 @@ 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) {
d.app.debug.Printf("%s: Sending invite message", invite.Code)
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("Failed to send to %s", RenderDiscordUsername(recipient))
d.app.err.Printf("%s: Failed to construct invite message: %v", invite.Code, err)
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInviteFailure"),
Flags: 64, // Ephemeral
},
})
if err == nil && !(d.app.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("Discord: Failed to send message to \"%s\": %v", RenderDiscordUsername(requester), err)
}
} else {
var err error
err = d.app.discord.SendDM(msg, recipient.ID)
if err != nil {
invite.SendTo = fmt.Sprintf("Failed to send to %s", RenderDiscordUsername(recipient))
d.app.err.Printf("%s: %s: %v", invite.Code, invite.SendTo, err)
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInviteFailure"),
Flags: 64, // Ephemeral
},
})
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", RenderDiscordUsername(requester), err)
}
} else {
d.app.info.Printf("%s: Sent invite email to \"%s\"", invite.Code, RenderDiscordUsername(recipient))
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInvite"),
Flags: 64, // Ephemeral
},
})
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", RenderDiscordUsername(requester), err)
}
// 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)
}
}
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)
}
func (d *DiscordDaemon) messageHandler(s *dg.Session, m *dg.MessageCreate) {
if m.GuildID != "" && d.channelName != "" {
if d.channelID == "" {
channel, err := s.Channel(m.ChannelID)
if err != nil {
d.app.err.Printf("Discord: Couldn't get channel, will monitor all: %v", err)
d.channelName = ""
}
if channel.Name == d.channelName {
d.channelID = channel.ID
}
}
if d.channelID != m.ChannelID {
d.app.debug.Printf("Discord: Ignoring message as not in specified channel")
return
}
}
if m.Author.ID == s.State.User.ID {
return
}
sects := strings.Split(m.Content, " ")
if len(sects) == 0 {
return
}
lang := d.app.storage.lang.chosenTelegramLang
if user, ok := d.users[m.Author.ID]; ok {
if _, ok := d.app.storage.lang.Telegram[user.Lang]; ok {
lang = user.Lang
}
}
switch msg := sects[0]; msg {
case "!" + d.app.config.Section("discord").Key("start_command").MustString("start"):
d.msgStart(s, m, lang)
case "!lang":
d.msgLang(s, m, sects, lang)
default:
d.msgPIN(s, m, sects, lang)
}
}
func (d *DiscordDaemon) msgStart(s *dg.Session, m *dg.MessageCreate, lang string) {
channel, err := s.UserChannelCreate(m.Author.ID)
if err != nil {
d.app.err.Printf("Discord: Failed to create private channel with \"%s\": %v", m.Author.Username, err)
return
}
user := d.MustGetUser(channel.ID, m.Author.ID, m.Author.Discriminator, m.Author.Username)
d.users[m.Author.ID] = user
_, err = d.bot.ChannelMessageSendReply(m.ChannelID, d.app.storage.lang.Telegram[lang].Strings.get("discordDMs"), m.Reference())
if err != nil {
d.app.err.Printf("Discord: Failed to send reply to \"%s\": %v", m.Author.Username, err)
return
}
content := d.app.storage.lang.Telegram[lang].Strings.get("startMessage") + "\n"
content += d.app.storage.lang.Telegram[lang].Strings.template("languageMessage", tmpl{"command": "!lang"})
_, err = s.ChannelMessageSend(channel.ID, content)
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
return
}
}
func (d *DiscordDaemon) msgLang(s *dg.Session, m *dg.MessageCreate, sects []string, lang string) {
if len(sects) == 1 {
list := "!lang <lang>\n"
for code := range d.app.storage.lang.Telegram {
list += fmt.Sprintf("%s: %s\n", code, d.app.storage.lang.Telegram[code].Meta.Name)
}
_, err := s.ChannelMessageSendReply(
m.ChannelID,
list,
m.Reference(),
)
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
}
return
}
if _, ok := d.app.storage.lang.Telegram[sects[1]]; ok {
var user DiscordUser
for _, u := range d.app.storage.GetDiscord() {
if u.ID == m.Author.ID {
u.Lang = sects[1]
d.app.storage.SetDiscordKey(u.JellyfinID, u)
user = u
break
}
}
d.users[m.Author.ID] = user
}
}
func (d *DiscordDaemon) msgPIN(s *dg.Session, m *dg.MessageCreate, sects []string, lang string) {
if _, ok := d.users[m.Author.ID]; ok {
channel, err := s.Channel(m.ChannelID)
if err != nil {
d.app.err.Printf("Discord: Failed to get channel: %v", err)
return
}
if channel.Type != dg.ChannelTypeDM {
d.app.debug.Println("Discord: Ignoring message as not a DM")
return
}
} else {
d.app.debug.Println("Discord: Ignoring message as user was not found")
return
}
user, ok := d.tokens[sects[0]]
if !ok || time.Now().After(user.Expiry) {
_, err := s.ChannelMessageSend(
m.ChannelID,
d.app.storage.lang.Telegram[lang].Strings.get("invalidPIN"),
)
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
}
delete(d.tokens, sects[0])
return
}
_, err := s.ChannelMessageSend(
m.ChannelID,
d.app.storage.lang.Telegram[lang].Strings.get("pinSuccess"),
)
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
}
dcUser := d.users[m.Author.ID]
dcUser.JellyfinID = user.JellyfinID
d.verifiedTokens[sects[0]] = dcUser
delete(d.tokens, sects[0])
}
func (d *DiscordDaemon) SendDM(message *Message, userID ...string) error {
channels := make([]string, len(userID))
for i, id := range userID {
@@ -877,10 +816,10 @@ func (d *DiscordDaemon) Send(message *Message, channelID ...string) error {
}
// UserVerified returns whether or not a token with the given PIN has been verified, and the user itself.
func (d *DiscordDaemon) UserVerified(pin string) (user DiscordUser, ok bool) {
user, ok = d.verifiedTokens[pin]
func (d *DiscordDaemon) UserVerified(pin string) (ContactMethodUser, bool) {
u, ok := d.verifiedTokens[pin]
// delete(d.verifiedTokens, pin)
return
return &u, ok
}
// AssignedUserVerified returns whether or not a user with the given PIN has been verified, and the token itself.
@@ -900,7 +839,44 @@ func (d *DiscordDaemon) UserExists(id string) bool {
return err != nil || c > 0
}
// DeleteVerifiedUser removes the token with the given PIN.
func (d *DiscordDaemon) DeleteVerifiedUser(pin string) {
delete(d.verifiedTokens, pin)
// Exists returns whether or not the given user exists.
func (d *DiscordDaemon) Exists(user ContactMethodUser) bool {
return d.UserExists(user.MethodID().(string))
}
// DeleteVerifiedToken removes the token with the given PIN.
func (d *DiscordDaemon) DeleteVerifiedToken(PIN string) {
delete(d.verifiedTokens, PIN)
}
func (d *DiscordDaemon) PIN(req newUserDTO) string { return req.DiscordPIN }
func (d *DiscordDaemon) Name() string { return lm.Discord }
func (d *DiscordDaemon) Required() bool {
return d.app.config.Section("discord").Key("required").MustBool(false)
}
func (d *DiscordDaemon) UniqueRequired() bool {
return d.app.config.Section("discord").Key("require_unique").MustBool(false)
}
func (d *DiscordDaemon) PostVerificationTasks(PIN string, u ContactMethodUser) error {
err := d.ApplyRole(u.MethodID().(string))
if err != nil {
return fmt.Errorf(lm.FailedSetDiscordMemberRole, err)
}
return err
}
func (d *DiscordUser) Name() string { return RenderDiscordUsername(*d) }
func (d *DiscordUser) SetMethodID(id any) { d.ID = id.(string) }
func (d *DiscordUser) MethodID() any { return d.ID }
func (d *DiscordUser) SetJellyfin(id string) { d.JellyfinID = id }
func (d *DiscordUser) Jellyfin() string { return d.JellyfinID }
func (d *DiscordUser) SetAllowContactFromDTO(req newUserDTO) { d.Contact = req.DiscordContact }
func (d *DiscordUser) SetAllowContact(contact bool) { d.Contact = contact }
func (d *DiscordUser) AllowContact() bool { return d.Contact }
func (d *DiscordUser) Store(st *Storage) {
st.SetDiscordKey(d.Jellyfin(), *d)
}

View File

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

View File

@@ -1,4 +1,6 @@
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b h1:xZ59n7Frzh8CwyfAapUZLSg+gXH5m63YEaFCMpDHhpI=
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b/go.mod h1:uDd4sYVYsqcxAB8j+Q7uhL6IJCs/r1kxib1HV4bgOMg=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
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=

851
email.go

File diff suppressed because it is too large Load Diff

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

@@ -1,19 +1,19 @@
//go:build external
// +build external
package main
import (
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"github.com/hrfee/jfa-go/logger"
)
const binaryType = "external"
var localFS dirFS
var langFS dirFS
func BuildTagsExternal() { buildTags = append(buildTags, "external") }
// When using os.DirFS, even on Windows the separator seems to be '/'.
// func FSJoin(elem ...string) string { return filepath.Join(elem...) }
@@ -29,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

@@ -1,12 +1,17 @@
package main
import "time"
import (
"time"
lm "github.com/hrfee/jfa-go/logmessages"
)
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
type GenericDaemon struct {
Stopped bool
ShutdownChannel chan string
TriggerChannel chan bool
Interval time.Duration
period time.Duration
jobs []func(app *appContext)
@@ -23,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,
@@ -36,12 +42,14 @@ func NewGenericDaemon(interval time.Duration, app *appContext, jobs ...func(app
func (d *GenericDaemon) Name(name string) { d.name = name }
func (d *GenericDaemon) run() {
d.app.info.Printf("%s started", d.name)
d.app.info.Printf(lm.StartDaemon, d.name)
for {
select {
case <-d.ShutdownChannel:
d.ShutdownChannel <- "Down"
return
case <-d.TriggerChannel:
break
case <-time.After(d.period):
break
}
@@ -57,6 +65,10 @@ func (d *GenericDaemon) run() {
}
}
func (d *GenericDaemon) Trigger() {
d.TriggerChannel <- true
}
func (d *GenericDaemon) Shutdown() {
d.Stopped = true
d.ShutdownChannel <- "Down"

157
go.mod
View File

@@ -1,6 +1,8 @@
module github.com/hrfee/jfa-go
go 1.20
go 1.23.0
toolchain go1.24.0
replace github.com/hrfee/jfa-go/docs => ./docs
@@ -10,6 +12,8 @@ replace github.com/hrfee/jfa-go/ombi => ./ombi
replace github.com/hrfee/jfa-go/logger => ./logger
replace github.com/hrfee/jfa-go/logmessages => ./logmessages
replace github.com/hrfee/jfa-go/linecache => ./linecache
replace github.com/hrfee/jfa-go/api => ./api
@@ -19,118 +23,123 @@ replace github.com/hrfee/jfa-go/easyproxy => ./easyproxy
replace github.com/hrfee/jfa-go/jellyseerr => ./jellyseerr
require (
github.com/bwmarrin/discordgo v0.27.1
github.com/dgraph-io/badger/v3 v3.2103.5
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a
github.com/fatih/color v1.15.0
github.com/fsnotify/fsnotify v1.6.0
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.9.0
github.com/getlantern/systray v1.2.2
github.com/gin-contrib/pprof v1.4.0
github.com/gin-contrib/static v0.0.1
github.com/gin-gonic/gin v1.9.1
github.com/gin-contrib/pprof v1.5.3
github.com/gin-gonic/gin v1.10.1
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a
github.com/hrfee/jfa-go/common v0.0.0-20240728190513-dabef831d769
github.com/hrfee/jfa-go/docs v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/jfa-go/easyproxy v0.0.0-00010101000000-000000000000
github.com/hrfee/jfa-go/linecache v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/jfa-go/logger v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/jfa-go/ombi v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/mediabrowser v0.3.13
github.com/itchyny/timefmt-go v0.1.5
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b
github.com/hrfee/jfa-go/common v0.0.0-20250716174732-bcb6346f8115
github.com/hrfee/jfa-go/docs v0.0.0-20250716174732-bcb6346f8115
github.com/hrfee/jfa-go/easyproxy v0.0.0-20250716174732-bcb6346f8115
github.com/hrfee/jfa-go/jellyseerr v0.0.0-20250716174732-bcb6346f8115
github.com/hrfee/jfa-go/linecache v0.0.0-20250716174732-bcb6346f8115
github.com/hrfee/jfa-go/logger v0.0.0-20250716174732-bcb6346f8115
github.com/hrfee/jfa-go/logmessages v0.0.0-20250716174732-bcb6346f8115
github.com/hrfee/jfa-go/ombi v0.0.0-20250716174732-bcb6346f8115
github.com/hrfee/mediabrowser v0.3.29
github.com/itchyny/timefmt-go v0.1.6
github.com/lithammer/shortuuid/v3 v3.0.7
github.com/mailgun/mailgun-go/v4 v4.9.1
github.com/mailgun/mailgun-go/v4 v4.23.0
github.com/mattn/go-sqlite3 v1.14.28
github.com/robert-nix/ansihtml v1.0.1
github.com/steambap/captcha v1.4.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/timshannon/badgerhold/v4 v4.0.2
github.com/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
maunium.net/go/mautrix v0.15.3
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.24.2
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/bytedance/sonic v1.9.2 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dgraph-io/ristretto v0.1.1 // indirect
github.com/bytedance/sonic v1.13.3 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dgraph-io/ristretto v1.0.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect
github.com/getlantern/errors v1.0.3 // 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-20230519221840-1283e026181c // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.2.4 // indirect
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-chi/chi/v5 v5.2.2 // 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.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/spec v0.20.9 // indirect
github.com/go-openapi/swag v0.22.4 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // 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.1 // 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.14.1 // indirect
github.com/go-playground/validator/v10 v10.27.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.2 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/glog v1.1.1 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/flatbuffers v23.5.26+incompatible // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/mux v1.8.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/hrfee/jfa-go/jellyseerr v0.0.0-00010101000000-000000000000 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/flatbuffers v25.2.10+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.16.6 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/klauspost/compress v1.18.0 // 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/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mailgun/errors v0.4.0 // indirect
github.com/mailru/easyjson v0.9.0 // 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.0.8 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rs/zerolog v1.29.1 // indirect
github.com/swaggo/swag v1.16.1 // 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/technoweenie/multipartstreamer v1.0.1 // indirect
github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // 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.11 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.mau.fi/util v0.8.8 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/otel v1.16.0 // indirect
go.opentelemetry.io/otel/metric v1.16.0 // indirect
go.opentelemetry.io/otel/trace v1.16.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.13.0 // indirect
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect
golang.org/x/image v0.8.0 // indirect
golang.org/x/net v0.15.0 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/tools v0.10.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/maulogger/v2 v2.4.1 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/arch v0.19.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect
golang.org/x/image v0.29.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.35.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
)

458
go.sum
View File

@@ -1,32 +1,43 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY=
github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.2 h1:GDaNjuWSGu09guE9Oql0MSTNhNCLlWwO8y/xM5BzcbM=
github.com/bytedance/sonic v1.9.2/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4=
github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
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/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 v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
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/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/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/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/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/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=
@@ -34,48 +45,52 @@ github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v3 v3.2103.1/go.mod h1:dULbq6ehJ5K0cGW/1TQ9iSfUk0gbSiToDWmWmTsJ53E=
github.com/dgraph-io/badger/v3 v3.2103.5 h1:ylPa6qzbjYRQMU6jokoj4wzcaweHylt//CH0AKt0akg=
github.com/dgraph-io/badger/v3 v3.2103.5/go.mod h1:4MPiseMeDQ3FNCYwRbbcBOGJLf5jsE0PPFzRiKjtcdw=
github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug=
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
github.com/dgraph-io/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/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
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.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
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/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=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 h1:0JZ+dUmQeA8IIVUMzysrX4/AKuQwWhV2dYQuPZdvdSQ=
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A=
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 h1:E2s37DuLxFhQDg5gKsWoLBOB0n+ZW8s599zru8FJ2/Y=
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/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.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
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/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.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
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=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/errors v1.0.1/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/errors v1.0.3 h1:Ne4Ycj7NI1BtSyAfVeAT/DNoxz7/S2BUc3L2Ht1YSHE=
github.com/getlantern/errors v1.0.3/go.mod h1:m8C7H1qmouvsGpwQqk/6NUpIVMpfzUPn608aBZDYV04=
github.com/getlantern/errors v1.0.4 h1:i2iR1M9GKj4WuingpNqJ+XQEw6i6dnAgKAmLj6ZB3X0=
github.com/getlantern/errors v1.0.4/go.mod h1:/Foq8jtSDGP8GOXzAjeslsC4Ar/3kB+UiQH+WyV4pzY=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 h1:NlQedYmPI3pRAXJb+hLVVDGqfvvXGRPV8vp7XOjKAZ0=
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65/go.mod h1:+ZU1h+iOVqWReBpky6d5Y2WL0sF2Llxu+QcxJFs2+OU=
@@ -87,71 +102,76 @@ github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 h1:cSrD9ryDfTV2y
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770/go.mod h1:GOQsoDnEHl6ZmNIL+5uVo+JWRFWozMEp18Izcb++H+A=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/ops v0.0.0-20220713155959-1315d978fff7/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/ops v0.0.0-20230519221840-1283e026181c h1:qcPAzA1ZDnwx618jAgQmxo6UvJkw2SkM1L4ofncmEhI=
github.com/getlantern/ops v0.0.0-20230519221840-1283e026181c/go.mod h1:g2ueCncOwWenlAr56Fh90FwsACkelqqtFUDLAHg1mng=
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 h1:3BwvWj0JZzFEvNNiMhCu4bf60nqcIuQpTYb00Ezm1ag=
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534/go.mod h1:ZsLfOY6gKQOTyEcPYNA9ws5/XHZQFroxqCOhHjGcs9Y=
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
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/pprof v1.4.0 h1:XxiBSf5jWZ5i16lNOPbMTVdgHBdhfGRD5PZ1LWazzvg=
github.com/gin-contrib/pprof v1.4.0/go.mod h1:RrehPJasUVBPK6yTUwOl8/NP6i0vbUgmxtis+Z5KE90=
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 v0.0.1 h1:JVxuvHPuUfkoul12N7dtQw7KRn/pSMq7Ue1Va9Swm1U=
github.com/gin-contrib/static v0.0.1/go.mod h1:CSxeF+wep05e0kCOsqWdAWbSszmc31zTIbD8TvWl7Hs=
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.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
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/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/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/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/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.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-logr/logr v1.2.4/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.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
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.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
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.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
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/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.20.9 h1:xnlYNQAwKd2VQRRfwTEI0DcK+2cbuvI/0c7jx3gA8/8=
github.com/go-openapi/spec v0.20.9/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
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/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.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU=
github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
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.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
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.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
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.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/go-playground/validator/v10 v10.14.1 h1:9c50NUPC30zyuKprjL3vNZ0m5oG+jU0zvx4AqHGnv4k=
github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
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.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
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=
@@ -159,9 +179,10 @@ 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.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/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/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=
@@ -170,18 +191,17 @@ github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzq
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v0.0.0-20210429001901-424d2337a529/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v1.1.1 h1:jxpi2eWoU84wbX9iIEyAeeoac3FLuifZpY9tcNUD9kw=
github.com/golang/glog v1.1.1/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
@@ -190,19 +210,21 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/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 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a h1:AWZzzFrqyjYlRloN6edwTLTUbKxf5flLXNuTBDm3Ews=
github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/flatbuffers v1.12.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/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-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v2.0.0+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg=
github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/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.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
github.com/google/flatbuffers v25.2.10+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=
@@ -212,53 +234,54 @@ 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.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/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.13 h1:NgQNbq+JWwsP68BdWXL/rwbpfE/oO5LJ5KVkE+aNbX8=
github.com/hrfee/mediabrowser v0.3.13/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/hrfee/mediabrowser v0.3.28 h1:KkSgODXxUnZLrkmjSWpma8mXwEVxlOtI51uS2QP/e+c=
github.com/hrfee/mediabrowser v0.3.28/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/hrfee/mediabrowser v0.3.29 h1:xTqGS9u8HuolZAhouYHxutnE0fF/8aVCInbByKZEzIo=
github.com/hrfee/mediabrowser v0.3.29/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
github.com/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/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.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.13.1/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.16.6 h1:91SKEy4K37vkp255cJ8QesJhjyRO0hn9i9G0GoUwLsk=
github.com/klauspost/compress v1.16.6/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/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/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
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/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
@@ -266,44 +289,55 @@ github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b h1:xZ59n7Frzh8CwyfAapUZLSg+gXH5m63YEaFCMpDHhpI=
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b/go.mod h1:uDd4sYVYsqcxAB8j+Q7uhL6IJCs/r1kxib1HV4bgOMg=
github.com/mailgun/mailgun-go/v4 v4.9.1 h1:D/jhJXYod4RqRsNOOSrjrtAcMEnz8mPYJmeA5cueHKY=
github.com/mailgun/mailgun-go/v4 v4.9.1/go.mod h1:FJlF9rI5cQT+mrwujtJjPMbIVy3Ebor9bKTVsJ0QU40=
github.com/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.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
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=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
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.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/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=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
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/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-20241025130422-66cb2e6d7274 h1:qli3BGQK0tYDkSEvZ/FzZTi9ZrOX86Q6CIhKLGc489A=
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb h1:3PrKuO92dUTMrQ9dx0YNejC6U/Si6jqKmyQ9vWjwqR4=
github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb/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=
@@ -312,20 +346,21 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/robert-nix/ansihtml v1.0.1 h1:VTiyQ6/+AxSJoSSLsMecnkh8i0ZqOEdiRl/odOc64fc=
github.com/robert-nix/ansihtml v1.0.1/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yDXcNeHHKZINE=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc=
github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU=
github.com/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/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
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/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=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
@@ -346,9 +381,8 @@ 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.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/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/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
@@ -357,13 +391,13 @@ github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+z
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
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.1 h1:fTNRhKstPKxcnoKsytm4sahr8FaYzUcT7i1/3nd/fBg=
github.com/swaggo/swag v1.16.1/go.mod h1:9/LMvHycG3NFHfR6LwvikHv5iFvmPADQ359cKikGxto=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
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.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/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/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
@@ -371,25 +405,24 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/timshannon/badgerhold/v3 v3.0.0-20210909134927-2b6764d68c1e h1:zWSVsQaifg0cVH9VvR+cMguV7exK6U+SoW8YD1cZpR4=
github.com/timshannon/badgerhold/v3 v3.0.0-20210909134927-2b6764d68c1e/go.mod h1:/Seq5xGNo8jLhSbDX3jdbeZrp4yFIpQ6/7n4TjziEWs=
github.com/timshannon/badgerhold/v4 v4.0.2 h1:83OLY/NFnEaMnHEPd84bYtkLipVkjTsMbzQRYbk47g4=
github.com/timshannon/badgerhold/v4 v4.0.2/go.mod h1:rh6RyXLQFsvrvcKondPQQFZnNovpRzu+gS0FlLxYuHY=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM=
github.com/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=
github.com/ugorji/go v1.1.5-pre/go.mod h1:FwP/aQVg39TXzItUBMwnWp9T9gPQnXw4Poh4/oBQZ/0=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
github.com/ugorji/go/codec v0.0.0-20181022190402-e5e69e061d4f/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v1.1.5-pre/go.mod h1:tULtS6Gy1AE1yCENaw4Vb//HLH5njI2tfCQDUqRd8fI=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
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.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/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=
@@ -401,48 +434,64 @@ 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.8.8 h1:OnuEEc/sIJFhnq4kFggiImUpcmnmL/xpvQMRu5Fiy5c=
go.mau.fi/util v0.8.8/go.mod h1:Y/kS3loxTEhy8Vill513EtPXr+CRDdae+Xj2BXXMy/c=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.9.0/go.mod h1:np4EoPGzoPs3O67xUVNoPPcmSvsfOxNlNA4F4AC+0Eo=
go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s=
go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4=
go.opentelemetry.io/otel/metric v1.16.0 h1:RbrpwVG1Hfv85LgnZ7+txXioPDoh6EdbZHo26Q3hqOo=
go.opentelemetry.io/otel/metric v1.16.0/go.mod h1:QE47cpOmkwipPiefDwo2wDzwJrlfxxNYodqc4xnGCo4=
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 v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
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/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.9.0/go.mod h1:2737Q0MuG8q1uILYm2YYVkAyLtOofiTNGg6VODnOiPo=
go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs=
go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0=
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.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
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/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.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
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=
golang.org/x/arch v0.19.0 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU=
golang.org/x/arch v0.19.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/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-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc=
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.8.0 h1:agUcRXV/+w6L9ryntYYsF2x9fQTMd4T8fiiYXAVW6Jg=
golang.org/x/image v0.8.0/go.mod h1:PwLxp3opCYg4WR2WO9P0L6ESnsD6bLTWcw8zanLMVFM=
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.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
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=
@@ -452,7 +501,9 @@ 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.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
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.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
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=
@@ -470,12 +521,14 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
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.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
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=
@@ -487,6 +540,9 @@ 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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
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=
@@ -501,36 +557,38 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
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.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/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.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
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=
@@ -545,8 +603,10 @@ 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.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg=
golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM=
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.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
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=
@@ -574,18 +634,17 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
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.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
@@ -593,17 +652,14 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
maunium.net/go/mautrix v0.15.3 h1:C9BHSUM0gYbuZmAtopuLjIcH5XHLb/ZjTEz7nN+0jN0=
maunium.net/go/mautrix v0.15.3/go.mod h1:zLrQqdxJlLkurRCozTc9CL6FySkgZlO/kpCYxBILSLE=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
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=
maunium.net/go/mautrix v0.24.2 h1:+AVT5kbcA/QuT5svrJKp4ivwoUmz+RRplMp3DnfpheI=
maunium.net/go/mautrix v0.24.2/go.mod h1:1ut900w++eE9by9yqCR2dQdMqwsHwZG5L+1bKB1EvSA=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

View File

@@ -3,7 +3,8 @@ package main
import (
"time"
"github.com/dgraph-io/badger/v3"
"github.com/dgraph-io/badger/v4"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser"
"github.com/timshannon/badgerhold/v4"
)
@@ -12,10 +13,10 @@ import (
// meant to be called with other such housekeeping functions, so assumes
// the user cache is fresh.
func (app *appContext) clearEmails() {
app.debug.Println("Housekeeping: removing unused email addresses")
app.debug.Println(lm.HousekeepingEmail)
emails := app.storage.GetEmails()
for _, email := range emails {
_, _, err := app.jf.UserByID(email.JellyfinID, false)
_, err := app.jf.UserByID(email.JellyfinID, false)
// Make sure the user doesn't exist, and no other error has occured
switch err.(type) {
case mediabrowser.ErrUserNotFound:
@@ -28,15 +29,20 @@ func (app *appContext) clearEmails() {
// clearDiscord does the same as clearEmails, but for Discord Users.
func (app *appContext) clearDiscord() {
app.debug.Println("Housekeeping: removing unused Discord IDs")
app.debug.Println(lm.HousekeepingDiscord)
discordUsers := app.storage.GetDiscord()
for _, discordUser := range discordUsers {
_, _, err := app.jf.UserByID(discordUser.JellyfinID, false)
user, err := app.jf.UserByID(discordUser.JellyfinID, false)
// Make sure the user doesn't exist, and no other error has occured
switch err.(type) {
case mediabrowser.ErrUserNotFound:
// Remove role in case their account was deleted oustide of jfa-go
app.discord.RemoveRole(discordUser.MethodID().(string))
app.storage.DeleteDiscordKey(discordUser.JellyfinID)
default:
if user.Policy.IsDisabled {
app.discord.RemoveRole(discordUser.MethodID().(string))
}
continue
}
}
@@ -44,10 +50,10 @@ func (app *appContext) clearDiscord() {
// clearMatrix does the same as clearEmails, but for Matrix Users.
func (app *appContext) clearMatrix() {
app.debug.Println("Housekeeping: removing unused Matrix IDs")
app.debug.Println(lm.HousekeepingMatrix)
matrixUsers := app.storage.GetMatrix()
for _, matrixUser := range matrixUsers {
_, _, err := app.jf.UserByID(matrixUser.JellyfinID, false)
_, err := app.jf.UserByID(matrixUser.JellyfinID, false)
// Make sure the user doesn't exist, and no other error has occured
switch err.(type) {
case mediabrowser.ErrUserNotFound:
@@ -60,10 +66,10 @@ func (app *appContext) clearMatrix() {
// clearTelegram does the same as clearEmails, but for Telegram Users.
func (app *appContext) clearTelegram() {
app.debug.Println("Housekeeping: removing unused Telegram IDs")
app.debug.Println(lm.HousekeepingTelegram)
telegramUsers := app.storage.GetTelegram()
for _, telegramUser := range telegramUsers {
_, _, err := app.jf.UserByID(telegramUser.JellyfinID, false)
_, err := app.jf.UserByID(telegramUser.JellyfinID, false)
// Make sure the user doesn't exist, and no other error has occured
switch err.(type) {
case mediabrowser.ErrUserNotFound:
@@ -75,7 +81,7 @@ func (app *appContext) clearTelegram() {
}
func (app *appContext) clearPWRCaptchas() {
app.debug.Println("Housekeeping: Clearing old PWR Captchas")
app.debug.Println(lm.HousekeepingCaptcha)
captchas := map[string]Captcha{}
for k, capt := range app.pwrCaptchas {
if capt.Generated.Add(CAPTCHA_VALIDITY * time.Second).After(time.Now()) {
@@ -86,7 +92,7 @@ func (app *appContext) clearPWRCaptchas() {
}
func (app *appContext) clearActivities() {
app.debug.Println("Housekeeping: Cleaning up Activity log...")
app.debug.Println(lm.HousekeepingActivity)
keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000)
maxAgeDays := app.config.Section("activity_log").Key("delete_after_days").MustInt(90)
minAge := time.Now().AddDate(0, 0, -maxAgeDays)
@@ -103,7 +109,7 @@ func (app *appContext) clearActivities() {
}
}
if err == badger.ErrTxnTooBig {
app.debug.Printf("Activities: Delete txn was too big, doing it manually.")
app.debug.Printf(lm.ActivityLogTxnTooBig)
list := []Activity{}
if errorSource == 0 {
app.storage.db.Find(&list, badgerhold.Where("Time").Lt(minAge))
@@ -119,22 +125,22 @@ func (app *appContext) clearActivities() {
func newHousekeepingDaemon(interval time.Duration, app *appContext) *GenericDaemon {
d := NewGenericDaemon(interval, app,
func(app *appContext) {
app.debug.Println("Housekeeping: Checking for expired invites")
app.debug.Println(lm.HousekeepingInvites)
app.checkInvites()
},
func(app *appContext) { app.clearActivities() },
)
d.Name("Housekeeping daemon")
d.Name("Housekeeping")
clearEmail := app.config.Section("email").Key("require_unique").MustBool(false)
clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false)
clearTelegram := app.config.Section("telegram").Key("require_unique").MustBool(false)
clearMatrix := app.config.Section("matrix").Key("require_unique").MustBool(false)
clearDiscord := discordEnabled && (app.config.Section("discord").Key("require_unique").MustBool(false) || app.config.Section("discord").Key("disable_enable_role").MustBool(false))
clearTelegram := telegramEnabled && (app.config.Section("telegram").Key("require_unique").MustBool(false))
clearMatrix := matrixEnabled && (app.config.Section("matrix").Key("require_unique").MustBool(false))
clearPWR := app.config.Section("captcha").Key("enabled").MustBool(false) && !app.config.Section("captcha").Key("recaptcha").MustBool(false)
if clearEmail || clearDiscord || clearTelegram || clearMatrix {
d.appendJobs(func(app *appContext) { app.jf.CacheExpiry = time.Now() })
d.appendJobs(func(app *appContext) { app.InvalidateJellyfinCache() })
}
if clearEmail {

View File

@@ -1,12 +1,11 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/{{ .cssVersion }}bundle.css">
{{ template "header.html" . }}
<title>404 - jfa-go</title>
{{ template "header.html" . }}
</head>
<body class="section">
<div class="page-container">
<div class="page-container m-2 lg:my-20 lg:mx-64">
<div class="card">
<h1 class="heading">Page not found.</h1>
<p class="content">

View File

@@ -1,16 +1,7 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="{{ .urlBase }}/css/{{ .cssVersion }}bundle.css">
<script>
window.URLBase = "{{ .urlBase }}";
window.notificationsEnabled = {{ .notifications }};
window.emailEnabled = {{ .emailEnabled }};
window.telegramEnabled = {{ .telegramEnabled }};
window.discordEnabled = {{ .discordEnabled }};
window.matrixEnabled = {{ .matrixEnabled }};
window.ombiEnabled = {{ .ombiEnabled }};
window.jellyseerrEnabled = {{ .jellyseerrEnabled }};
window.usernameEnabled = {{ .username }};
window.langFile = JSON.parse({{ .language }});
window.linkResetEnabled = {{ .linkResetEnabled }};
@@ -18,7 +9,6 @@
window.jellyfinLogin = {{ .jellyfinLogin }};
window.jfAdminOnly = {{ .jfAdminOnly }};
window.jfAllowAll = {{ .jfAllowAll }};
window.referralsEnabled = {{ .referralsEnabled }};
window.loginAppearance = "{{ .loginAppearance }}";
</script>
<title>Admin - jfa-go</title>
@@ -27,16 +17,18 @@
<body class="max-w-full overflow-x-hidden section">
{{ template "login-modal.html" . }}
<div id="modal-add-user" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-add-user" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 flex flex-col gap-2" id="form-add-user" href="">
<span class="heading">{{ .strings.newUser }} <span class="modal-close">&times;</span></span>
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.username }}" id="add-user-user">
<input type="email" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.emailAddress }}">
<input type="password" class="field input ~neutral @high mb-4" placeholder="{{ .strings.password }}" id="add-user-password">
<label class="label supra">{{ .strings.profile }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="add-user-profile">
</select>
</div>
<input type="text" class="field input ~neutral @high" placeholder="{{ .strings.username }}" id="add-user-user">
<input type="email" class="field input ~neutral @high" placeholder="{{ .strings.emailAddress }}">
<input type="password" class="field input ~neutral @high" placeholder="{{ .strings.password }}" id="add-user-password">
<label class="label flex flex-col gap-2">
<span class="supra">{{ .strings.profile }}</span>
<div class="select ~neutral @low">
<select id="add-user-profile">
</select>
</div>
</label>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.create }}</span>
@@ -44,13 +36,14 @@
</form>
</div>
<div id="modal-about" class="modal">
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 content card">
<img src="{{ .urlBase }}/banner.svg" class="banner header" alt="jfa-go banner">
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/2 content card">
<img src="{{ .pages.Base }}/banner.svg" class="banner header" alt="jfa-go banner">
<span class="heading"><span class="modal-close">&times;</span></span>
<p>{{ .strings.version }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .version }}</span></p>
<p>{{ .strings.commitNoun }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .commit }}</span></p>
<p>{{ .strings.buildTime }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .buildTime }}</span></p>
<p>{{ .strings.builtBy }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .builtBy }}</span></p>
<p>{{ .strings.buildTags }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .buildTags }}</span></p>
<div class="flex flex-row flex-wrap gap-2 my-2">
<a class="button ~neutral lang-link" href="https://github.com/hrfee/jfa-go"><i class="ri-github-line mr-2"></i>github</a>
<a class="button ~urge lang-link" href="https://wiki.jfa-go.com">wiki/docs</a>
@@ -311,25 +304,27 @@
<form class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card" id="form-editor" href="">
<span class="heading"><span id="header-editor"></span> <span class="modal-close">&times;</span></span>
<div class="row">
<div class="col card ~neutral @low">
<aside class="aside sm ~urge dark:~d_info mb-2 @low" id="aside-editor"></aside>
<span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span>
<div id="editor-variables" class="mt-4"></div>
<span class="label supra" for="editor-conditionals" id="label-editor-conditionals">{{ .strings.conditionals }}</span>
<div id="editor-conditionals"></div>
<label class="label supra" for="textarea-editor">{{ .strings.message }}</label>
<textarea id="textarea-editor" class="textarea full-width flex-auto ~neutral @low mt-4 font-mono"></textarea>
<p class="support mt-4 mb-2">{{ .strings.markdownSupported }}</p>
<div class="flex-row">
<label class="full-width ml-2">
<div class="col card ~neutral @low flex flex-col gap-2 justify-between">
<div class="flex flex-col gap-2">
<aside class="aside sm ~urge dark:~d_info @low" id="aside-editor"></aside>
<label class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</label>
<div id="editor-variables" class="flex flex-row gap-2 flex-wrap"></div>
<span class="label supra" for="editor-conditionals" id="label-editor-conditionals">{{ .strings.conditionals }}</span>
<div id="editor-conditionals"></div>
<label class="label supra" for="textarea-editor">{{ .strings.message }}</label>
<textarea id="textarea-editor" class="textarea full-width flex-auto ~neutral @low font-mono"></textarea>
</div>
<div class="flex flex-col gap-2">
<p class="support">{{ .strings.markdownSupported }}</p>
<label class="w-full">
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.submit }}</span>
<span class="button ~urge @low w-full supra submit">{{ .strings.submit }}</span>
</label>
</div>
</div>
<div class="col card ~neutral @low">
<div class="col card ~neutral @low flex flex-col gap-2">
<span class="subheading supra">{{ .strings.preview }}</span>
<div class="mt-8" id="editor-preview"></div>
<div id="editor-preview"></div>
</div>
</div>
</form>
@@ -367,6 +362,7 @@
<tr>
<th>{{ .strings.name }}</th>
<th>{{ .strings.date }}</th>
<th>{{ .strings.version }}</th>
<th class="table-inline justify-center">{{ .strings.backupDownloadRestore }}</th>
</tr>
</thead>
@@ -535,65 +531,46 @@
</form>
</div>
<div id="notification-box"></div>
<div class="top-4 left-4 absolute flex flex-row gap-2">
<span class="dropdown z-[11]" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button">
<i class="ri-global-line"></i>
<span class="ml-2 chev"></span>
</span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<label class="switch pb-4">
<input type="radio" name="lang-time" id="lang-12h">
<span>{{ .strings.time12h }}</span>
</label>
<label class="switch pb-4">
<input type="radio" name="lang-time" id="lang-24h">
<span>{{ .strings.time24h }}</span>
</label>
<div id="lang-list"></div>
</div>
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4">
<div class="top-2 inset-x-2 lg:absolute flex flex-row justify-between">
<div class="flex flex-row gap-2">
{{ template "lang-select.html" . }}
<span class="button ~warning h-min" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
</div>
</span>
<span class="button ~warning" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
</div>
{{ if .userPageEnabled }}
<div class="top-4 right-4 absolute">
<a class="button ~info" href="{{ .urlBase }}/my/account"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
</div>
{{ end }}
<div class="page-container">
<div class="mb-4">
<header>
<div class="flex flex-row overflow-x-scroll items-center">
<span id="button-tab-invites" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.invites }}</span>
<span id="button-tab-accounts" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.accounts }}</span>
<span id="button-tab-activity" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.activity }}</span>
<span id="button-tab-settings" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.settings }}</span>
</div>
</header>
</div>
<div class="mb-4">
<div>
<span class="button ~critical @low mb-4 unfocused" id="logout-button">{{ .strings.logout }}</span>
<div class="flex flex-row gap-2">
<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>
</div>
{{ end }}
</div>
</div>
<div id="tab-invites">
<div class="card @low invites dark:~d_neutral mb-4 overflow-visible">
<header>
<div class="flex flex-row overflow-x-auto items-center gap-2">
<span id="button-tab-invites" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.invites }}</span>
<span id="button-tab-accounts" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.accounts }}</span>
<span id="button-tab-activity" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.activity }}</span>
<span id="button-tab-statistics" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.statistics }}</span>
<span id="button-tab-settings" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.settings }}</span>
</div>
</header>
<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" class="mt-2"></div>
<div id="invites"></div>
</div>
<div class="card @low dark:~d_neutral">
<div class="card @low dark:~d_neutral flex flex-col gap-2">
<span class="heading">{{ .strings.create }}</span>
<div class="flex flex-col md:flex-row gap-3 mt-2" id="create-inv">
<div class="card ~neutral @low flex flex-col gap-2 grow">
<div class="flex flex-col md:flex-row gap-3" id="create-inv">
<div class="card ~neutral @low flex flex-col gap-2 flex-1">
<div class="flex flex-row gap-2">
<label class="w-1/2">
<input type="radio" name="duration" class="unfocused" id="radio-inv-duration" checked>
<input type="radio" name="radio-duration" class="unfocused" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.inviteDuration }}</span>
</label>
<label class="w-1/2">
<input type="radio" name="duration" class="unfocused" id="radio-user-expiry">
<input type="radio" name="radio-duration" class="unfocused">
<span class="button ~neutral @low supra full-width center">{{ .strings.userExpiry }}</span>
</label>
</div>
@@ -694,7 +671,7 @@
<input type="text" id="create-user-label" class="input ~neutral @low">
</div>
</div>
<div class="card ~neutral @low flex flex-col justify-between gap-2 grow">
<div class="card ~neutral @low flex flex-col justify-between gap-2 flex-1">
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-4">
<label class="label supra" for="create-uses">{{ .strings.inviteNumberOfUses }}</label>
@@ -738,17 +715,25 @@
</div>
</div>
</div>
<div id="tab-accounts" class="unfocused">
<div class="card @low dark:~d_neutral accounts mb-4 overflow-visible">
<div id="tab-accounts" class="flex flex-col gap-4 unfocused">
<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 flex flex-row 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">
@@ -757,13 +742,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>
@@ -798,8 +787,8 @@
<span class="button ~info @low center unfocused " id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
<span class="button ~critical @low center " id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
</div>
<div class="card @low accounts-header table-responsive mt-2">
<table class="table text-base leading-4">
<div class="card @low accounts-header table-responsive">
<table class="table text-base leading-5">
<thead>
<tr>
<th><input type="checkbox" value="" id="accounts-select-all"></th>
@@ -826,19 +815,31 @@
</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 flex flex-row gap-2">
<span>{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</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 flex flex-row 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="unfocused">
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible">
<div id="tab-activity" class="flex flex-col gap-4 unfocused">
<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">
@@ -848,10 +849,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 flex flex-row 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">
@@ -860,42 +868,88 @@
</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 flex flex-row gap-2">
<span>{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
<button class="button ~neutral @low unfocused" id="activity-keep-searching">{{ .strings.keepSearching }}</button>
</div>
</div>
<div 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 flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAllRecords }}</span>
</button>
</div>
</div>
</div>
<div id="tab-settings" class="unfocused">
<div class="card @low dark:~d_neutral settings overflow">
<div id="tab-statistics" class="flex flex-col gap-4 unfocused">
<div class="card @low dark:~d_neutral">
<div class="card @low dark:~d_neutral flex flex-col gap-2">
<div class="flex flex-row gap-2">
<label class="w-full">
<input type="radio" name="statistics-query-type" class="hidden" id="radio-statistics-accounts" checked>
<span class="button ~neutral w-full center @high">{{ .strings.accounts }}</span>
</label>
<label class="w-full">
<input type="radio" name="statistics-query-type" class="hidden" id="radio-statistics-activity">
<span class="button ~neutral w-full center @low">{{ .strings.activity }}</span>
</label>
</div>
<div id="statistics-query-tab-accounts">
<div class="flex flex-col align-middle gap-2">
<div class="flex flex-row align-middle w-full gap-2">
<input type="search" class="field ~neutral @low input search mr-2" placeholder="{{ .strings.query }}">
<span class="button ~neutral @low center inside-input rounded-s-none statistics-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<button class="button ~info @low statistics-query-execute" aria-label="{{ .strings.run }}"><i class="ri-refresh-line"></i></button>
</div>
<div class="flex flex-row gap-2 flex-wrap">
<div class="statistics-sort-by-field"></div>
<span class="flex flex-row gap-2 flex-wrap statistics-filter-area"></span>
</div>
<div class="card ~neutral @low statistics-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</div>
</div>
</div>
<div id="statistics-query-tab-activity">
<div class="flex flex-col align-middle gap-2">
<div class="flex flex-row align-middle w-full gap-2">
<input type="search" class="field ~neutral @low input search mr-2" placeholder="{{ .strings.query }}">
<span class="button ~neutral @low center inside-input rounded-s-none statistics-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<button class="button ~info @low statistics-query-execute" aria-label="{{ .strings.run }}"><i class="ri-refresh-line"></i></button>
</div>
<div class="flex flex-row gap-2 flex-wrap">
<div class="statistics-sort-by-field"></div>
<span class="flex flex-row gap-2 flex-wrap statistics-filter-area"></span>
</div>
<div class="card ~neutral @low statistics-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</div>
</div>
</div>
</div>
<div id="statistics-container"></div>
</div>
</div>
<div id="tab-settings" class="flex flex-col gap-4 unfocused">
<div class="card @low dark:~d_neutral settings overflow flex flex-col gap-2">
<div class="flex flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
<span class="heading">{{ .strings.settings }}</span>
@@ -912,16 +966,19 @@
</div>
</div>
<div class="flex flex-col md:flex-row gap-3">
<div class="card @low dark:~d_neutral col" id="settings-sidebar">
<div class="md:card @low dark:~d_neutral flex md:flex flex-col gap-2 flex-1" id="settings-sidebar">
<div class="flex flex-row justify-between">
<input type="search" class="field ~neutral @low input settings-section-button justify-between mb-2" id="settings-search" placeholder="{{ .strings.search }}">
<button class="button ~neutral @low center -ml-10 rounded-s-none mb-2 settings-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></button>
<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>
<aside class="aside sm ~urge dark:~d_info mb-2 @low" id="settings-message">Note: <span class="badge ~critical">*</span> indicates a required field, <span class="badge ~info dark:~d_warning">R</span> indicates changes require a restart.</aside>
<span class="button ~neutral @low settings-section-button justify-between mb-2" id="setting-about"><span class="flex">{{ .strings.aboutProgram }} <i class="ri-information-line ml-2"></i></span></span>
<span class="button ~neutral @low settings-section-button justify-between mb-2" id="setting-profiles"><span class="flex">{{ .strings.userProfiles }} <i class="ri-user-line ml-2"></i></span></span>
</div>
<div class="card ~neutral @low col overflow" id="settings-panel">
<div class="card ~neutral @low overflow flex-1" 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>
@@ -936,6 +993,6 @@
</div>
</div>
</div>
<script src="{{ .urlBase }}/js/admin.js" type="module"></script>
<script src="{{ .pages.Base }}/js/admin.js" type="module"></script>
</body>
</html>

View File

@@ -1,12 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<link inline rel="stylesheet" type="text/css" href="bundle.css">
<!--- This CSS is inlined so we should keep this here! -->
<link inline rel="stylesheet" type="text/css" href="web/css/v3bundle.css">
{{ template "header.html" . }}
<title>Crash report</title>
</head>
<body>
<div class="page-container">
<div class="page-container m-2 lg:my-20 lg:mx-64">
<div class="card ~critical sectioned">
<section class="section ~critical">
<span class="heading">Crash report for jfa-go</span>
@@ -40,6 +41,6 @@
</section>
</div>
</div>
<script inline src="crash.js"></script>
<script inline src="web/js/crash.js"></script>
</body>
</html>

View File

@@ -1,12 +1,11 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="{{ .urlBase }}/css/{{ .cssVersion }}bundle.css">
{{ template "header.html" . }}
<title>{{ .strings.successHeader }} - jfa-go</title>
</head>
<body class="section">
<div class="page-container">
<div class="page-container m-2 lg:my-20 lg:mx-64">
<div class="card ~neutral @low mb-4">
<span class="heading mb-4">{{ .strings.successHeader }}</span>
<p class="content my-4">{{ .successMessage }}</p>

View File

@@ -3,7 +3,6 @@
window.usernameEnabled = {{ .username }};
window.validationStrings = JSON.parse({{ .validationStrings }});
window.invalidPassword = "{{ .strings.reEnterPasswordInvalid }}";
window.URLBase = "{{ .urlBase }}";
window.code = "{{ .code }}";
window.language = "{{ .langName }}";
window.messages = JSON.parse({{ .notifications }});
@@ -14,16 +13,13 @@
window.userExpiryHours = {{ .userExpiryHours }};
window.userExpiryMinutes = {{ .userExpiryMinutes }};
window.userExpiryMessage = {{ .userExpiryMessage }};
window.telegramEnabled = {{ .telegramEnabled }};
window.telegramRequired = {{ .telegramRequired }};
window.telegramPIN = "{{ .telegramPIN }}";
window.emailRequired = {{ .emailRequired }};
window.discordEnabled = {{ .discordEnabled }};
window.discordRequired = {{ .discordRequired }};
window.discordPIN = "{{ .discordPIN }}";
window.discordInviteLink = {{ .discordInviteLink }};
window.discordServerName = "{{ .discordServerName }}";
window.matrixEnabled = {{ .matrixEnabled }};
window.matrixRequired = {{ .matrixRequired }};
window.matrixUserID = "{{ .matrixUser }}";
window.captcha = {{ .captcha }};

View File

@@ -1,7 +1,6 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/{{ .cssVersion }}bundle.css">
{{ template "header.html" . }}
{{ if .passwordReset }}
<title>{{ .strings.passwordReset }}</title>
@@ -35,20 +34,11 @@
</div>
</div>
{{ template "account-linking.html" . }}
<div class="top-4 left-4 absolute">
<span class="dropdown" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button">
<i class="ri-global-line"></i>
<span class="ml-2 chev"></span>
</span>
<div class="dropdown-display">
<div class="card ~neutral @low" id="lang-list">
</div>
</div>
</span>
</div>
<div id="notification-box"></div>
<div class="page-container">
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4">
<div class="top-2 inset-x-2 lg:absolute flex flex-row justify-between">
{{ template "lang-select.html" . }}
</div>
<div class="card dark:~d_neutral @low">
<div class="flex flex-col md:flex-row gap-3 items-baseline mb-2">
<span class="heading mr-5">

View File

@@ -1,13 +1,34 @@
<link rel="stylesheet" type="text/css" href="{{ .pages.Base }}/css/{{ .cssVersion }}bundle.css">
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="Description" content="jfa-go, a better way to manage Jellyfin users.">
<meta name="color-scheme" content="dark light">
<meta name="robots" content="noindex">
<link rel="apple-touch-icon" sizes="180x180" href="{{ .urlBase }}/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="{{ .urlBase }}/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="{{ .urlBase }}/favicon-16x16.png">
<link rel="manifest" href="{{ .urlBase }}/site.webmanifest">
<link rel="mask-icon" href="{{ .urlBase }}/safari-pinned-tab.svg" color="#5bbad5">
<link rel="apple-touch-icon" sizes="180x180" href="{{ .pages.Base }}/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="{{ .pages.Base }}/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="{{ .pages.Base }}/favicon-16x16.png">
<link rel="manifest" href="{{ .pages.Base }}/site.webmanifest">
<link rel="mask-icon" href="{{ .pages.Base }}/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#603cba">
<meta name="theme-color" content="#ffffff">
<script>
window.pages = {
"Base": "{{ .pages.Base }}",
"TrueBase": "{{ .pages.TrueBase }}",
"ExternalURI": "{{ .pages.ExternalURI }}",
"Current": "{{ .pages.Current }}",
"Admin": "{{ .pages.Admin }}",
"MyAccount": "{{ .pages.MyAccount }}",
"Form": "{{ .pages.Form }}"
};
window.emailEnabled = {{ .emailEnabled }};
window.discordEnabled = {{ .discordEnabled }};
window.telegramEnabled = {{ .telegramEnabled }};
window.matrixEnabled = {{ .matrixEnabled }};
window.notificationsEnabled = {{ .notifications }};
window.ombiEnabled = {{ .ombiEnabled }};
window.jellyseerrEnabled = {{ .jellyseerrEnabled }};
window.referralsEnabled = {{ .referralsEnabled }};
window.pwrEnabled = {{ .pwrEnabled }};
</script>

View File

@@ -1,12 +1,11 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="{{ .urlBase }}/css/{{ .cssVersion }}bundle.css">
{{ template "header.html" . }}
<title>Invalid Code - jfa-go</title>
</head>
<body class="section">
<div class="page-container">
<div class="page-container m-2 lg:my-20 lg:mx-64">
<div class="card">
<h1 class="text-3xl font-semibold">Invalid invite code.</h1>
<p class="content">The code above was either incorrect, or has expired.</p>

19
html/lang-select.html Normal file
View File

@@ -0,0 +1,19 @@
<span class="dropdown z-[11]" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button">
<i class="ri-global-line"></i>
<span class="ml-2 chev"></span>
</span>
<div class="dropdown-display">
<div class="card ~neutral @low flex flex-col gap-2">
<label class="switch">
<input type="radio" name="lang-time" id="lang-12h">
<span>{{ .strings.time12h }}</span>
</label>
<label class="switch">
<input type="radio" name="lang-time" id="lang-24h">
<span>{{ .strings.time24h }}</span>
</label>
<div id="lang-list"></div>
</div>
</div>
</span>

View File

@@ -15,7 +15,7 @@
{{ $hasTwoCards = 1 }}
<div class="card mx-2 flex-initial w-full lg:w-[35%] mb-4 lg:mb-0 dark:~d_neutral @low content">
<span class="heading row">{{ .strings.loginNotAdmin }}</span>
<a class="button ~info h-12 w-full" href="{{ .urlBase }}/my/account"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
<a class="button ~info h-12 w-full" href="{{ .pages.Base }}{{ .pages.MyAccount }}"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
</div>
{{ end }}
{{ end }}

View File

@@ -1,7 +1,6 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/{{ .cssVersion }}bundle.css">
{{ template "header.html" . }}
<title>{{ .strings.passwordReset }} - jfa-go</title>
</head>
@@ -11,7 +10,7 @@
<span id="copy-notification" class="unfocused">{{ .strings.copied }}</span>
</div>
{{ end }}
<div class="page-container">
<div class="page-container m-2 lg:my-20 lg:mx-64">
<div class="card ~neutral @low mb-4">
<span class="heading mb-4">
{{ if .success }}
@@ -40,6 +39,6 @@
</div>
<i class="content">{{ .contactMessage }}</i>
</div>
<script src="{{ .urlBase }}/js/pwr-pin.js" type="module"></script>
<script src="{{ .pages.Base }}/js/pwr-pin.js" type="module"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,21 @@
<!DOCTYPE html>
<html lang="en" class="light">
<head>
<link rel="stylesheet" type="text/css" href="{{ .urlBase }}/css/{{ .cssVersion }}bundle.css">
<script>
window.URLBase = "{{ .urlBase }}";
window.notificationsEnabled = {{ .notifications }};
window.ombiEnabled = {{ .ombiEnabled }};
window.langFile = JSON.parse({{ .language }});
window.pwrEnabled = {{ .pwrEnabled }};
window.linkResetEnabled = {{ .linkResetEnabled }};
window.language = "{{ .langName }}";
window.telegramEnabled = {{ .telegramEnabled }};
window.telegramRequired = {{ .telegramRequired }};
window.telegramUsername = {{ .telegramUsername }};
window.telegramURL = {{ .telegramURL }};
window.emailEnabled = {{ .emailEnabled }};
window.emailRequired = {{ .emailRequired }};
window.discordEnabled = {{ .discordEnabled }};
window.discordRequired = {{ .discordRequired }};
window.discordServerName = "{{ .discordServerName }}";
window.discordInviteLink = {{ .discordInviteLink }};
window.discordSendPINMessage = "{{ .discordSendPINMessage }}";
window.matrixEnabled = {{ .matrixEnabled }};
window.matrixRequired = {{ .matrixRequired }};
window.matrixUserID = "{{ .matrixUser }}";
window.validationStrings = JSON.parse({{ .validationStrings }});
window.referralsEnabled = {{ .referralsEnabled }};
</script>
{{ template "header.html" . }}
<title>{{ .strings.myAccount }}</title>
@@ -79,33 +70,15 @@
{{ template "login-modal.html" . }}
{{ template "account-linking.html" . }}
<div id="notification-box"></div>
<div class="top-4 left-4 absolute">
<span class="dropdown" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button">
<i class="ri-global-line"></i>
<span class="ml-2 chev"></span>
</span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<label class="switch pb-4">
<input type="radio" name="lang-time" id="lang-12h">
<span>{{ .strings.time12h }}</span>
</label>
<label class="switch pb-4">
<input type="radio" name="lang-time" id="lang-24h">
<span>{{ .strings.time24h }}</span>
</label>
<div id="lang-list"></div>
</div>
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4 unfocused">
<div class="top-2 inset-x-2 lg:absolute flex flex-row justify-between">
<div class="flex flex-row gap-2">
{{ template "lang-select.html" . }}
<span class="button ~warning h-min" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
<span class="button ~critical @low mb-4 unfocused" id="logout-button">{{ .strings.logout }}</span>
</div>
</span>
<span class="button ~warning" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
<span class="button ~critical @low mb-4 unfocused" id="logout-button">{{ .strings.logout }}</span>
</div>
<div class="top-4 right-4 absolute">
<a class="button ~info unfocused" href="/" id="admin-back-button"><i class="ri-arrow-left-fill mr-2"></i>{{ .strings.admin }}</a>
</div>
<div class="page-container unfocused">
<a class="button ~info unfocused h-min" href="/" id="admin-back-button"><i class="ri-arrow-left-fill mr-2"></i>{{ .strings.admin }}</a>
</div>
<div class="card @low dark:~d_neutral mb-4" id="card-user">
<span class="heading mb-2"></span>
</div>
@@ -173,7 +146,7 @@
{{ end }}
</div>
</div>
<script src="{{ .urlBase }}/js/user.js" type="module"></script>
<script src="{{ .pages.Base }}/js/user.js" type="module"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 45 KiB

0
images/jfa-go-icon.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

0
images/jfa-go-icon.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 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

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 72 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 58 KiB

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

@@ -1,3 +1,4 @@
//go:build !external
// +build !external
package main
@@ -5,20 +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
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
@@ -35,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")
logger.Println("Using internal storage")
}

View File

@@ -5,20 +5,21 @@ import (
"time"
"github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
)
func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
user, imported, err := app.js.GetOrImportUser(jfID)
if err != nil {
app.debug.Printf("Failed to get or trigger import for Jellyseerr (user \"%s\"): %v", jfID, err)
app.debug.Printf(lm.FailedImportUser, lm.Jellyseerr, jfID, err)
return
}
if imported {
app.debug.Printf("Jellyseerr: Triggered import for Jellyfin user \"%s\" (ID %d)", jfID, user.ID)
app.debug.Printf(lm.ImportJellyseerrUser, jfID, user.ID)
}
notif, err := app.js.GetNotificationPreferencesByID(user.ID)
if err != nil {
app.debug.Printf("Failed to get notification prefs for Jellyseerr (user \"%s\"): %v", jfID, err)
app.debug.Printf(lm.FailedGetJellyseerrNotificationPrefs, jfID, err)
return
}
@@ -27,7 +28,7 @@ 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("Failed to set Jellyseerr email address: %v\n", err)
app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, jfID, err)
} else {
contactMethods[jellyseerr.FieldEmailEnabled] = email.Contact
}
@@ -51,15 +52,15 @@ func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
if len(contactMethods) != 0 {
err := app.js.ModifyNotifications(jfID, contactMethods)
if err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
}
}
func (app *appContext) SynchronizeJellyseerrUsers() {
users, status, err := app.jf.GetUsers(false)
if err != nil || status != 200 {
app.err.Printf("Failed to get users (%d): %s", status, err)
users, err := app.jf.GetUsers(false)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
return
}
// I'm sure Jellyseerr can handle it,
@@ -76,6 +77,6 @@ func newJellyseerrDaemon(interval time.Duration, app *appContext) *GenericDaemon
app.SynchronizeJellyseerrUsers()
},
)
d.Name("Jellyseerr import daemon")
d.Name("Jellyseerr import")
return d
}

View File

@@ -4,4 +4,6 @@ replace github.com/hrfee/jfa-go/common => ../common
go 1.18
require github.com/hrfee/jfa-go/common v0.0.0-20240728190513-dabef831d769 // indirect
require github.com/hrfee/jfa-go/common v0.0.0-20240728190513-dabef831d769
require github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a // indirect

2
jellyseerr/go.sum Normal file
View File

@@ -0,0 +1,2 @@
github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a h1:qbXZgCqb9eaPSJfLEXczQD2lxTv6jb6silMPIWW9j6o=
github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a/go.mod h1:c5HKkLayo0GrEUDlJwT12b67BL9cdPjP271Xlv/KDRQ=

View File

@@ -12,7 +12,7 @@ import (
"strings"
"time"
"github.com/hrfee/jfa-go/common"
co "github.com/hrfee/jfa-go/common"
)
const (
@@ -28,13 +28,13 @@ type Jellyseerr struct {
userCache map[string]User // Map of jellyfin IDs to users
cacheExpiry time.Time
cacheLength time.Duration
timeoutHandler common.TimeoutHandler
timeoutHandler co.TimeoutHandler
LogRequestBodies bool
AutoImportUsers bool
}
// NewJellyseerr returns an Ombi object.
func NewJellyseerr(server, key string, timeoutHandler common.TimeoutHandler) *Jellyseerr {
func NewJellyseerr(server, key string, timeoutHandler co.TimeoutHandler) *Jellyseerr {
if !strings.HasSuffix(server, API_SUFFIX) {
server = server + API_SUFFIX
}
@@ -55,6 +55,11 @@ func NewJellyseerr(server, key string, timeoutHandler common.TimeoutHandler) *Je
}
}
// SetTransport sets the http.Transport to use for requests. Can be used to set a proxy.
func (js *Jellyseerr) SetTransport(t *http.Transport) {
js.httpClient.Transport = t
}
func (js *Jellyseerr) req(mode string, uri string, data any, queryParams url.Values, headers map[string]string, response bool) (string, int, error) {
var params []byte
if data != nil {
@@ -82,25 +87,23 @@ func (js *Jellyseerr) req(mode string, uri string, data any, queryParams url.Val
}
}
resp, err := js.httpClient.Do(req)
reqFailed := err != nil || !(resp.StatusCode == 200 || resp.StatusCode == 201)
err = co.GenericErrFromResponse(resp, err)
defer js.timeoutHandler()
var responseText string
defer resp.Body.Close()
if response || reqFailed {
if response || err != nil {
responseText, err = js.decodeResp(resp)
if err != nil {
return responseText, resp.StatusCode, err
}
}
if reqFailed {
if err != nil {
var msg ErrorDTO
err = json.Unmarshal([]byte(responseText), &msg)
if err != nil {
return responseText, resp.StatusCode, err
}
if msg.Message == "" {
err = fmt.Errorf("failed (error %d)", resp.StatusCode)
} else {
if msg.Message != "" {
err = fmt.Errorf("got %d: %s", resp.StatusCode, msg.Message)
}
return responseText, resp.StatusCode, err
@@ -145,14 +148,11 @@ func (js *Jellyseerr) ImportFromJellyfin(jfIDs ...string) ([]User, error) {
params := map[string]interface{}{
"jellyfinUserIds": jfIDs,
}
resp, status, err := js.post(js.server+"/user/import-from-jellyfin", params, true)
resp, _, err := js.post(js.server+"/user/import-from-jellyfin", params, true)
var data []User
if err != nil {
return data, err
}
if status != 200 && status != 201 {
return data, fmt.Errorf("failed (error %d)", status)
}
err = json.Unmarshal([]byte(resp), &data)
for _, u := range data {
if u.JellyfinUserID != "" {
@@ -197,15 +197,11 @@ func (js *Jellyseerr) getUserPage(page int) (GetUsersDTO, error) {
if js.LogRequestBodies {
fmt.Printf("Jellyseerr API Client: Sending with URL params \"%+v\"\n", params)
}
resp, status, err := js.get(js.server+"/user", nil, params)
resp, _, err := js.get(js.server+"/user", nil, params)
var data GetUsersDTO
if status != 200 {
return data, fmt.Errorf("failed (error %d)", status)
if err == nil {
err = json.Unmarshal([]byte(resp), &data)
}
if err != nil {
return data, err
}
err = json.Unmarshal([]byte(resp), &data)
return data, err
}
@@ -261,12 +257,9 @@ func (js *Jellyseerr) getUser(jfID string) (User, error) {
}
func (js *Jellyseerr) Me() (User, error) {
resp, status, err := js.get(js.server+"/auth/me", nil, url.Values{})
resp, _, err := js.get(js.server+"/auth/me", nil, url.Values{})
var data User
data.ID = -1
if status != 200 {
return data, fmt.Errorf("failed (error %d)", status)
}
if err != nil {
return data, err
}
@@ -281,13 +274,10 @@ func (js *Jellyseerr) GetPermissions(jfID string) (Permissions, error) {
return data.Permissions, err
}
resp, status, err := js.get(fmt.Sprintf(js.server+"/user/%d/settings/permissions", u.ID), nil, url.Values{})
resp, _, err := js.get(fmt.Sprintf(js.server+"/user/%d/settings/permissions", u.ID), nil, url.Values{})
if err != nil {
return data.Permissions, err
}
if status != 200 {
return data.Permissions, fmt.Errorf("failed (error %d)", status)
}
err = json.Unmarshal([]byte(resp), &data)
return data.Permissions, err
}
@@ -298,13 +288,10 @@ func (js *Jellyseerr) SetPermissions(jfID string, perm Permissions) error {
return err
}
_, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/permissions", u.ID), permissionsDTO{Permissions: perm}, false)
_, _, err = js.post(fmt.Sprintf(js.server+"/user/%d/settings/permissions", u.ID), permissionsDTO{Permissions: perm}, false)
if err != nil {
return err
}
if status != 200 && status != 201 {
return fmt.Errorf("failed (error %d)", status)
}
u.Permissions = perm
js.userCache[jfID] = u
return nil
@@ -316,13 +303,10 @@ func (js *Jellyseerr) ApplyTemplateToUser(jfID string, tmpl UserTemplate) error
return err
}
_, status, err := js.put(fmt.Sprintf(js.server+"/user/%d", u.ID), tmpl, false)
_, _, err = js.put(fmt.Sprintf(js.server+"/user/%d", u.ID), tmpl, false)
if err != nil {
return err
}
if status != 200 && status != 201 {
return fmt.Errorf("failed (error %d)", status)
}
u.UserTemplate = tmpl
js.userCache[jfID] = u
return nil
@@ -337,13 +321,10 @@ func (js *Jellyseerr) ModifyUser(jfID string, conf map[UserField]any) error {
return err
}
_, status, err := js.put(fmt.Sprintf(js.server+"/user/%d", u.ID), conf, false)
_, _, err = js.put(fmt.Sprintf(js.server+"/user/%d", u.ID), conf, false)
if err != nil {
return err
}
if status != 200 && status != 201 {
return fmt.Errorf("failed (error %d)", status)
}
// Lazily just invalidate the cache.
js.cacheExpiry = time.Now()
return nil
@@ -355,10 +336,7 @@ func (js *Jellyseerr) DeleteUser(jfID string) error {
return err
}
status, err := js.delete(fmt.Sprintf(js.server+"/user/%d", u.ID), nil)
if status != 200 && status != 201 {
return fmt.Errorf("failed (error %d)", status)
}
_, err = js.delete(fmt.Sprintf(js.server+"/user/%d", u.ID), nil)
if err != nil {
return err
}
@@ -376,13 +354,10 @@ func (js *Jellyseerr) GetNotificationPreferences(jfID string) (Notifications, er
func (js *Jellyseerr) GetNotificationPreferencesByID(jellyseerrID int64) (Notifications, error) {
var data Notifications
resp, status, err := js.get(fmt.Sprintf(js.server+"/user/%d/settings/notifications", jellyseerrID), nil, url.Values{})
resp, _, err := js.get(fmt.Sprintf(js.server+"/user/%d/settings/notifications", jellyseerrID), nil, url.Values{})
if err != nil {
return data, err
}
if status != 200 {
return data, fmt.Errorf("failed (error %d)", status)
}
err = json.Unmarshal([]byte(resp), &data)
return data, err
}
@@ -397,13 +372,10 @@ func (js *Jellyseerr) ApplyNotificationsTemplateToUser(jfID string, tmpl Notific
return err
}
_, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/notifications", u.ID), tmpl, false)
_, _, err = js.post(fmt.Sprintf(js.server+"/user/%d/settings/notifications", u.ID), tmpl, false)
if err != nil {
return err
}
if status != 200 && status != 201 {
return fmt.Errorf("failed (error %d)", status)
}
return nil
}
@@ -413,13 +385,10 @@ func (js *Jellyseerr) ModifyNotifications(jfID string, conf map[NotificationsFie
return err
}
_, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/notifications", u.ID), conf, false)
_, _, err = js.post(fmt.Sprintf(js.server+"/user/%d/settings/notifications", u.ID), conf, false)
if err != nil {
return err
}
if status != 200 && status != 201 {
return fmt.Errorf("failed (error %d)", status)
}
return nil
}
@@ -429,11 +398,8 @@ func (js *Jellyseerr) GetUsers() (map[string]User, error) {
}
func (js *Jellyseerr) UserByID(jellyseerrID int64) (User, error) {
resp, status, err := js.get(js.server+fmt.Sprintf("/user/%d", jellyseerrID), nil, url.Values{})
resp, _, err := js.get(js.server+fmt.Sprintf("/user/%d", jellyseerrID), nil, url.Values{})
var data User
if status != 200 {
return data, fmt.Errorf("failed (error %d)", status)
}
if err != nil {
return data, err
}
@@ -447,13 +413,10 @@ func (js *Jellyseerr) ModifyMainUserSettings(jfID string, conf MainUserSettings)
return err
}
_, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/main", u.ID), conf, false)
_, _, err = js.post(fmt.Sprintf(js.server+"/user/%d/settings/main", u.ID), conf, false)
if err != nil {
return err
}
if status != 200 && status != 201 {
return fmt.Errorf("failed (error %d)", status)
}
// Lazily just invalidate the cache.
js.cacheExpiry = time.Now()
return nil

48
lang.go
View File

@@ -1,5 +1,11 @@
package main
import (
"fmt"
"github.com/hrfee/jfa-go/common"
)
type langMeta struct {
Name string `json:"name"`
// Language to fall back on if strings are missing. Defaults to en-us.
@@ -13,11 +19,11 @@ type quantityString struct {
type adminLangs map[string]adminLang
func (ls *adminLangs) getOptions() [][2]string {
opts := make([][2]string, len(*ls))
func (ls *adminLangs) getOptions() []common.Option {
opts := make([]common.Option, len(*ls))
i := 0
for key, lang := range *ls {
opts[i] = [2]string{key, lang.Meta.Name}
opts[i] = common.Option{key, lang.Meta.Name}
i++
}
return opts
@@ -42,11 +48,11 @@ type adminLang struct {
type userLangs map[string]userLang
func (ls *userLangs) getOptions() [][2]string {
opts := make([][2]string, len(*ls))
func (ls *userLangs) getOptions() []common.Option {
opts := make([]common.Option, len(*ls))
i := 0
for key, lang := range *ls {
opts[i] = [2]string{key, lang.Meta.Name}
opts[i] = common.Option{key, lang.Meta.Name}
i++
}
return opts
@@ -65,11 +71,11 @@ type userLang struct {
type pwrLangs map[string]pwrLang
func (ls *pwrLangs) getOptions() [][2]string {
opts := make([][2]string, len(*ls))
func (ls *pwrLangs) getOptions() []common.Option {
opts := make([]common.Option, len(*ls))
i := 0
for key, lang := range *ls {
opts[i] = [2]string{key, lang.Meta.Name}
opts[i] = common.Option{key, lang.Meta.Name}
i++
}
return opts
@@ -82,11 +88,11 @@ type pwrLang struct {
type emailLangs map[string]emailLang
func (ls *emailLangs) getOptions() [][2]string {
opts := make([][2]string, len(*ls))
func (ls *emailLangs) getOptions() []common.Option {
opts := make([]common.Option, len(*ls))
i := 0
for key, lang := range *ls {
opts[i] = [2]string{key, lang.Meta.Name}
opts[i] = common.Option{key, lang.Meta.Name}
i++
}
return opts
@@ -106,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
@@ -122,6 +129,7 @@ type setupLang struct {
Login langSection `json:"login"`
JellyfinEmby langSection `json:"jellyfinEmby"`
Ombi langSection `json:"ombi"`
Jellyseerr langSection `json:"jellyseerr"`
Email langSection `json:"email"`
Messages langSection `json:"messages"`
Notifications langSection `json:"notifications"`
@@ -134,11 +142,11 @@ type setupLang struct {
JSON string
}
func (ls *setupLangs) getOptions() [][2]string {
opts := make([][2]string, len(*ls))
func (ls *setupLangs) getOptions() []common.Option {
opts := make([]common.Option, len(*ls))
i := 0
for key, lang := range *ls {
opts[i] = [2]string{key, lang.Meta.Name}
opts[i] = common.Option{key, lang.Meta.Name}
i++
}
return opts
@@ -151,18 +159,18 @@ type telegramLang struct {
Strings langSection `json:"strings"`
}
func (ts *telegramLangs) getOptions() [][2]string {
opts := make([][2]string, len(*ts))
func (ts *telegramLangs) getOptions() []common.Option {
opts := make([]common.Option, len(*ts))
i := 0
for key, lang := range *ts {
opts[i] = [2]string{key, lang.Meta.Name}
opts[i] = common.Option{key, lang.Meta.Name}
i++
}
return opts
}
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
@@ -179,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

@@ -8,6 +8,7 @@
"accounts": "Accounts",
"activity": "Activity",
"settings": "Settings",
"statistics": "Stats",
"inviteMonths": "Months",
"inviteDays": "Days",
"inviteHours": "Hours",
@@ -56,8 +57,11 @@
"unlink": "Unlink Account",
"deleted": "Deleted",
"disabled": "Disabled",
"query": "Query",
"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:",
@@ -136,6 +140,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",
@@ -146,6 +152,7 @@
"postSignupCardDescription": "Card shown to user after signing up. Overrides \"Success Message\". Overriden by \"Auto redirect on success\" setting.",
"buildTime": "Build Time",
"builtBy": "Built By",
"buildTags": "Build Tags",
"loginNotAdmin": "Not an Admin?",
"referrer": "Referrer",
"accountLinked": "{contactMethod} linked: {user}",
@@ -189,6 +196,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.",
@@ -201,7 +211,8 @@
"backupCreated": "Backup created",
"backupCanBeFound": "The backup can be found on the server at {filepath}.",
"backupCanDownload": "Alternatively, click below to download the backup.",
"wikiPage": "Wiki Page"
"wikiPage": "Wiki Page",
"wiki": "Wiki"
},
"notifications": {
"pathCopied": "Full path copied to clipboard.",

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

@@ -112,7 +112,12 @@
"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."
},
"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.",

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

View File

@@ -41,14 +41,17 @@
"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.",
"errorConnection": "Couldn't connect to jfa-go.",
"errorUnknown": "Unknown error.",
"error401Unauthorized": "Unauthorized. Try refreshing the page.",
"errorSaveSettings": "Couldn't save settings."
"errorSaveSettings": "Couldn't save settings.",
"errorSpecialSymbols": "Field cannot contain special symbols."
},
"quantityStrings": {
"year": {
@@ -64,4 +67,4 @@
"plural": "{n} Days"
}
}
}
}

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

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} วัน"
}
}
}

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

@@ -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": "ติดต่อผู้ดูแลระบบสำหรับข้อมูลเพิ่มเติม"
}
}

88
lang/form/ckb-iq.json Normal file
View File

@@ -0,0 +1,88 @@
{
"meta": {
"name": "کوردی سۆرانی"
},
"strings": {
"pageTitle": "دروستکردنی هەژماری Jellyfin",
"createAccountHeader": "دروستکردنی هەژمار",
"accountDetails": "زانیارییەکان",
"emailAddress": "ئیمەیل",
"username": "ناوی بەکارهێنەر",
"oldPassword": "وشەی نهێنی کۆن",
"newPassword": "وشەی نهێنی نوێ",
"password": "وشەی نهێنی",
"reEnterPassword": "دووبارە وشەی نهێنی بنووسەوە",
"reEnterPasswordInvalid": "وشە نهێنییەکان یەک ناگرن.",
"createAccountButton": "هەژمار دروستبکە",
"passwordRequirementsHeader": "داواکارییەکانی وشەی نهێنی",
"successHeader": "سەرکەوت!",
"confirmationRequired": "دووپاتکردنەوەی ئیمەیل داواکراوە",
"confirmationRequiredMessage": "تکایە سەیری نامەکانی ئیمەیلەکەت بکە بۆ دووپاتکردنەوەی ناونیشانەکەت.",
"yourAccountIsValidUntil": "هەژمارەکەت تاکو {date} کاردەکات.",
"sendPIN": "ئەم ژمارە نهێنییەی خوارەوە بۆ بۆتەکە بنێرە، پاشان وەرەوە بۆ پەیوەستکردنی هەژمارەکەت.",
"sendPINDiscord": "{command} لە چەناڵی {server_channel}ی دیسکۆردەکەت بنوسە، پاشان ئەم ژمارە نهێنییەی خوارەوە بنێرە.",
"matrixEnterUser": "",
"welcomeUser": "{user}، بەخێربێیت!",
"addContactMethod": "زیادکردنی ڕێگەی پەیوەندیی پێوە کردنم",
"editContactMethod": "گۆڕینی ڕێگەی پەیوەندیی پێوە کردنم",
"joinTheServer": "چوونە سێرڤەر:",
"customMessagePlaceholderHeader": "دەستکاریکردنی ئەم کارتە",
"customMessagePlaceholderContent": "",
"userPageSuccessMessage": "دواتر دەتوانیت زانیاری لەسەر هەژمارەکەت ببینیت و دەستکاری بکەیت لە بەشی {myAccount}.",
"resetPassword": "هێنانەوەی تێپەڕەوشەی نهێنی",
"resetPasswordThroughJellyfin": "بۆ هێنانەوەی تێپەڕەوشەکەت، سەردانی {jfLink} بکە و گرتە لە \"تێپەڕەوشەم بیرچووە\" بکە.",
"resetPasswordThroughLink": "",
"resetPasswordThroughLinkStart": "",
"resetPasswordThroughLinkEnd": "",
"resetPasswordUsername": "",
"resetPasswordEmail": "",
"resetPasswordContactMethod": "",
"resetSent": "",
"resetSentDescription": "",
"changePassword": "",
"referralsDescription": "",
"referralsWithExpiryDescription": "",
"copyReferral": "",
"invitedBy": ""
},
"notifications": {
"errorUserExists": "",
"errorInvalidCode": "",
"errorAccountLinked": "",
"errorEmailLinked": "",
"errorTelegramVerification": "",
"errorDiscordVerification": "",
"errorMatrixVerification": "",
"errorInvalidPIN": "",
"errorUnknown": "",
"errorNoEmail": "",
"errorCaptcha": "",
"errorPassword": "",
"errorNoMatch": "",
"errorOldPassword": "",
"passwordChanged": "",
"verified": ""
},
"validationStrings": {
"length": {
"singular": "",
"plural": ""
},
"uppercase": {
"singular": "",
"plural": ""
},
"lowercase": {
"singular": "",
"plural": ""
},
"number": {
"singular": "",
"plural": ""
},
"special": {
"singular": "",
"plural": ""
}
}
}

View File

@@ -17,7 +17,7 @@
"confirmationRequired": "E-Mail Bestätigung erforderlich",
"confirmationRequiredMessage": "Bitte überprüfe deinen Posteingang und bestätige deine E-Mail-Adresse.",
"yourAccountIsValidUntil": "Dein Konto wird bis zum {date} gültig sein.",
"sendPIN": "Sende die untenstehende PIN an den Bot und komm dann hierher zurück, um dein Konto zu verbinden.",
"sendPIN": "Sende die PIN unten an den Bot und komm dann hierher zurück, um dein Konto zu verknüpfen.",
"sendPINDiscord": "Gib auf Discord {command} in {server_channel} ein und sende die untenstehende PIN.",
"matrixEnterUser": "Gib deine Benutzer-ID ein und drücke auf Absenden. Anschließend erhälst du ein PIN, die hier eingegeben wird um fortzufahren.",
"oldPassword": "Altes Passwort",
@@ -30,7 +30,20 @@
"joinTheServer": "Server beitreten:",
"userPageSuccessMessage": "Du kannst die Details deines Accounts später in {myAccount} Seite einsehen und ändern.",
"resetPassword": "Passwort zurücksetzen",
"resetPasswordThroughJellyfin": "Um das Passwort zurückzusetzen, besuche {jfLink} und drücke auf die Schaltfläche \"Passwort vergessen\"."
"resetPasswordThroughJellyfin": "Um das Passwort zurückzusetzen, besuche {jfLink} und drücke auf die Schaltfläche \"Passwort vergessen\".",
"resetPasswordThroughLinkStart": "Um dein Passwort zurückzusetzen, gib eine der folgenden Möglichkeiten ein:",
"resetPasswordContactMethod": "Den Benutzernamen einer Kontaktmethode, die mit deinem Konto verknüpft ist",
"changePassword": "Passwort ändern",
"resetPasswordThroughLink": "Um dein Passwort zurückzusetzen, gib deinen Benutzernamen, deine E-Mail-Adresse oder einer verlinkten Kontaktmethode ein und sende ihn ab. Du erhältst einen Link zum Zurücksetzen deines Passworts.",
"resetSent": "Infos zum Zurücksetzen wurden gesendet.",
"resetSentDescription": "Wenn ein Konto mit dem angegebenen Benutzernamen/der angegebenen Kontaktmethode existiert, wurde ein Link zum Zurücksetzen des Passworts über alle verfügbaren Kontaktmethoden verschickt. Der Code ist 30 Minuten gültig.",
"referralsDescription": "Lade Freunde & Familie mit diesem Link zu Jellyfin ein. Wenn er abläuft, kannst du hier einen neuen Link anfordern.",
"copyReferral": "Link kopieren",
"invitedBy": "Du wurdest von {user} eingeladen.",
"resetPasswordThroughLinkEnd": "Drücke dann auf Abschicken. Du erhältst einen Link, mit dem du dein Passwort zurücksetzen kannst.",
"resetPasswordUsername": "Dein Jellyfin-Benutzername",
"resetPasswordEmail": "Deine E-Mail Adresse",
"referralsWithExpiryDescription": "Lade Freunde & Familie mit diesem Link zu Jellyfin ein. Der Link wird deaktiviert, sobald er abläuft."
},
"validationStrings": {
"length": {
@@ -67,6 +80,10 @@
"errorNoEmail": "E-Mail Adresse erforderlich.",
"errorCaptcha": "Captcha falsch.",
"errorPassword": "Prüfe die Passwortanforderungen.",
"errorNoMatch": "Passwörter stimmen nicht überein."
"errorNoMatch": "Passwörter stimmen nicht überein.",
"errorAccountLinked": "Konto wird bereits verwendet.",
"errorEmailLinked": "E-Mail wird bereits verwendet.",
"passwordChanged": "Das Passwort wurde geändert.",
"errorOldPassword": "Das alte Passwort ist falsch."
}
}

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

@@ -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.",

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} ตัว"
}
}
}

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": "กรอกรหัสผ่านใหม่ด้านล่าง"
}
}

View File

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

View File

@@ -147,4 +147,4 @@
"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

@@ -33,7 +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.",
"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": {
@@ -47,7 +49,9 @@
"title": "General",
"listenAddress": "Listen Address",
"urlBase": "URL Base",
"urlBaseNotice": "Only needed if using a reverse proxy on a subdomain (e.g 'jellyf.in/accounts').",
"urlBaseNotice": "Only needed if using a reverse proxy on a subfolder (e.g 'jellyf.in/accounts').",
"externalURL": "External jfa-go URL",
"externalURLNotice": "The URL you'll be accessing jfa-go from. Used to generate links for things like password resets. Make sure to include the above URL base if you set one.",
"lightTheme": "Light",
"darkTheme": "Dark",
"useHTTPS": "Use HTTPS",
@@ -94,7 +98,14 @@
"ombi": {
"title": "Ombi",
"description": "By connecting to Ombi, both a Jellyfin and Ombi account will be created when a user joins through jfa-go. After setup is finished, go to Settings to set a default profile for new ombi users.",
"apiKeyNotice": "Find this in the first tab of Ombi settings."
"apiKeyNotice": "Find this in the first tab of Ombi settings.",
"stabilityWarning": "Warning: Ombi integration is unstable, and can cause issues. Jellyseerr is recommended instead. See {n} for more info."
},
"jellyseerr": {
"title": "Jellyseerr",
"description": "Jellyseerr is an alternative to Ombi, and integrates with jfa-go slightly better. Again, after setup is finished, go to Settings to create a profile and add a template for new Jellyseerr accounts.",
"importExisting": "Import existing users",
"importExistingDescription": "If enabled, your existing users will have contact details and preferences from jfa-go synchronized."
},
"messages": {
"title": "Messages",
@@ -134,6 +145,7 @@
"passwordResets": {
"title": "Password Resets",
"description": "When a user tries to reset their password, Jellyfin creates a file named 'passwordreset-*.json' which contains a PIN. jfa-go reads the file and sends the PIN to the user. If you enabled the \"User Page\" feature, a reset can also be performed there, given a username, email, or contact method.",
"moreInfo": "More information about the different ways of resetting passwords can be found on {n}.",
"pathToJellyfin": "Path to Jellyfin configuration directory",
"pathToJellyfinNotice": "If you don't know where this is, try resetting your password in Jellyfin. A popup with '<path to jellyfin>/passwordreset-*.json' will appear. This is not necessary if you only want to use self-service password resets through the \"User Page\".",
"resetLinks": "Send a link instead of a PIN",
@@ -155,6 +167,7 @@
"helpMessages": {
"title": "Help Messages",
"description": "These messages will display in the account creation page and in some emails.",
"markdownMessageNotice": "Contents of some emails, pages and messages can be customized further with markdown in Settings.",
"contactMessage": "Contact Message",
"contactMessageNotice": "Displays at the bottom of all pages except admin.",
"helpMessage": "Help Message",

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

@@ -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."
}
}
}

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