Compare commits

..

243 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/nl/
2023-12-26 16:33:01 +01:00
Harvey Tindall
7223981280 done: use sshkey from secret, not file
update to appleboy/drone-ssh requires special ownership of the ssh key
file, which I can't be bothered with, so we'll source it from a secret
instead. Probably better anyway, that's how the other key was already.
2023-12-26 15:01:33 +00:00
Harvey Tindall
47536f3e63 readme: update latest compatible version 2023-12-26 14:51:53 +00:00
Harvey Tindall
ac4fecd819 site: fix for new font 2023-12-26 14:36:13 +00:00
Harvey Tindall
b75bd4d6c5 Crash on SSL cert/key error, describe issue in log
if serving ssl/tls fails, the cert/key files are checked to see if they
    are accessible, and any errors logged.
2023-12-26 14:19:22 +00:00
Harvey Tindall
2be7baea4a trim base css of most redundant classes 2023-12-24 18:55:58 +00:00
Harvey Tindall
d56d45a404 userpage: rework dynamic layout, finally 2023-12-24 18:26:35 +00:00
Harvey Tindall
b50d66d265 ui: more modal fixes 2023-12-24 15:16:11 +00:00
Harvey Tindall
aec0a5349a ui: fix remaining few modal sizes on mobile 2023-12-24 15:04:58 +00:00
Harvey Tindall
20560332ed invites: improve inv dropdown wrapping 2023-12-24 14:53:37 +00:00
Harvey Tindall
202ee0977e invites: improve inv dropdown layout 2023-12-24 14:34:04 +00:00
Harvey Tindall
f460bfcfc6 logip: fix user logging 2023-12-24 13:24:18 +00:00
Harvey Tindall
4f5d12f800 invites: ui adjustments, fix duration display > 1y 2023-12-24 02:29:14 +00:00
Harvey Tindall
9092b98b28 accounts: hide previous date example in extend expiry 2023-12-24 01:52:16 +00:00
Harvey Tindall
0f72a85724 accounts: allow extending expiry of more than one user 2023-12-24 01:45:11 +00:00
Harvey Tindall
0840931fed Merge (optional) IP logging 2023-12-24 01:06:07 +00:00
Harvey Tindall
00379824df Merge branch 'main' into kimboslice99-main 2023-12-23 21:53:39 +00:00
Harvey Tindall
f823705e40 ips: log on activities, show on card 2023-12-23 21:47:41 +00:00
Harvey Tindall
269836fc99 ips: add advanced settings for ip logging 2023-12-23 21:00:32 +00:00
Harvey Tindall
49d8c6f8e4 pwr: add captcha daemon 2023-12-23 20:18:16 +00:00
Harvey Tindall
278588ca39 pwr: functioning captcha/recaptcha 2023-12-23 20:10:48 +00:00
Harvey Tindall
ab05c07469 form: modularize captcha somewhat 2023-12-23 18:20:09 +00:00
kimboslice99
04c94ba55a Log IPs 2023-12-23 13:09:49 -05:00
Harvey Tindall
6e205760c3 ui: more invites page improvements/cleanup, fix tooltips on mobile 2023-12-23 17:45:18 +00:00
Harvey Tindall
82032b98a8 invites: improve invite wrapping on mobile 2023-12-23 15:36:28 +00:00
Harvey Tindall
e8666d5bf2 ui: general adjustments 2023-12-22 21:40:56 +00:00
Harvey Tindall
d1affe271c ui: wrap settings header 2023-12-22 18:36:21 +00:00
Harvey Tindall
ea109c7b63 ui: wrap accounts/activity headers 2023-12-22 18:06:12 +00:00
Harvey Tindall
cb5a8c1c23 accounts: position filter dropdown better for mobile 2023-12-22 17:46:57 +00:00
Harvey Tindall
7f518f55b2 Merge backups
Backups, manual & scheduled
2023-12-21 21:53:50 +00:00
175 changed files with 12907 additions and 8652 deletions

View File

@@ -1,183 +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
volumes:
- name: ssh_key
path: /root/drone_rsa
settings:
host:
from_secret: ssh2_host
username:
from_secret: ssh2_username
port:
from_secret: ssh2_port
volumes:
- /root/.ssh/docker-build:/root/drone_rsa
key_path: /root/drone_rsa
command_timeout: 50m
script:
- /mnt/buildx/jfa-go/build.sh stable
- wget https://builds.hrfee.pw/upload.py -O /mnt/buildx/jfa-go/jfa-go/upload.py
- pip3 install requests
- bash -c 'cd /mnt/buildx/jfa-go/jfa-go && BUILDRONE_KEY=$(cat /mnt/buildx/jfa-go/key) python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-stable=true'
- rm -f /mnt/buildx/jfa-go/jfa-go/upload.py
trigger:
event:
- tag
volumes:
- name: ssh_key
host:
path: /root/.ssh/docker-build
---
name: jfa-go-git
kind: pipeline
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
volumes:
- name: ssh_key
path: /root/drone_rsa
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
settings:
host:
from_secret: ssh2_host
username:
from_secret: ssh2_username
port:
from_secret: ssh2_port
volumes:
- /root/.ssh/docker-build:/root/drone_rsa
envs:
- buildrone_key
key_path: /root/drone_rsa
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
volumes:
- name: ssh_key
host:
path: /root/.ssh/docker-build
---
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

@@ -1,3 +1,5 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
version: 2
project_name: jfa-go
release:
github:
@@ -6,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
@@ -60,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:
@@ -67,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:
@@ -78,19 +61,22 @@ builds:
dir: ./
env:
- CGO_ENABLED=1
- CC=x86_64-linux-gnu-gcc
- CXX=x86_64-linux-gnu-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
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
@@ -98,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
@@ -108,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:
@@ -137,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
@@ -146,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
@@ -155,7 +161,7 @@ nfpms:
license: MIT
vendor: hrfee.dev
version_metadata: git
builds:
ids:
- linux-tray
contents:
- src: ./LICENSE
@@ -172,9 +178,12 @@ nfpms:
- jfa-go
dependencies:
- libayatana-appindicator
- libolm-dev
rpm:
dependencies:
- libappindicator-gtk3
- libolm
apk:
dependencies:
- libayatana-appindicator
- olm

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,30 +1,26 @@
FROM --platform=$BUILDPLATFORM golang:latest AS support
# Use this instead if hrfee/jfa-go-build-docker doesn't support your architecture
# FROM --platform=$BUILDPLATFORM golang:latest AS support
FROM --platform=$BUILDPLATFORM 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
RUN apt-get update -y \
&& apt-get install build-essential python3-pip curl software-properties-common sed -y \
&& (curl -sL https://deb.nodesource.com/setup_current.x | bash -) \
&& apt-get install nodejs \
&& (cd /opt/build; make configuration npm email typescript variants-html bundle-css inline-css swagger copy INTERNAL=off GOESBUILD=on) \
&& 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
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" ]

175
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
.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
@@ -94,95 +102,132 @@ 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)
all: $(BUILDDEPS) $(GO_TARGET)
compress:
upx --lzma $(GO_TARGET)
install:
cp -r build $(DESTDIR)/jfa-go
@@ -194,6 +239,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,41 +13,35 @@
Studies mean I can't work on this project a lot outside of breaks, however I hope i'll be able to fit in general support and things like bug fixes into my time. New features and such will likely come in short bursts throughout the year (if they do at all).
#### Does/Will it still work?
jfa-go currently works on Jellyfin 10.8.9, the latest version. I should be able to maintain compatability in the future, unless any big changes occur.
jfa-go currently works on Jellyfin 10.9.8, the latest version as of 31/07/2024. I should be able to maintain compatability in the future, unless any big changes occur.
#### 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 now [Emby](https://emby.media/)) that provides invite-based account creation as well as other features that make one's instance much easier to manage.
a rewrite of [jellyfin-accounts](https://github.com/hrfee/jellyfin-accounts) (original naming for both, ik
😂).
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) (and [Emby](https://emby.media/) as 2nd class) that provides invite-based account creation as well as other features that make one's instance much easier to manage.
#### Features
* 🧑 Invite based account creation: Send invites to your friends or family, and let them choose their own username and password without relying on you.
* Send invites via a link and/or email
* Send invites via a link and/or email, discord, telegram or matrix
* Granular control over invites: Validity period as well as number of uses can be specified.
* Account profiles: Assign settings profiles to invites so new users have your predefined permissions, homescreen layout, etc. applied to their account on creation.
* Password validation: Ensure users choose a strong password.
* CAPTCHAs can be enabled to avoid bots
* CAPTCHAs and contact method verificatoin can be enabled to avoid bots.
* ⌛ User expiry: Specify a validity period, and new users accounts will be disabled/deleted after it. The period can be manually extended too.
* 🔗 Ombi Integration: Automatically creates Ombi accounts for new users using their email address and login details, and your own defined set of permissions.
* Account management: Apply settings to your users individually or en masse, and delete users, optionally sending them an email notification with a reason.
* 🔗 Ombi/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.
* "My Account" Page: Allows users to reset their password, manage contact details, view their account expiry date, and send referrals. Custom messages can be added, with markdown.
* Referrals: Users can be given special invites to send to their friends and families.
* 📨 Email storage: Add your existing users email addresses through the UI, and jfa-go will ask new users for them on account creation.
* Email addresses can optionally be used instead of usernames
* "My Account" Page: Allows users to reset their password, manage contact details, view their account expiry date, and send referrals. Can be customized with markdown.
* Referrals: Users can be given special invites to send to their friends and families, similar to some invite-only services like Bluesky.
* 🔑 Password resets: When users forget their passwords and request a change in Jellyfin, jfa-go reads the PIN from the created file and sends it straight to them via email/telegram.
* Can also be done through the "My Account" page if enabled.
* Admin Notifications: Get notified when someone creates an account, or an invite expires.
* 📣 Announcements: Bulk message your users with announcements about your server.
* Authentication via Jellyfin: Instead of using separate credentials for jfa-go and Jellyfin, jfa-go can use it as the authentication provider.
* Enables the usage of jfa-go by multiple people
* 🌓 Customizations
* Customize emails with variables and markdown
* Specify contact and help messages to appear in emails and pages
@@ -65,6 +59,8 @@ a rewrite of [jellyfin-accounts](https://github.com/hrfee/jellyfin-accounts) (or
**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 \
@@ -72,7 +68,7 @@ docker create \
-p 8056:8056 \
# -p 8057:8057 if using tls
-v /path/to/.config/jfa-go:/data \ # Path to wherever you want to store the config file and other data
-v /path/to/jellyfin:/jf \ # Path to Jellyfin config directory, ignore if using Emby
-v /path/to/jellyfin:/jf \ # Only needed for password resets through Jellyfin, ignore if not using or using Emby
-v /etc/localtime:/etc/localtime:ro \ # Makes sure time is correct
hrfee/jfa-go # hrfee/jfa-go:unstable for latest build from git
```
@@ -80,7 +76,7 @@ docker create \
##### [Debian/Ubuntu](https://apt.hrfee.dev)
```sh
sudo apt-get update && sudo apt-get install curl apt-transport-https gnupg
curl https://apt.hrfee.dev/hrfee.pubkey.gpg | sudo apt-key add -
curl https://apt.hrfee.dev/hrfee.pubkey.gpg | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/apt.hrfee.dev.gpg
# For stable releases
echo "deb https://apt.hrfee.dev trusty main" | sudo tee /etc/apt/sources.list.d/hrfee.list
@@ -94,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
# ------
```
@@ -108,7 +104,7 @@ Available on the AUR as:
##### Other platforms
Download precompiled binaries from:
* [The releases section](https://github.com/hrfee/jfa-go/releases) (stable)
* [Buildrone](https://builds.hrfee.dev/view/hrfee/jfa-go) (nightly)
* [dl.jfa-go.com](https://dl.jfa-go.com) (nightly)
unzip the `jfa-go`/`jfa-go.exe` executable to somewhere useful.
* For \*nix/macOS users, `chmod +x jfa-go` then place it somewhere in your PATH like `/usr/bin`.
@@ -147,6 +143,8 @@ Usage of jfa-go:
alternate port to host web ui on.
-pprof
Exposes pprof profiler on /debug/pprof.
-restore string
path to database backup to restore.
-swagger
Enable swagger at /swagger/index.html
```
@@ -154,18 +152,9 @@ Usage of jfa-go:
#### Systemd
jfa-go does not run as a daemon by default. Run `jfa-go systemd` to create a systemd `.service` file in your current directory, which you can copy into `~/.config/systemd/user` or somewhere else.
---
If you're switching from jellyfin-accounts, copy your existing `~/.jf-accounts` to:
* `XDG_CONFIG_DIR/jfa-go` (usually ~/.config/jfa-go) on \*nix systems,
* `%AppData%/jfa-go` on Windows,
* `~/Library/Application Support/jfa-go` on macOS.
(or specify config/data path with `-config/-data` respectively.)
#### Contributing
See [the wiki page](https://wiki.jfa-go.com/docs/dev/) or [CONTRIBUTING.md](https://github.com/hrfee/jfa-go/blob/main/CONTRIBUTING.md).
See [the wiki page](https://wiki.jfa-go.com/docs/dev/).
##### Translation
[![Translation status](https://weblate.jfa-go.com/widgets/jfa-go/-/multi-auto.svg)](https://weblate.jfa-go.com/engage/jfa-go/)
@@ -175,4 +164,3 @@ For translations, use the weblate instance [here](https://weblate.jfa-go.com/eng
Big thanks to those who sponsor me. You can see them below:
[<img src="https://sponsors-endpoint.hrfee.pw/sponsor/avatar/0" width="35">](https://sponsors-endpoint.hrfee.pw/sponsor/profile/0)
[<img src="https://sponsors-endpoint.hrfee.pw/sponsor/avatar/1" width="35">](https://sponsors-endpoint.hrfee.pw/sponsor/profile/0)

View File

@@ -2,6 +2,7 @@ package main
import (
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/timshannon/badgerhold/v4"
)
@@ -85,7 +86,7 @@ func activitySourceToString(v ActivitySource) string {
return "anon"
}
// @Summary Get the requested set of activities, Paginated, filtered and sorted.
// @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"
// @Success 200 {object} GetActivitiesRespDTO
@@ -120,7 +121,7 @@ func (app *appContext) GetActivities(gc *gin.Context) {
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{
@@ -138,17 +139,18 @@ func (app *appContext) GetActivities(gc *gin.Context) {
InviteCode: act.InviteCode,
Value: act.Value,
Time: act.Time.Unix(),
IP: act.IP,
}
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
resp.Activities[i].Username = act.Value
resp.Activities[i].Value = ""
} else if user, status, err := app.jf.UserByID(act.UserID, false); status == 200 && err == nil {
} else if user, err := app.jf.UserByID(act.UserID, false); err == nil {
resp.Activities[i].Username = user.Name
}
if (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != "" {
user, status, err := app.jf.UserByID(act.Source, false)
if status == 200 && err == nil {
user, err := app.jf.UserByID(act.Source, false)
if err == nil {
resp.Activities[i].SourceUsername = user.Name
}
}

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

@@ -8,9 +8,9 @@ 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"
)
const (
@@ -29,6 +29,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 +53,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(),
})
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 +66,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(),
})
} else if used {
del := false
newInv := inv
@@ -174,7 +80,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
InviteCode: code,
Value: inv.Label,
Time: time.Now(),
})
}, nil, false)
} else if newInv.RemainingUses != 0 {
// 0 means infinite i guess?
newInv.RemainingUses--
@@ -187,6 +93,67 @@ 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)
}
}
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.Code, data, app, 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 +163,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)
@@ -230,13 +197,12 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
if req.SendTo != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
addressValid := false
discord := ""
app.debug.Printf("%s: Sending invite message", 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)
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipNoUser, req.SendTo)
} else if len(users) > 1 {
invite.SendTo = fmt.Sprintf("Failed: Multiple users found: \"%s\"", req.SendTo)
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipMultiUser, req.SendTo)
} else {
invite.SendTo = req.SendTo
addressValid = true
@@ -249,8 +215,10 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
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)
// Slight misuse of the template
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, req.SendTo, err)
app.err.Printf(lm.FailedConstructInviteMessage, invite.Code, err)
} else {
var err error
if discord != "" {
@@ -259,10 +227,10 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
err = app.email.send(msg, req.SendTo)
}
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)
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, err)
app.err.Println(invite.SendTo)
} else {
app.info.Printf("%s: Sent invite email to \"%s\"", invite.Code, req.SendTo)
app.info.Printf(lm.SentInviteMessage, invite.Code, req.SendTo)
}
}
}
@@ -285,7 +253,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
InviteCode: invite.Code,
Value: invite.Label,
Time: time.Now(),
})
}, gc, false)
respondBool(200, true, gc)
}
@@ -297,7 +265,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
@@ -305,7 +272,8 @@ func (app *appContext) GetInvites(gc *gin.Context) {
if inv.IsReferral {
continue
}
_, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
years, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
months += years * 12
invite := inviteDTO{
Code: inv.Code,
Months: months,
@@ -331,7 +299,7 @@ func (app *appContext) GetInvites(gc *gin.Context) {
if err != nil {
date, err := timefmt.Parse(pair[1], app.datePattern+" "+app.timePattern)
if err != nil {
app.err.Printf("Failed to parse usedBy time: %v", err)
app.err.Printf(lm.FailedParseTime, err)
}
unix = date.Unix()
}
@@ -346,7 +314,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")
@@ -364,23 +331,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)
}
@@ -392,14 +344,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
}
@@ -423,11 +374,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
@@ -435,9 +386,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")
@@ -452,15 +402,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)
@@ -479,7 +426,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)
@@ -492,12 +438,12 @@ func (app *appContext) DeleteInvite(gc *gin.Context) {
InviteCode: req.Code,
Value: inv.Label,
Time: time.Now(),
})
}, gc, false)
app.info.Printf("%s: Invite deleted", req.Code)
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)
}

165
api-jellyseerr.go Normal file
View File

@@ -0,0 +1,165 @@
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.
// @Produce json
// @Success 200 {object} ombiUsersDTO
// @Failure 500 {object} stringResponse
// @Router /jellyseerr/users [get]
// @Security Bearer
// @tags Jellyseerr
func (app *appContext) JellyseerrUsers(gc *gin.Context) {
users, err := app.js.GetUsers()
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyseerr, err)
respond(500, "Couldn't get users", gc)
return
}
userlist := make([]ombiUser, len(users))
i := 0
for _, u := range users {
userlist[i] = ombiUser{
Name: u.Name(),
ID: strconv.FormatInt(u.ID, 10),
}
i++
}
gc.JSON(200, ombiUsersDTO{Users: userlist})
}
// @Summary Store Jellyseerr user template in an existing profile.
// @Produce json
// @Param id path string true "Jellyseerr ID of user to source from"
// @Param profile path string true "Name of profile to store in"
// @Success 200 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Router /profiles/jellyseerr/{profile}/{id} [post]
// @Security Bearer
// @tags Jellyseerr
func (app *appContext) SetJellyseerrProfile(gc *gin.Context) {
jellyseerrID, err := strconv.ParseInt(gc.Param("id"), 10, 64)
if err != nil {
respondBool(400, false, gc)
return
}
escapedProfileName := gc.Param("profile")
profileName, _ := url.QueryUnescape(escapedProfileName)
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respondBool(400, false, gc)
return
}
u, err := app.js.UserByID(jellyseerrID)
if err != nil {
app.err.Printf(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(lm.FailedGetJellyseerrNotificationPrefs, gc.Param("id"), err)
respond(500, "Couldn't get user notification prefs", gc)
return
}
profile.Jellyseerr.Notifications = n.NotificationsTemplate
profile.Jellyseerr.Enabled = true
app.storage.SetProfileKey(profileName, profile)
respondBool(204, true, gc)
}
// @Summary Remove jellyseerr user template from a profile.
// @Produce json
// @Param profile path string true "Name of profile to store in"
// @Success 200 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Router /profiles/jellyseerr/{profile} [delete]
// @Security Bearer
// @tags Jellyseerr
func (app *appContext) DeleteJellyseerrProfile(gc *gin.Context) {
escapedProfileName := gc.Param("profile")
profileName, _ := url.QueryUnescape(escapedProfileName)
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respondBool(400, false, gc)
return
}
profile.Jellyseerr.Enabled = false
app.storage.SetProfileKey(profileName, profile)
respondBool(204, true, gc)
}
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

@@ -5,6 +5,8 @@ import (
"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"
)
@@ -26,18 +28,20 @@ func (app *appContext) GetCustomContent(gc *gin.Context) {
adminLang = app.storage.lang.chosenAdminLang
}
list := emailListDTO{
"UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.MustGetCustomContentKey("UserCreated").Enabled},
"InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.MustGetCustomContentKey("InviteExpiry").Enabled},
"PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.MustGetCustomContentKey("PasswordReset").Enabled},
"UserDeleted": {Name: app.storage.lang.Email[lang].UserDeleted["name"], Enabled: app.storage.MustGetCustomContentKey("UserDeleted").Enabled},
"UserDisabled": {Name: app.storage.lang.Email[lang].UserDisabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserDisabled").Enabled},
"UserEnabled": {Name: app.storage.lang.Email[lang].UserEnabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserEnabled").Enabled},
"InviteEmail": {Name: app.storage.lang.Email[lang].InviteEmail["name"], Enabled: app.storage.MustGetCustomContentKey("InviteEmail").Enabled},
"WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.MustGetCustomContentKey("WelcomeEmail").Enabled},
"EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.MustGetCustomContentKey("EmailConfirmation").Enabled},
"UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpired").Enabled},
"UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.MustGetCustomContentKey("UserLogin").Enabled},
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("UserPage").Enabled},
"UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.MustGetCustomContentKey("UserCreated").Enabled},
"InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.MustGetCustomContentKey("InviteExpiry").Enabled},
"PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.MustGetCustomContentKey("PasswordReset").Enabled},
"UserDeleted": {Name: app.storage.lang.Email[lang].UserDeleted["name"], Enabled: app.storage.MustGetCustomContentKey("UserDeleted").Enabled},
"UserDisabled": {Name: app.storage.lang.Email[lang].UserDisabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserDisabled").Enabled},
"UserEnabled": {Name: app.storage.lang.Email[lang].UserEnabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserEnabled").Enabled},
"UserExpiryAdjusted": {Name: app.storage.lang.Email[lang].UserExpiryAdjusted["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpiryAdjusted").Enabled},
"InviteEmail": {Name: app.storage.lang.Email[lang].InviteEmail["name"], Enabled: app.storage.MustGetCustomContentKey("InviteEmail").Enabled},
"WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.MustGetCustomContentKey("WelcomeEmail").Enabled},
"EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.MustGetCustomContentKey("EmailConfirmation").Enabled},
"UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpired").Enabled},
"UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.MustGetCustomContentKey("UserLogin").Enabled},
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("UserPage").Enabled},
"PostSignupCard": {Name: app.storage.lang.Admin[adminLang].Strings["postSignupCard"], Enabled: app.storage.MustGetCustomContentKey("PostSignupCard").Enabled, Description: app.storage.lang.Admin[adminLang].Strings["postSignupCardDescription"]},
}
filter := gc.Query("filter")
@@ -51,39 +55,6 @@ func (app *appContext) GetCustomContent(gc *gin.Context) {
gc.JSON(200, list)
}
// No longer needed, these are stored by string keys in the database now.
/* func (app *appContext) getCustomMessage(id string) *CustomContent {
switch id {
case "Announcement":
return &CustomContent{}
case "UserCreated":
return &app.storage.customEmails.UserCreated
case "InviteExpiry":
return &app.storage.customEmails.InviteExpiry
case "PasswordReset":
return &app.storage.customEmails.PasswordReset
case "UserDeleted":
return &app.storage.customEmails.UserDeleted
case "UserDisabled":
return &app.storage.customEmails.UserDisabled
case "UserEnabled":
return &app.storage.customEmails.UserEnabled
case "InviteEmail":
return &app.storage.customEmails.InviteEmail
case "WelcomeEmail":
return &app.storage.customEmails.WelcomeEmail
case "EmailConfirmation":
return &app.storage.customEmails.EmailConfirmation
case "UserExpired":
return &app.storage.customEmails.UserExpired
case "UserLogin":
return &app.storage.userPage.Login
case "UserPage":
return &app.storage.userPage.Page
}
return nil
} */
// @Summary Sets the corresponding custom content.
// @Produce json
// @Param CustomContent body CustomContent true "Content = email (in markdown)."
@@ -164,7 +135,7 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress")
customMessage, ok := app.storage.GetCustomContentKey(id)
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
}
@@ -177,7 +148,11 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
} else if id == "UserLogin" {
variables = []string{}
customMessage.Variables = variables
} else if id == "PostSignupCard" {
variables = []string{"{username}", "{myAccountURL}"}
customMessage.Variables = variables
}
content = customMessage.Content
noContent := content == ""
if !noContent {
@@ -217,6 +192,11 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
msg, err = app.email.constructEnabled("", app, true)
}
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
case "UserExpiryAdjusted":
if noContent {
msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", app, true)
}
values = app.email.expiryAdjustedValues(username, time.Now(), app.storage.lang.Email[lang].Strings.get("reason"), app, false, true)
case "InviteEmail":
if noContent {
msg, err = app.email.constructInvite("", Invite{}, app, true)
@@ -237,14 +217,14 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
msg, err = app.email.constructUserExpired(app, true)
}
values = app.email.userExpiredValues(app, false)
case "UserLogin", "UserPage":
case "UserLogin", "UserPage", "PostSignupCard":
values = map[string]interface{}{}
}
if err != nil {
respondBool(500, false, gc)
return
}
if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" {
if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" && id != "PostSignupCard" {
content = msg.Text
variables = make([]string, strings.Count(content, "{"))
i := 0
@@ -270,17 +250,32 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
}
app.storage.SetCustomContentKey(id, customMessage)
var mail *Message
if id != "UserLogin" && id != "UserPage" {
if id != "UserLogin" && id != "UserPage" && id != "PostSignupCard" {
mail, err = app.email.constructTemplate("", "<div class=\"preview-content\"></div>", app)
if err != nil {
respondBool(500, false, gc)
return
}
} else if id == "PostSignupCard" {
// Jankiness follows.
// Source content from "Success Message" setting.
if noContent {
content = "# " + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("successHeader") + "\n" + app.config.Section("ui").Key("success_message").String()
if app.config.Section("user_page").Key("enabled").MustBool(false) {
content += "\n\n<br>\n" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.template("userPageSuccessMessage", tmpl{
"myAccount": "[" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("myAccount") + "]({myAccountURL})",
})
}
}
mail = &Message{
HTML: "<div class=\"card ~neutral dark:~d_neutral @low\"><div class=\"preview-content\"></div><br><button class=\"button ~urge dark:~d_urge @low full-width center supra submit\">" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("continue") + "</a></div>",
}
mail.Markdown = mail.HTML
} else {
mail = &Message{
HTML: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
Markdown: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
HTML: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
}
mail.Markdown = mail.HTML
}
gc.JSON(200, customEmailDTO{Content: content, Variables: variables, Conditionals: conditionals, Values: values, HTML: mail.HTML, Plaintext: mail.Text})
}
@@ -329,6 +324,14 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
tgUser.Lang = lang
}
app.storage.SetTelegramKey(req.ID, tgUser)
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldTelegram: tgUser.ChatID,
jellyseerr.FieldTelegramEnabled: tgUser.Contact,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
linkExistingOmbiDiscordTelegram(app)
respondBool(200, true, gc)
}
@@ -353,16 +356,14 @@ func (app *appContext) SetContactMethods(gc *gin.Context) {
}
func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Context) {
jsPrefs := map[jellyseerr.NotificationsField]any{}
if tgUser, ok := app.storage.GetTelegramKey(req.ID); ok {
change := tgUser.Contact != req.Telegram
tgUser.Contact = req.Telegram
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
}
}
if dcUser, ok := app.storage.GetDiscordKey(req.ID); ok {
@@ -370,11 +371,8 @@ 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
}
}
if mxUser, ok := app.storage.GetMatrixKey(req.ID); ok {
@@ -382,11 +380,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 {
@@ -394,11 +388,14 @@ 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(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
}
respondBool(200, true, gc)
@@ -417,7 +414,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
@@ -434,14 +431,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
@@ -457,7 +454,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
@@ -465,7 +462,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
@@ -475,7 +472,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
}
@@ -492,7 +489,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
@@ -531,7 +528,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
@@ -543,7 +540,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
}
@@ -551,12 +548,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
}
@@ -573,6 +570,7 @@ func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
// @Failure 500 {object} boolResponse
// @Param MatrixLoginDTO body MatrixLoginDTO true "Username & password."
// @Router /matrix/login [post]
// @Security Bearer
// @tags Other
func (app *appContext) MatrixLogin(gc *gin.Context) {
var req MatrixLoginDTO
@@ -583,18 +581,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
}
@@ -608,6 +606,7 @@ func (app *appContext) MatrixLogin(gc *gin.Context) {
// @Failure 500 {object} boolResponse
// @Param MatrixConnectUserDTO body MatrixConnectUserDTO true "User's Jellyfin ID & Matrix user ID."
// @Router /users/matrix [post]
// @Security Bearer
// @tags Other
func (app *appContext) MatrixConnect(gc *gin.Context) {
var req MatrixConnectUserDTO
@@ -615,20 +614,18 @@ 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
respondBool(200, true, gc)
}
@@ -639,6 +636,7 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
// @Failure 500 {object} boolResponse
// @Param username path string true "username to search."
// @Router /users/discord/{username} [get]
// @Security Bearer
// @tags Other
func (app *appContext) DiscordGetUsers(gc *gin.Context) {
name := gc.Param("username")
@@ -665,6 +663,7 @@ func (app *appContext) DiscordGetUsers(gc *gin.Context) {
// @Failure 500 {object} boolResponse
// @Param DiscordConnectUserDTO body DiscordConnectUserDTO true "User's Jellyfin ID & Discord ID."
// @Router /users/discord [post]
// @Security Bearer
// @tags Other
func (app *appContext) DiscordConnect(gc *gin.Context) {
var req DiscordConnectUserDTO
@@ -681,6 +680,13 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
app.storage.SetDiscordKey(req.JellyfinID, user)
if err := app.js.ModifyNotifications(req.JellyfinID, map[jellyseerr.NotificationsField]any{
jellyseerr.FieldDiscord: req.DiscordID,
jellyseerr.FieldDiscordEnabled: true,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: req.JellyfinID,
@@ -688,7 +694,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
})
}, gc, false)
linkExistingOmbiDiscordTelegram(app)
respondBool(200, true, gc)
@@ -699,6 +705,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
// @Success 200 {object} boolResponse
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
// @Router /users/discord [delete]
// @Security Bearer
// @Tags Users
func (app *appContext) UnlinkDiscord(gc *gin.Context) {
var req forUserDTO
@@ -710,6 +717,14 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
} */
app.storage.DeleteDiscordKey(req.ID)
// May not actually remove Discord ID, but should disable interaction.
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier,
jellyseerr.FieldDiscordEnabled: false,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: req.ID,
@@ -717,7 +732,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
})
}, gc, false)
respondBool(200, true, gc)
}
@@ -727,6 +742,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
// @Success 200 {object} boolResponse
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
// @Router /users/telegram [delete]
// @Security Bearer
// @Tags Users
func (app *appContext) UnlinkTelegram(gc *gin.Context) {
var req forUserDTO
@@ -738,6 +754,13 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
} */
app.storage.DeleteTelegramKey(req.ID)
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier,
jellyseerr.FieldTelegramEnabled: false,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: req.ID,
@@ -745,7 +768,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
})
}, gc, false)
respondBool(200, true, gc)
}
@@ -755,6 +778,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
// @Success 200 {object} boolResponse
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
// @Router /users/matrix [delete]
// @Security Bearer
// @Tags Users
func (app *appContext) UnlinkMatrix(gc *gin.Context) {
var req forUserDTO
@@ -773,7 +797,7 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
})
}, gc, false)
respondBool(200, true, gc)
}

View File

@@ -2,43 +2,54 @@ 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 {
@@ -51,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.
@@ -65,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
}
@@ -95,15 +106,16 @@ func (app *appContext) OmbiUsers(gc *gin.Context) {
func (app *appContext) SetOmbiProfile(gc *gin.Context) {
var req ombiUser
gc.BindJSON(&req)
profileName := gc.Param("profile")
escapedProfileName := gc.Param("profile")
profileName, _ := url.QueryUnescape(escapedProfileName)
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respondBool(400, false, gc)
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
}
@@ -122,7 +134,8 @@ func (app *appContext) SetOmbiProfile(gc *gin.Context) {
// @Security Bearer
// @tags Ombi
func (app *appContext) DeleteOmbiProfile(gc *gin.Context) {
profileName := gc.Param("profile")
escapedProfileName := gc.Param("profile")
profileName, _ := url.QueryUnescape(escapedProfileName)
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respondBool(400, false, gc)
@@ -133,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{}:
@@ -144,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{},
@@ -27,6 +55,7 @@ func (app *appContext) GetProfiles(gc *gin.Context) {
LibraryAccess: p.LibraryAccess,
FromUser: p.FromUser,
Ombi: p.Ombi != nil,
Jellyseerr: p.Jellyseerr.Enabled,
ReferralsEnabled: false,
}
if referralsEnabled {
@@ -51,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 {
@@ -78,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)
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
}
@@ -93,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
}
@@ -144,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"
@@ -8,6 +9,8 @@ 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"
)
@@ -26,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
}
@@ -132,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)
@@ -160,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(),
})
}
@@ -173,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
@@ -197,17 +199,10 @@ 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 {
emailStore, ok := app.storage.GetEmailsKey(id)
if !ok {
emailStore = EmailAddress{
Contact: true,
}
}
emailStore.Addr = claims["email"].(string)
app.storage.SetEmailsKey(id, emailStore)
app.modifyEmail(id, claims["email"].(string))
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
@@ -216,21 +211,10 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
Source: gc.GetString("jfId"),
Value: "email",
Time: time.Now(),
})
}, gc, true)
if app.config.Section("ombi").Key("enabled").MustBool(false) {
ombiUser, code, err := app.getOmbiUser(id)
if code == 200 && err == nil {
ombiUser["emailAddress"] = claims["email"].(string)
code, err = app.ombi.ModifyUser(ombiUser)
if code != 200 || err != nil {
app.err.Printf("%s: Failed to change ombi email address (%d): %v", ombiUser["userName"].(string), code, err)
}
}
}
app.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
}
}
@@ -248,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
@@ -268,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)
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
}
@@ -307,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
}
@@ -355,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
@@ -371,6 +354,13 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
}
app.storage.SetDiscordKey(gc.GetString("jfId"), dcUser)
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldDiscord: dcUser.ID,
jellyseerr.FieldDiscordEnabled: dcUser.Contact,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
@@ -378,7 +368,7 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
})
}, gc, true)
respondBool(200, true, gc)
}
@@ -419,6 +409,13 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
}
app.storage.SetTelegramKey(gc.GetString("jfId"), tgUser)
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldTelegram: tgUser.ChatID,
jellyseerr.FieldTelegramEnabled: tgUser.Contact,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
@@ -426,7 +423,7 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
})
}, gc, true)
respondBool(200, true, gc)
}
@@ -480,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
}
@@ -507,7 +504,7 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
})
}, gc, true)
delete(app.matrix.tokens, pin)
respondBool(200, true, gc)
@@ -522,6 +519,13 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
app.storage.DeleteDiscordKey(gc.GetString("jfId"))
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier,
jellyseerr.FieldDiscordEnabled: false,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: gc.GetString("jfId"),
@@ -529,7 +533,7 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
})
}, gc, true)
respondBool(200, true, gc)
}
@@ -543,6 +547,13 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
app.storage.DeleteTelegramKey(gc.GetString("jfId"))
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier,
jellyseerr.FieldTelegramEnabled: false,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: gc.GetString("jfId"),
@@ -550,7 +561,7 @@ func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
})
}, gc, true)
respondBool(200, true, gc)
}
@@ -571,7 +582,7 @@ func (app *appContext) UnlinkMyMatrix(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
})
}, gc, true)
respondBool(200, true, gc)
}
@@ -595,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
@@ -605,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)
@@ -615,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
@@ -636,16 +646,16 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
}, app, 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)
@@ -672,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
}
@@ -701,22 +710,22 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
SourceType: ActivityUser,
Source: user.ID,
Time: time.Now(),
})
}, gc, true)
if app.config.Section("ombi").Key("enabled").MustBool(false) {
func() {
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")
@@ -724,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)
}
@@ -750,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
}
@@ -771,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 = ""
@@ -780,6 +790,7 @@ 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)

File diff suppressed because it is too large Load Diff

262
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"
@@ -114,6 +117,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
var req ResetPasswordDTO
gc.BindJSON(&req)
validation := app.validator.validate(req.Password)
captcha := app.config.Section("captcha").Key("enabled").MustBool(false)
valid := true
for _, val := range validation {
if !val {
@@ -121,35 +125,42 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
}
}
if !valid || req.PIN == "" {
// 200 bcs idk what i did in js
app.info.Printf("%s: Password reset failed: Invalid password", req.PIN)
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(lm.FailedChangePassword, lm.Jellyfin, "?", lm.IncorrectCaptcha)
respond(400, "errorCaptcha", gc)
return
}
var userID, username string
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
}
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 {
@@ -160,15 +171,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
}
@@ -179,205 +189,132 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
SourceType: ActivityUser,
Source: user.ID,
Time: time.Now(),
})
}, gc, true)
prevPassword := req.PIN
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()
// 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),
@@ -417,12 +354,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
}
@@ -444,8 +382,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)
@@ -518,11 +457,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.
@@ -536,6 +471,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 {

112
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"
)
@@ -18,10 +20,31 @@ const (
REFRESH_TOKEN_VALIDITY_SEC = 3600 * 24
)
func (app *appContext) logIpInfo(gc *gin.Context, user bool, out string) {
if (user && LOGIPU) || (!user && LOGIP) {
out += fmt.Sprintf(" (ip=%s)", gc.ClientIP())
}
app.info.Println(out)
}
func (app *appContext) logIpDebug(gc *gin.Context, user bool, out string) {
if (user && LOGIPU) || (!user && LOGIP) {
out += fmt.Sprintf(" (ip=%s)", gc.ClientIP())
}
app.debug.Println(out)
}
func (app *appContext) logIpErr(gc *gin.Context, user bool, out string) {
if (user && LOGIPU) || (!user && LOGIP) {
out += fmt.Sprintf(" (ip=%s)", gc.ClientIP())
}
app.err.Println(out)
}
func (app *appContext) webAuth() gin.HandlerFunc {
return app.authenticate
}
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
@@ -53,32 +76,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
@@ -96,7 +113,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
}
@@ -111,14 +128,13 @@ 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()
}
@@ -133,7 +149,7 @@ type getTokenDTO struct {
Token string `json:"token" example:"kjsdklsfdkljfsjsdfklsdfkldsfjdfskjsdfjklsdf"` // API token for use with everything else.
}
func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, password string, ok bool) {
func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool) (username, password string, ok bool) {
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
auth, _ := base64.StdEncoding.DecodeString(header[1])
creds := strings.SplitN(string(auth), ":", 2)
@@ -141,7 +157,7 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, pas
password = creds[1]
ok = false
if username == "" || password == "" {
app.debug.Println("Auth denied: blank username/password")
app.logIpDebug(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.EmptyUserOrPass))
respond(401, "Unauthorized", gc)
return
}
@@ -149,21 +165,20 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, pas
return
}
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context) (user mediabrowser.User, ok bool) {
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context, userpage bool) (user mediabrowser.User, ok bool) {
ok = false
user, status, err := app.authJf.Authenticate(username, password)
if status != 200 || err != nil {
if status == 401 || status == 400 {
app.info.Println("Auth denied: Invalid username/password (Jellyfin)")
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.info.Println("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
}
@@ -180,8 +195,8 @@ func (app *appContext) validateJellyfinCredentials(username, password string, gc
// @tags Auth
// @Security getTokenAuth
func (app *appContext) getTokenLogin(gc *gin.Context) {
app.info.Println("Token requested (login attempt)")
username, password, ok := app.decodeValidateLoginHeader(gc)
app.logIpInfo(gc, false, fmt.Sprintf(lm.RequestingToken, lm.TokenLoginAttempt))
username, password, ok := app.decodeValidateLoginHeader(gc, false)
if !ok {
return
}
@@ -190,18 +205,17 @@ 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.info.Println("Auth denied: Invalid username/password")
app.logIpInfo(gc, false, fmt.Sprintf(lm.FailedAuthRequest, lm.InvalidUserOrPass))
respond(401, "Unauthorized", gc)
return
}
if !match {
user, ok := app.validateJellyfinCredentials(username, password, gc)
user, ok := app.validateJellyfinCredentials(username, password, gc, false)
if !ok {
return
}
@@ -214,7 +228,7 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
}
accountsAdmin = accountsAdmin || (adminOnly && user.Policy.IsAdministrator)
if !accountsAdmin {
app.debug.Printf("Auth denied: Users \"%s\" isn't admin", username)
app.authLog(fmt.Sprintf(lm.NonAdminUser, username))
respond(401, "Unauthorized", gc)
return
}
@@ -224,16 +238,20 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
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
}
gc.SetCookie("refresh", refresh, (3600 * 24), "/", gc.Request.URL.Hostname(), true, true)
// host := gc.Request.URL.Hostname()
host := app.ExternalDomain
// 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})
}
@@ -241,35 +259,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
}
@@ -284,7 +296,7 @@ func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName s
// @Router /token/refresh [get]
// @tags Auth
func (app *appContext) getTokenRefresh(gc *gin.Context) {
app.debug.Println("Token requested (refresh token)")
app.logIpInfo(gc, false, fmt.Sprintf(lm.RequestingToken, lm.TokenRefresh))
claims, ok := app.decodeValidateRefreshCookie(gc, "refresh")
if !ok {
return
@@ -293,10 +305,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
}
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", gc.Request.URL.Hostname(), true, true)
// host := gc.Request.URL.Hostname()
host := app.ExternalDomain
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
gc.JSON(200, getTokenDTO{jwt})
}

View File

@@ -5,40 +5,134 @@ 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_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}{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 +154,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 +184,82 @@ 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("remaining:%+v\n", backupsByCommit)
}
// fmt.Printf("toDelete: %d, backCount: %d, keep: %d, length: %d\n", toDelete, backups.count, toKeep, len(backups.files))
if toDelete > 0 && toDelete <= backups.count {
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,43 +267,36 @@ 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()
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 = ""
}
func newBackupDaemon(app *appContext) *housekeepingDaemon {
func newBackupDaemon(app *appContext) *GenericDaemon {
interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute
daemon := housekeepingDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
}
daemon.jobs = []func(app *appContext){
d := NewGenericDaemon(interval, app,
func(app *appContext) {
app.debug.Println("Backups: Creating backup")
app.makeBackup()
},
}
return &daemon
)
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 + "2023-12-21T21-08-00" + BACKUP_SUFFIX
A1 := Backup{}
A1.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
testBackupParse(Q1, A1, t)
}
func TestBackupParserOldUpload(t *testing.T) {
Q2 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX
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,18 @@
package common
import (
"bytes"
"compress/gzip"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
lm "github.com/hrfee/jfa-go/logmessages"
)
// TimeoutHandler recovers from an http timeout or panic.
@@ -12,7 +22,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 +31,127 @@ 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
}

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

183
config.go
View File

@@ -3,12 +3,16 @@ package main
import (
"fmt"
"io/fs"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/hrfee/jfa-go/common"
"github.com/hrfee/jfa-go/easyproxy"
lm "github.com/hrfee/jfa-go/logmessages"
"gopkg.in/ini.v1"
)
@@ -18,6 +22,9 @@ var telegramEnabled = false
var discordEnabled = false
var matrixEnabled = false
// URL subpaths. Ignore the "Current" field.
var PAGES = PagePaths{}
func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
val := app.config.Section(sect).Key(key).MustString("")
if strings.HasPrefix(val, "jfa-go:") {
@@ -31,15 +38,44 @@ func (app *appContext) MustSetValue(section, key, val string) {
app.config.Section(section).Key(key).SetValue(app.config.Section(section).Key(key).MustString(val))
}
func (app *appContext) MustSetURLPath(section, key, val string) {
if !strings.HasPrefix(val, "/") && val != "" {
val = "/" + val
}
app.MustSetValue(section, key, val)
}
func FormatSubpath(path string) string {
if path == "/" {
return ""
}
return strings.TrimSuffix(path, "/")
}
func (app *appContext) loadConfig() error {
var err error
app.config, err = ini.Load(app.configPath)
app.config, err = ini.ShadowLoad(app.configPath)
if err != nil {
return err
}
// URLs
app.MustSetURLPath("ui", "url_base", "")
app.MustSetURLPath("url_paths", "admin", "")
app.MustSetURLPath("url_paths", "user_page", "/my/account")
app.MustSetURLPath("url_paths", "form", "/invite")
PAGES.Base = FormatSubpath(app.config.Section("ui").Key("url_base").String())
PAGES.Admin = FormatSubpath(app.config.Section("url_paths").Key("admin").String())
PAGES.MyAccount = FormatSubpath(app.config.Section("url_paths").Key("user_page").String())
PAGES.Form = FormatSubpath(app.config.Section("url_paths").Key("form").String())
if !(app.config.Section("user_page").Key("enabled").MustBool(true)) {
PAGES.MyAccount = "disabled"
}
if PAGES.Base == PAGES.Form || PAGES.Base == "/accounts" || PAGES.Base == "/settings" || PAGES.Base == "/activity" {
app.err.Printf(lm.BadURLBase, PAGES.Base)
}
app.info.Printf(lm.SubpathBlockMessage, PAGES.Base, PAGES.Admin, PAGES.MyAccount, PAGES.Form)
app.MustSetValue("jellyfin", "public_server", app.config.Section("jellyfin").Key("server").String())
app.MustSetValue("ui", "redirect_url", app.config.Section("jellyfin").Key("public_server").String())
for _, key := range app.config.Section("files").Keys() {
@@ -53,7 +89,19 @@ func (app *appContext) loadConfig() error {
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"))))
}
app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/")
app.ExternalURI = strings.TrimSuffix(strings.TrimSuffix(app.config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/")
if !strings.HasSuffix(app.ExternalURI, PAGES.Base) {
app.err.Println(lm.NoURLSuffix)
}
if app.ExternalURI == "" {
app.err.Println(lm.NoExternalHost + lm.LoginWontSave)
}
u, err := url.Parse(app.ExternalURI)
if err == nil {
app.ExternalDomain = u.Hostname()
}
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")
@@ -77,6 +125,7 @@ func (app *appContext) loadConfig() error {
app.MustSetValue("smtp", "hello_hostname", "localhost")
app.MustSetValue("smtp", "cert_validation", "true")
app.MustSetValue("smtp", "auth_type", "4")
app.MustSetValue("smtp", "port", "465")
app.MustSetValue("activity_log", "keep_n_records", "1000")
app.MustSetValue("activity_log", "delete_after_days", "90")
@@ -105,6 +154,11 @@ func (app *appContext) loadConfig() error {
app.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html")
app.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt")
app.MustSetValue("user_expiry", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html")
app.MustSetValue("user_expiry", "adjustment_email_text", "jfa-go:"+"expiry-adjusted.txt")
app.MustSetValue("email", "collect", "true")
app.MustSetValue("matrix", "topic", "Jellyfin notifications")
app.MustSetValue("matrix", "show_on_reg", "true")
@@ -115,16 +169,22 @@ func (app *appContext) loadConfig() error {
app.MustSetValue("backups", "every_n_minutes", "1440")
app.MustSetValue("backups", "path", filepath.Join(app.dataPath, "backups"))
app.MustSetValue("backups", "keep_n_backups", "20")
app.MustSetValue("backups", "keep_previous_version_backup", "true")
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))
// 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)
LOGIP = app.config.Section("advanced").Key("log_ips").MustBool(false)
LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false)
app.MustSetValue("advanced", "auth_retry_count", "6")
app.MustSetValue("advanced", "auth_retry_gap", "10")
app.MustSetValue("ui", "port", "8056")
app.MustSetValue("advanced", "tls_port", "8057")
app.MustSetValue("advanced", "value_log_size", "512")
pwrMethods := []string{"allow_pwr_username", "allow_pwr_email", "allow_pwr_contact_method"}
allDisabled := true
@@ -134,7 +194,7 @@ func (app *appContext) loadConfig() error {
}
}
if allDisabled {
fmt.Println("SETALLTRUE")
app.info.Println(lm.EnableAllPWRMethods)
for _, v := range pwrMethods {
app.config.Section("user_page").Key(v).SetValue("true")
}
@@ -169,9 +229,15 @@ func (app *appContext) loadConfig() error {
app.proxyConfig.Password = app.config.Section("advanced").Key("proxy_password").MustString("")
app.proxyTransport, err = easyproxy.NewTransport(app.proxyConfig)
if err != nil {
app.err.Printf("Failed to initialize Proxy: %v\n", err)
app.err.Printf(lm.FailedInitProxy, app.proxyConfig.Addr, err)
// As explained in lm.FailedInitProxy, sleep here might grab the admin's attention,
// Since we don't crash on this failing.
time.Sleep(15 * time.Second)
app.proxyEnabled = false
} else {
app.proxyEnabled = true
app.info.Printf(lm.InitProxy, app.proxyConfig.Addr)
}
app.proxyEnabled = true
}
app.MustSetValue("updates", "enabled", "true")
@@ -223,3 +289,98 @@ func (app *appContext) loadConfig() error {
return nil
}
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

1636
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;
}
@@ -106,48 +98,6 @@ div.card:contains(section.banner.footer) {
padding-bottom: 0px;
}
.tab-button {
font-size: 2rem;
}
.al {
text-align: left;
}
.ar {
text-align: right;
}
.ac {
text-align: center;
}
.w-100 {
width: 100%;
}
.h-100 {
height: 100%;
}
.inline-block {
display: inline-block;
}
.align-top {
align-items: top;
}
.flex-expand {
display: flex;
justify-content: space-between;
}
.flex-row-group {
display: block;
flex-grow: 1;
}
.row {
display: flex;
flex-wrap: wrap;
@@ -172,23 +122,7 @@ span.sm:not(.heading) {
margin: .25rem;
}
.flex-col {
display: flex;
flex-direction: column;
}
.flex-form {
display: flex;
flex-direction: column;
}
@media screen and (min-width: 768px) {
.flex-form {
flex: 1;
margin: 0.5rem;
}
}
/* Who knows for half of these to be honest */
@media screen and (max-width: 400px) {
.row {
flex-direction: column;
@@ -219,69 +153,6 @@ sup.\~critical, .text-critical {
font-size: 1rem;
}
.inv-created-users strong,p {
padding-left: 0.5rem;
padding-bottom: 0.2rem;
}
.inv-created-users.empty strong,p {
padding: 0;
}
.inv {
overflow: visible;
}
.inv-table {
font-size: 0.8rem;
}
.inv-profilearea {
min-width: 20%;
}
.inv-profileselect {
min-width: 100%;
}
.inv-codearea {
max-width: 40%;
min-width: 10rem;
display: flex;
justify-content: start;
align-items: center;
}
.inv-empty .inv-codearea {
justify-content: start;
}
.invite-link {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: auto;
}
.ellipsis {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.no-pad {
padding: 0px 0px 0px 0px;
}
.elem-pad > * {
margin: var(--spacing-4, 1rem);
}
.icon.clickable {
padding: 0.5rem 0.6rem;
}
.input {
box-sizing: border-box; /* fixes weird length issue with inputs */
}
@@ -300,10 +171,6 @@ sup.\~critical, .text-critical {
width: 100%;
}
.flex-auto {
flex: auto;
}
.center {
justify-content: center;
}
@@ -312,14 +179,6 @@ sup.\~critical, .text-critical {
align-items: center;
}
.no-lp {
padding-left: 0px;
}
.block {
display: block;
}
.focused {
display: block;
}
@@ -416,7 +275,16 @@ table {
color: var(--color-content);
}
table.table.manual-pad th, table.table.manual-pad td {
padding: 0;
}
table.table-p-0 th, table.table-p-0 td {
padding-left: 0 !important;
padding-right: 0 !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
p.top {
margin-top: 0px;
@@ -575,7 +443,6 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
cursor: pointer;
}
.g-recaptcha {
overflow: hidden;
width: 296px;
@@ -587,3 +454,19 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
.g-recaptcha iframe {
margin: -2px 0px 0px -4px;
}
.dropdown-manual-toggle {
margin-bottom: -0.5rem;
padding-bottom: 0.5rem;
}
section.section:not(.\~neutral) {
background-color: inherit;
}
@layer components {
.switch {
@apply flex flex-row gap-1 items-center;
}
}

View File

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

View File

@@ -2,29 +2,31 @@ package main
import (
"fmt"
"net/http"
"strings"
"time"
dg "github.com/bwmarrin/discordgo"
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
}
func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
@@ -59,6 +61,11 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
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()
@@ -92,13 +99,11 @@ func (d *DiscordDaemon) MustGetUser(channelID, userID, discrim, username string)
}
func (d *DiscordDaemon) run() {
d.bot.AddHandler(d.messageHandler)
d.bot.AddHandler(d.commandHandler)
d.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers | dg.IntentsGuildInvites
if err := d.bot.Open(); err != nil {
d.app.err.Printf("Discord: Failed to start daemon: %v", err)
d.app.err.Printf(lm.FailedStartDaemon, lm.Discord, err)
return
}
// Wait for everything to populate, it's slow sometimes.
@@ -116,17 +121,17 @@ 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"))
@@ -145,7 +150,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 +173,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 +237,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 +278,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 +308,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 +317,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
@@ -381,7 +404,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 +415,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,
@@ -409,9 +432,9 @@ func (d *DiscordDaemon) registerCommands() {
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
}
}
@@ -420,12 +443,12 @@ func (d *DiscordDaemon) registerCommands() {
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 +459,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 +467,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 +475,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 +510,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 +527,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 +545,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 +559,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 +590,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,7 +599,7 @@ 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)
@@ -590,12 +614,9 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
//}
// 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
if !(ok && requesterEmail.Admin) {
d.app.err.Printf(lm.FailedGenerateInvite, fmt.Sprintf(lm.NonAdminUser, requester.JellyfinID))
// FIXME: add response message
return
}
@@ -629,7 +650,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 {
@@ -638,13 +659,12 @@ 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)
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)
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, invite.Code, err)
d.app.err.Println(invite.SendTo)
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
@@ -653,14 +673,14 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
},
})
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", RenderDiscordUsername(requester), err)
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
}
} else {
var err error
err = d.app.discord.SendDM(msg, recipient.ID)
if err != nil {
invite.SendTo = fmt.Sprintf("Failed to send to %s", RenderDiscordUsername(recipient))
d.app.err.Printf("%s: %s: %v", invite.Code, invite.SendTo, err)
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, RenderDiscordUsername(recipient), err)
d.app.err.Println(invite.SendTo)
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
@@ -669,10 +689,10 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
},
})
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", RenderDiscordUsername(requester), err)
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
}
} else {
d.app.info.Printf("%s: Sent invite email to \"%s\"", invite.Code, RenderDiscordUsername(recipient))
d.app.info.Printf(lm.SentInviteMessage, invite.Code, RenderDiscordUsername(recipient))
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
@@ -681,7 +701,7 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
},
})
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", RenderDiscordUsername(requester), err)
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
}
}
}
@@ -690,140 +710,6 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
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 +763,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 +786,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,7 @@
module github.com/hrfee/jfa-go/easyproxy
go 1.20
go 1.18
require golang.org/x/net v0.15.0
require golang.org/x/net v0.36.0
require github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b // indirect
require github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b

View File

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

117
email.go
View File

@@ -10,6 +10,7 @@ import (
"html/template"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"strconv"
@@ -20,6 +21,7 @@ import (
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/hrfee/jfa-go/easyproxy"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser"
"github.com/itchyny/timefmt-go"
"github.com/mailgun/mailgun-go/v4"
@@ -95,10 +97,10 @@ func NewEmailer(app *appContext) *Emailer {
authType := sMail.AuthType(app.config.Section("smtp").Key("auth_type").MustInt(4))
err := emailer.NewSMTP(app.config.Section("smtp").Key("server").String(), app.config.Section("smtp").Key("port").MustInt(465), username, password, sslTLS, app.config.Section("smtp").Key("ssl_cert").MustString(""), app.config.Section("smtp").Key("hello_hostname").String(), app.config.Section("smtp").Key("cert_validation").MustBool(true), authType, proxyConf)
if err != nil {
app.err.Printf("Error while initiating SMTP mailer: %v", err)
app.err.Printf(lm.FailedInitSMTP, err)
}
} else if method == "mailgun" {
emailer.NewMailgun(app.config.Section("mailgun").Key("api_url").String(), app.config.Section("mailgun").Key("api_key").String())
emailer.NewMailgun(app.config.Section("mailgun").Key("api_url").String(), app.config.Section("mailgun").Key("api_key").String(), app.proxyTransport)
} else if method == "dummy" {
emailer.sender = &DummyClient{}
}
@@ -200,10 +202,14 @@ type Mailgun struct {
}
// NewMailgun returns a Mailgun emailClient.
func (emailer *Emailer) NewMailgun(url, key string) {
func (emailer *Emailer) NewMailgun(url, key string, transport *http.Transport) {
sender := &Mailgun{
client: mailgun.NewMailgun(strings.Split(emailer.fromAddr, "@")[1], key),
}
if transport != nil {
cli := sender.client.Client()
cli.Transport = transport
}
// Mailgun client takes the base url, so we need to trim off the end (e.g 'v3/messages')
if strings.Contains(url, "messages") {
url = url[0:strings.LastIndex(url, "/")]
@@ -319,17 +325,11 @@ func (emailer *Emailer) confirmationValues(code, username, key string, app *appC
}
} else {
message := app.config.Section("messages").Key("message").String()
inviteLink := app.config.Section("invite_emails").Key("url_base").String()
inviteLink := app.ExternalURI
if code == "" { // Personal email change
if strings.HasSuffix(inviteLink, "/invite") {
inviteLink = strings.TrimSuffix(inviteLink, "/invite")
}
inviteLink = fmt.Sprintf("%s/my/confirm/%s", inviteLink, url.PathEscape(key))
} else { // Invite email confirmation
if !strings.HasSuffix(inviteLink, "/invite") {
inviteLink += "/invite"
}
inviteLink = fmt.Sprintf("%s/%s?key=%s", inviteLink, code, url.PathEscape(key))
inviteLink = fmt.Sprintf("%s%s/%s?key=%s", inviteLink, PAGES.Form, code, url.PathEscape(key))
}
template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username})
template["confirmationURL"] = inviteLink
@@ -393,11 +393,7 @@ func (emailer *Emailer) inviteValues(code string, invite Invite, app *appContext
expiry := invite.ValidTill
d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern)
message := app.config.Section("messages").Key("message").String()
inviteLink := app.config.Section("invite_emails").Key("url_base").String()
if !strings.HasSuffix(inviteLink, "/invite") {
inviteLink += "/invite"
}
inviteLink = fmt.Sprintf("%s/%s", inviteLink, code)
inviteLink := fmt.Sprintf("%s%s/%s", app.ExternalURI, PAGES.Form, code)
template := map[string]interface{}{
"hello": emailer.lang.InviteEmail.get("hello"),
"youHaveBeenInvited": emailer.lang.InviteEmail.get("youHaveBeenInvited"),
@@ -580,7 +576,7 @@ func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bo
// Only used in html email.
template["pin_code"] = pwr.Pin
} else {
app.info.Println("Couldn't generate PWR link: %v", err)
app.info.Printf(lm.FailedGeneratePWRLink, err)
template["pin"] = pwr.Pin
}
} else {
@@ -741,6 +737,72 @@ func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub b
return email, nil
}
func (emailer *Emailer) expiryAdjustedValues(username string, expiry time.Time, reason string, app *appContext, noSub bool, custom bool) map[string]interface{} {
template := map[string]interface{}{
"yourExpiryWasAdjusted": emailer.lang.UserExpiryAdjusted.get("yourExpiryWasAdjusted"),
"ifPreviouslyDisabled": emailer.lang.UserExpiryAdjusted.get("ifPreviouslyDisabled"),
"reasonString": emailer.lang.Strings.get("reason"),
"newExpiry": "",
"message": "",
}
if noSub {
template["helloUser"] = emailer.lang.Strings.get("helloUser")
empty := []string{"reason", "newExpiry"}
for _, v := range empty {
template[v] = "{" + v + "}"
}
} else {
template["reason"] = reason
template["message"] = app.config.Section("messages").Key("message").String()
template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username})
exp := app.formatDatetime(expiry)
if !expiry.IsZero() {
if custom {
template["newExpiry"] = exp
} else if !expiry.IsZero() {
template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
"date": exp,
})
}
}
}
return template
}
func (emailer *Emailer) constructExpiryAdjusted(username string, expiry time.Time, reason string, app *appContext, noSub bool) (*Message, error) {
email := &Message{
Subject: app.config.Section("user_expiry").Key("adjustment_subject").MustString(emailer.lang.UserExpiryAdjusted.get("title")),
}
var err error
var template map[string]interface{}
message := app.storage.MustGetCustomContentKey("UserExpiryAdjusted")
if message.Enabled {
template = emailer.expiryAdjustedValues(username, expiry, reason, app, noSub, true)
} else {
template = emailer.expiryAdjustedValues(username, expiry, reason, app, noSub, false)
}
if noSub {
template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
"date": "{newExpiry}",
})
}
if message.Enabled {
content := templateEmail(
message.Content,
message.Variables,
nil,
template,
)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "user_expiry", "adjustment_email_", template)
}
if err != nil {
return nil, err
}
return email, nil
}
func (emailer *Emailer) welcomeValues(username string, expiry time.Time, app *appContext, noSub bool, custom bool) map[string]interface{} {
template := map[string]interface{}{
"welcome": emailer.lang.WelcomeEmail.get("welcome"),
@@ -901,11 +963,10 @@ func (app *appContext) getAddressOrName(jfID string) string {
// returns "" if none found. returns only the first match, might be an issue if there are users with the same contact method usernames.
func (app *appContext) ReverseUserSearch(address string, matchUsername, matchEmail, matchContactMethod bool) (user mediabrowser.User, ok bool) {
ok = false
var status int
var err error = nil
if matchUsername {
user, status, err = app.jf.UserByName(address, false)
if status == 200 && err == nil {
user, err = app.jf.UserByName(address, false)
if err == nil {
ok = true
return
}
@@ -916,8 +977,8 @@ func (app *appContext) ReverseUserSearch(address string, matchUsername, matchEma
err = app.storage.db.Find(&emailAddresses, badgerhold.Where("Addr").Eq(address))
if err == nil && len(emailAddresses) > 0 {
for _, emailUser := range emailAddresses {
user, status, err = app.jf.UserByID(emailUser.JellyfinID, false)
if status == 200 && err == nil {
user, err = app.jf.UserByID(emailUser.JellyfinID, false)
if err == nil {
ok = true
return
}
@@ -930,8 +991,8 @@ func (app *appContext) ReverseUserSearch(address string, matchUsername, matchEma
if matchContactMethod {
for _, dcUser := range app.storage.GetDiscord() {
if RenderDiscordUsername(dcUser) == strings.ToLower(address) {
user, status, err = app.jf.UserByID(dcUser.JellyfinID, false)
if status == 200 && err == nil {
user, err = app.jf.UserByID(dcUser.JellyfinID, false)
if err == nil {
ok = true
return
}
@@ -942,8 +1003,8 @@ func (app *appContext) ReverseUserSearch(address string, matchUsername, matchEma
err = app.storage.db.Find(&telegramUsers, badgerhold.Where("Username").Eq(tgUsername))
if err == nil && len(telegramUsers) > 0 {
for _, telegramUser := range telegramUsers {
user, status, err = app.jf.UserByID(telegramUser.JellyfinID, false)
if status == 200 && err == nil {
user, err = app.jf.UserByID(telegramUser.JellyfinID, false)
if err == nil {
ok = true
return
}
@@ -953,8 +1014,8 @@ func (app *appContext) ReverseUserSearch(address string, matchUsername, matchEma
err = app.storage.db.Find(&matrixUsers, badgerhold.Where("UserID").Eq(address))
if err == nil && len(matrixUsers) > 0 {
for _, matrixUser := range matrixUsers {
user, status, err = app.jf.UserByID(matrixUser.JellyfinID, false)
if status == 200 && err == nil {
user, err = app.jf.UserByID(matrixUser.JellyfinID, false)
if err == nil {
ok = true
return
}

View File

@@ -1,3 +1,4 @@
//go:build external
// +build external
package main
@@ -12,6 +13,8 @@ import (
const binaryType = "external"
func BuildTagsExternal() { buildTags = append(buildTags, "external") }
var localFS dirFS
var langFS dirFS

69
generic-d.go Normal file
View File

@@ -0,0 +1,69 @@
package main
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
Interval time.Duration
period time.Duration
jobs []func(app *appContext)
app *appContext
name string
}
func (d *GenericDaemon) appendJobs(jobs ...func(app *appContext)) {
d.jobs = append(d.jobs, jobs...)
}
// NewGenericDaemon returns a daemon which can be given jobs that utilize appContext.
func NewGenericDaemon(interval time.Duration, app *appContext, jobs ...func(app *appContext)) *GenericDaemon {
d := GenericDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
name: "Generic Daemon",
}
d.jobs = jobs
return &d
}
func (d *GenericDaemon) Name(name string) { d.name = name }
func (d *GenericDaemon) run() {
d.app.info.Printf(lm.StartDaemon, d.name)
for {
select {
case <-d.ShutdownChannel:
d.ShutdownChannel <- "Down"
return
case <-time.After(d.period):
break
}
started := time.Now()
for _, job := range d.jobs {
job(d.app)
}
finished := time.Now()
duration := finished.Sub(started)
d.period = d.Interval - duration
}
}
func (d *GenericDaemon) Shutdown() {
d.Stopped = true
d.ShutdownChannel <- "Down"
<-d.ShutdownChannel
close(d.ShutdownChannel)
}

147
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,124 +12,133 @@ 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
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/bwmarrin/discordgo v0.28.1
github.com/dgraph-io/badger/v4 v4.3.1
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a
github.com/fatih/color v1.15.0
github.com/fsnotify/fsnotify v1.6.0
github.com/fatih/color v1.18.0
github.com/fsnotify/fsnotify v1.8.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.0
github.com/gin-contrib/static v1.1.2
github.com/gin-gonic/gin v1.10.0
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a
github.com/hrfee/jfa-go/common v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/jfa-go/docs v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/jfa-go/linecache v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/jfa-go/logger v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/jfa-go/ombi v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/mediabrowser v0.3.12
github.com/itchyny/timefmt-go v0.1.5
github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81
github.com/hrfee/jfa-go/common v0.0.0-20241105225412-da4470bc4fbc
github.com/hrfee/jfa-go/docs v0.0.0-20241105225412-da4470bc4fbc
github.com/hrfee/jfa-go/easyproxy v0.0.0-20241105225412-da4470bc4fbc
github.com/hrfee/jfa-go/jellyseerr v0.0.0-20241105225412-da4470bc4fbc
github.com/hrfee/jfa-go/linecache v0.0.0-20241105225412-da4470bc4fbc
github.com/hrfee/jfa-go/logger v0.0.0-20241105225412-da4470bc4fbc
github.com/hrfee/jfa-go/logmessages v0.0.0-20241105225412-da4470bc4fbc
github.com/hrfee/jfa-go/ombi v0.0.0-20241105225412-da4470bc4fbc
github.com/hrfee/mediabrowser v0.3.24
github.com/itchyny/timefmt-go v0.1.6
github.com/lithammer/shortuuid/v3 v3.0.7
github.com/mailgun/mailgun-go/v4 v4.9.1
github.com/mailgun/mailgun-go/v4 v4.18.1
github.com/mattn/go-sqlite3 v1.14.24
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.21.1
)
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/badger/v3 v3.2103.5 // indirect
github.com/dgraph-io/ristretto v0.1.1 // indirect
github.com/bytedance/sonic v1.12.4 // indirect
github.com/bytedance/sonic/loader v0.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dgraph-io/ristretto v1.0.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.6 // 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/getlantern/ops v0.0.0-20231025133620-f368ab734534 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.2.4 // indirect
github.com/go-chi/chi/v5 v5.1.0 // indirect
github.com/go-logr/logr v1.4.2 // 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.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-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.22.1 // 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.3 // 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/easyproxy v0.0.0-00010101000000-000000000000 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/flatbuffers v24.3.25+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.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b // indirect
github.com/mailgun/errors v0.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // 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.3 // indirect
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rs/zerolog v1.29.1 // indirect
github.com/swaggo/swag v1.16.1 // indirect
github.com/rs/zerolog v1.33.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-20240103092955-90b7d1423f92 // 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.2.12 // indirect
go.mau.fi/util v0.8.1 // 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/otel v1.31.0 // indirect
go.opentelemetry.io/otel/metric v1.31.0 // indirect
go.opentelemetry.io/otel/trace v1.31.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.11.0 // indirect
golang.org/x/crypto v0.35.0 // indirect
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
golang.org/x/image v0.21.0 // indirect
golang.org/x/net v0.36.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/tools v0.26.0 // indirect
google.golang.org/protobuf v1.35.1 // indirect
)

370
go.sum
View File

@@ -1,32 +1,35 @@
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/bytedance/sonic v1.12.4 h1:9Csb3c9ZJhfUWeMtpCDCq6BUoH5ogfDFLUgQ/jG+R0k=
github.com/bytedance/sonic v1.12.4/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=
github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/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/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,18 +37,18 @@ 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/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/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=
@@ -55,27 +58,21 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
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/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
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 +84,62 @@ 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/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/static v1.1.2 h1:c3kT4bFkUJn2aoRU3s6XnMjJT8J6nNWJkR0NglqmlZ4=
github.com/gin-contrib/static v1.1.2/go.mod h1:Fw90ozjHCmZBWbgrsqrDvO28YbhKEKzKp8GixhR4yLw=
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/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-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/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/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-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-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 +147,8 @@ 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/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,8 +157,6 @@ 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=
@@ -181,7 +166,6 @@ github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfb
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 +174,17 @@ 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/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/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 +194,48 @@ 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.12 h1:fqDxt1be3e+ZNjAtlKc8MTqg7peo6fuGCrk2wOXo20k=
github.com/hrfee/mediabrowser v0.3.12/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/hrfee/mediabrowser v0.3.24 h1:cT5+X3bZeaSBQFevMYkFIw6JJ8nW7Myvb+11a2/THMA=
github.com/hrfee/mediabrowser v0.3.24/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/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/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/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 +243,43 @@ 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/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/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-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/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/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/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 +288,18 @@ 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/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
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 +320,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 +330,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,31 +344,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/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/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=
github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE=
github.com/xhit/go-simple-mail/v2 v2.13.0 h1:OANWU9jHZrVfBkNkvLf8Ww0fexwpQVF/v/5f96fFTLI=
github.com/xhit/go-simple-mail/v2 v2.13.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA=
github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
@@ -403,50 +369,46 @@ 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.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/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/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
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.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/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.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
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/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/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=
@@ -456,7 +418,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.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/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=
@@ -474,14 +437,12 @@ 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.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU=
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/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/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=
@@ -493,6 +454,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/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=
@@ -507,39 +470,34 @@ 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.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.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/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 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
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/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=
@@ -554,8 +512,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.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/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=
@@ -583,18 +541,15 @@ 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=
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=
@@ -602,17 +557,12 @@ 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=
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:
@@ -74,8 +80,19 @@ func (app *appContext) clearTelegram() {
}
}
func (app *appContext) clearPWRCaptchas() {
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()) {
captchas[k] = capt
}
}
app.pwrCaptchas = captchas
}
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)
@@ -92,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))
@@ -105,83 +122,42 @@ func (app *appContext) clearActivities() {
}
}
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
type housekeepingDaemon struct {
Stopped bool
ShutdownChannel chan string
Interval time.Duration
period time.Duration
jobs []func(app *appContext)
app *appContext
}
func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemon {
daemon := housekeepingDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
}
daemon.jobs = []func(app *appContext){
func newHousekeepingDaemon(interval time.Duration, app *appContext) *GenericDaemon {
d := NewGenericDaemon(interval, app,
func(app *appContext) {
app.debug.Println("Housekeeping: Checking for expired invites")
app.debug.Println(lm.HousekeepingInvites)
app.checkInvites()
},
func(app *appContext) { app.clearActivities() },
}
)
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 {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.jf.CacheExpiry = time.Now() })
d.appendJobs(func(app *appContext) { app.jf.CacheExpiry = time.Now() })
}
if clearEmail {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearEmails() })
d.appendJobs(func(app *appContext) { app.clearEmails() })
}
if clearDiscord {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearDiscord() })
d.appendJobs(func(app *appContext) { app.clearDiscord() })
}
if clearTelegram {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearTelegram() })
d.appendJobs(func(app *appContext) { app.clearTelegram() })
}
if clearMatrix {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearMatrix() })
d.appendJobs(func(app *appContext) { app.clearMatrix() })
}
if clearPWR {
d.appendJobs(func(app *appContext) { app.clearPWRCaptchas() })
}
return &daemon
}
func (rt *housekeepingDaemon) run() {
rt.app.info.Println("Invite daemon started")
for {
select {
case <-rt.ShutdownChannel:
rt.ShutdownChannel <- "Down"
return
case <-time.After(rt.period):
break
}
started := time.Now()
for _, job := range rt.jobs {
job(rt.app)
}
finished := time.Now()
duration := finished.Sub(started)
rt.period = rt.Interval - duration
}
}
func (rt *housekeepingDaemon) Shutdown() {
rt.Stopped = true
rt.ShutdownChannel <- "Down"
<-rt.ShutdownChannel
close(rt.ShutdownChannel)
return d
}

View File

@@ -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,15 +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.usernameEnabled = {{ .username }};
window.langFile = JSON.parse({{ .language }});
window.linkResetEnabled = {{ .linkResetEnabled }};
@@ -17,7 +9,6 @@
window.jellyfinLogin = {{ .jellyfinLogin }};
window.jfAdminOnly = {{ .jfAdminOnly }};
window.jfAllowAll = {{ .jfAllowAll }};
window.referralsEnabled = {{ .referralsEnabled }};
window.loginAppearance = "{{ .loginAppearance }}";
</script>
<title>Admin - jfa-go</title>
@@ -26,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-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>
@@ -43,31 +36,32 @@
</form>
</div>
<div id="modal-about" class="modal">
<div class="relative mx-auto my-[10%] 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>
<div class="row col flex">
<a class="button ~neutral mr-2 mt-4 mb-4 lang-link" href="https://github.com/hrfee/jfa-go"><i class="ri-github-line mr-2"></i>github</a>
<a class="button ~urge mt-4 mb-4 mr-2 lang-link" href="https://wiki.jfa-go.com">wiki/docs</a>
<a class="button ~positive mt-4 mb-4 mr-2 lang-link" href="https://weblate.jfa-go.com">translation</a>
<div class="dropdown mr-2" tabindex="0">
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~info mt-4 mb-4 dropdown-button lang-link">
<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>
<a class="button ~positive lang-link" href="https://weblate.jfa-go.com">translation</a>
<div class="dropdown" tabindex="0">
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~info dropdown-button lang-link">
<i class="ri-hand-heart-line mr-2"></i>
donate
<span class="ml-2 chev"></span>
</a>
<div class="dropdown-display">
<div class="card ~neutral @low">
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~neutral mb-2 w-100 lang-link">GitHub</a>
<a href="https://ko-fi.com/hrfee" target="_blank" class="button ~neutral mb-2 w-100 lang-link">Ko-fi</a>
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~neutral mb-2 w-full lang-link">GitHub</a>
<a href="https://ko-fi.com/hrfee" target="_blank" class="button ~neutral mb-2 w-full lang-link">Ko-fi</a>
</div>
</div>
</div>
<a class="button ~urge mt-4 mb-4 @low discord lang-link" href="https://discord.com/invite/MrtvuQmyhP" target="_blank"><i class="ri-discord-line mr-2"></i>discord</a>
<a class="button ~urge @low discord lang-link" href="https://discord.com/invite/MrtvuQmyhP" target="_blank"><i class="ri-discord-line mr-2"></i>discord</a>
</div>
<p><a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE">Available under the MIT License. Font "Hanken Grotesk" available under SIL OFL 1.1 License.</a></p>
<pre class="font-mono bg-inherit">{{ .license }}</pre>
@@ -80,46 +74,60 @@
</div>
</div>
<div id="modal-modify-user" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-modify-user" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-modify-user" href="">
<span class="heading"><span id="header-modify-user"></span> <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.modifySettingsDescription }}</p>
<div class="flex flex-row mb-4">
<label class="flex-row-group mr-2">
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-profile" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
<div class="flex flex-col gap-4 my-2">
<div class="flex flex-row gap-2">
<label class="grow">
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-profile" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
</label>
<label class="grow">
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-user">
<span class="button ~neutral @low supra full-width center">{{ .strings.user }}</span>
</label>
</div>
<div class="select ~neutral @low">
<select id="modify-user-profiles"></select>
</div>
<div class="select ~neutral @low unfocused">
<select id="modify-user-users"></select>
</div>
<label class="switch">
<input type="checkbox" id="modify-user-configuration" checked>
<span>{{ .strings.applyConfigurationAndPolicy }}</span>
</label>
<label class="flex-row-group ml-2">
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-user">
<span class="button ~neutral @low supra full-width center">{{ .strings.user }}</span>
<label class="switch">
<input type="checkbox" id="modify-user-homescreen" checked>
<span>{{ .strings.applyHomescreenLayout }}</span>
</label>
<label class="switch">
<input type="checkbox" id="modify-user-ombi" checked>
<span>{{ .strings.applyOmbi }}</span>
</label>
<label class="switch">
<input type="checkbox" id="modify-user-jellyseerr" checked>
<span>{{ .strings.applyJellyseerr }}</span>
</label>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
</label>
</div>
<div class="select ~neutral @low mb-4">
<select id="modify-user-profiles"></select>
</div>
<div class="select ~neutral @low mb-4 unfocused">
<select id="modify-user-users"></select>
</div>
<label class="switch mb-4">
<input type="checkbox" id="modify-user-homescreen" checked>
<span>{{ .strings.applyHomescreenLayout }}</span>
</label>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
</label>
</form>
</div>
{{ if .referralsEnabled }}
<div id="modal-enable-referrals-user" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-enable-referrals-user" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-enable-referrals-user" href="">
<span class="heading"><span id="header-enable-referrals-user"></span> <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.enableReferralsDescription }}</p>
<div class="flex flex-row mb-4">
<label class="flex-row-group mr-2">
<label class="grow mr-2">
<input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-profile" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
</label>
<label class="flex-row-group ml-2">
<label class="grow ml-2">
<input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-invite">
<span class="button ~neutral @low supra full-width center">{{ .strings.invite }}</span>
</label>
@@ -142,7 +150,7 @@
</form>
</div>
<div id="modal-enable-referrals-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-enable-referrals-profile" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-enable-referrals-profile" href="">
<span class="heading"><span id="header-enable-referrals-profile">{{ .strings.enableReferrals }}</span> <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.enableReferralsProfileDescription }}</p>
<label class="supra" for="enable-referrals-profile-invites">{{ .strings.invite }}</label>
@@ -162,7 +170,7 @@
</div>
{{ end }}
<div id="modal-delete-user" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-delete-user" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-delete-user" href="">
<span class="heading"><span id="header-delete-user"></span> <span class="modal-close">&times;</span></span>
<div class="content mt-8">
<label class="switch mb-4">
@@ -178,7 +186,7 @@
</form>
</div>
<div id="modal-extend-expiry" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-extend-expiry" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-extend-expiry" href="">
<span class="heading"><span id="header-extend-expiry"></span> <span class="modal-close">&times;</span></span>
<div class="content mt-8">
<aside class="aside sm ~urge dark:~d_info mb-2 @low row unfocused" id="extend-expiry-date"></aside>
@@ -242,7 +250,7 @@
<div id="modal-announce" class="modal">
<form class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card" id="form-announce" href="">
<span class="heading"><span id="header-announce"></span> <span class="modal-close">&times;</span></span>
<div class="row">
<div class="flex flex-col md:flex-row">
<div class="col card ~neutral @low">
<div id="announce-details">
<span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span>
@@ -259,7 +267,7 @@
<input type="text" class="input ~neutral @low mb-2 mt-4">
<p class="support">{{ .strings.templateEnterName }}</p>
</label>
<div class="row flex-expand">
<div class="flex flex-row justify-between">
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low center supra submit">{{ .strings.send }}</span>
@@ -275,10 +283,10 @@
</form>
</div>
<div id="modal-customize" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3">
<span class="heading">{{ .strings.customizeMessages }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.customizeMessagesDescription }}</p>
<div class="table-responsive">
<div class="">
<table class="table">
<thead>
<tr>
@@ -296,30 +304,33 @@
<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">
<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>
</div>
<div id="modal-restart" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~critical @low">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~critical @low">
<span class="heading">{{ .strings.settingsRestartRequired }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.settingsRestartRequiredDescription }}</p>
<div class="float-right">
@@ -329,7 +340,7 @@
</div>
</div>
<div id="modal-backups" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-2/3">
<span class="heading">{{ .strings.backups }} <span class="modal-close">&times;</span></span>
<div class="content my-4">
{{ .strings.backupsDescription }}
@@ -345,12 +356,13 @@
<input id="backups-file" name="backups-file" type="file" hidden>
<button class="button ~neutral @low mr-2 mb-2" id="settings-backups-sort-direction">{{ .strings.sortDirection }}</button>
</div>
<div class="overflow-x-auto">
<div class="overflow-x-auto text-xs md:text-sm">
<table class="table">
<thead>
<tr>
<th>{{ .strings.name }}</th>
<th>{{ .strings.date }}</th>
<th>{{ .strings.version }}</th>
<th class="table-inline justify-center">{{ .strings.backupDownloadRestore }}</th>
</tr>
</thead>
@@ -360,30 +372,30 @@
</div>
</div>
<div id="modal-backed-up" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.backupCreated }} <span class="modal-close">&times;</span></span>
<p class="content my-4" id="settings-backed-up-location"></p>
<p class="content my-4">{{ .strings.backupCanDownload }}</p>
<div>
<button class="button flex w-100 ~info @low mb-2"><span class="flex items-center" id="settings-backed-up-download">{{ .strings.download }}</span></button>
<button class="button flex w-full ~info @low mb-2"><span class="flex items-center" id="settings-backed-up-download">{{ .strings.download }}</span></button>
</div>
</div>
</div>
<div id="modal-refresh" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.settingsApplied }}</span>
<p class="content">{{ .strings.settingsRefreshPage }}</p>
</div>
</div>
<div id="modal-send-pwr" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.sendPWR }}</span>
<p class="content my-2" id="send-pwr-note"></p>
<span class="button ~urge @low mt-2" id="send-pwr-link">{{ .strings.copy }}</span>
</div>
</div>
<div id="modal-ombi-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-ombi-defaults" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-ombi-defaults" href="">
<span class="heading">{{ .strings.ombiProfile }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.ombiUserDefaultsDescription }}</p>
<div class="select ~neutral @low mb-4">
@@ -395,8 +407,21 @@
</label>
</form>
</div>
<div id="modal-jellyseerr-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-jellyseerr-defaults" href="">
<span class="heading">{{ .strings.jellyseerrProfile }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.jellyseerrUserDefaultsDescription }}</p>
<div class="select ~neutral @low mb-4">
<select></select>
</div>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.submit }}</span>
</label>
</form>
</div>
<div id="modal-user-profiles" class="modal">
<div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-2/3 content card">
<span class="heading">{{ .strings.userProfiles }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.userProfilesDescription }}</p>
<div class="table-responsive">
@@ -408,6 +433,9 @@
{{ if .ombiEnabled }}
<th>Ombi</th>
{{ end }}
{{ if .jellyseerrEnabled }}
<th>Jellyseerr</th>
{{ end }}
{{ if .referralsEnabled }}
<th>{{ .strings.referrals }}</th>
{{ end }}
@@ -422,7 +450,7 @@
</div>
</div>
<div id="modal-add-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-add-profile" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-add-profile" href="">
<span class="heading">{{ .strings.addProfile }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.addProfileDescription }}</p>
<label>
@@ -445,7 +473,7 @@
</form>
</div>
<div id="modal-update" class="modal">
<div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 content card">
<span class="heading">{{ .strings.updates }} <span class="modal-close">&times;</span></span>
<p class="content">
<h2 class="mt-2">
@@ -461,7 +489,7 @@
</div>
{{ if .telegramEnabled }}
<div id="modal-telegram" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkTelegram }}</span>
<p class="content mb-4">{{ .strings.sendPIN }}</p>
<h1 class="ac" id="telegram-pin"></h1>
@@ -479,7 +507,7 @@
{{ end }}
{{ if .discordEnabled }}
<div id="modal-discord" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3">
<span class="heading mb-4"><span id="discord-header"></span><span class="modal-close">&times;</span></span>
<p class="content mb-4" id="discord-description"></p>
<div class="row">
@@ -490,7 +518,7 @@
</div>
{{ end }}
<div id="modal-matrix" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-matrix" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-matrix" href="">
<span class="heading">{{ .strings.linkMatrix }}</span>
<p class="content my-4">{{ .strings.linkMatrixDescription }}</p>
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.matrixHomeServer }}" id="matrix-homeserver">
@@ -503,99 +531,79 @@
</form>
</div>
<div id="notification-box"></div>
<div class="top-4 left-4 absolute">
<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 class="flex flex-wrap items-center justify-between">
<div>
<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">
<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-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"></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" id="create-inv">
<div class="card ~neutral @low col">
<div class="row mb-2">
<label class="col mr-2">
<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>
<span class="button ~neutral @high supra full-width center">{{ .strings.inviteDuration }}</span>
</label>
<label class="col ml-2">
<label class="w-1/2">
<input type="radio" name="duration" class="unfocused" id="radio-user-expiry">
<span class="button ~neutral @low supra full-width center">{{ .strings.userExpiry }}</span>
</label>
</div>
<div id="inv-duration">
<div class="row">
<div class="col">
<div id="inv-duration" class="flex flex-col gap-2">
<div class="flex flex-row gap-2">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="create-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="create-months">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="create-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="create-days">
<option>0</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="flex flex-row gap-2">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="create-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="create-hours">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="create-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="create-minutes">
<option>0</option>
</select>
@@ -603,44 +611,46 @@
</div>
</div>
</div>
<div id="user-expiry" class="unfocused">
<p class="support mb-2">{{ .strings.userExpiryDescription }}</p>
<div class="mb-2">
<label for="create-user-expiry-enabled" class="button ~neutral @low">
<input type="checkbox" id="create-user-expiry-enabled" aria-label="User duration enabled">
<span class="ml-2">{{ .strings.enabled }} </span>
</label>
<div id="user-expiry" class="unfocused flex flex-col gap-2">
<div class="flex flex-row gap-2">
<p class="support">{{ .strings.userExpiryDescription }}</p>
<div>
<label for="create-user-expiry-enabled" class="button ~neutral @low">
<input type="checkbox" id="create-user-expiry-enabled" aria-label="User duration enabled">
<span class="ml-2">{{ .strings.enabled }} </span>
</label>
</div>
</div>
<div class="row">
<div class="col">
<div class="flex flex-row gap-2">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="user-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="user-months">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="user-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="user-days">
<option>0</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="flex flex-row gap-2">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="user-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="user-hours">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="user-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="user-minutes">
<option>0</option>
</select>
@@ -648,77 +658,91 @@
</div>
</div>
</div>
<div class="col">
<div class="flex flex-col gap-4">
<label class="label supra" for="create-label"> {{ .strings.label }}</label>
<input type="text" id="create-label" class="input ~neutral @low mb-2 mt-4">
<input type="text" id="create-label" class="input ~neutral @low">
</div>
<div class="col">
<label class="label supra" for="create-user-label"> {{ .strings.userLabel }}</label>
<p class="support">{{ .strings.userLabelDescription }}</p>
<input type="text" id="create-user-label" class="input ~neutral @low mb-2 mt-4">
<div class="flex flex-col gap-4">
<div>
<label class="label supra" for="create-user-label"> {{ .strings.userLabel }}</label>
<p class="support">{{ .strings.userLabelDescription }}</p>
</div>
<input type="text" id="create-user-label" class="input ~neutral @low">
</div>
</div>
<div class="card ~neutral @low col">
<label class="label supra" for="create-uses">{{ .strings.inviteNumberOfUses }}</label>
<div class="flex-expand mb-2 mt-4">
<input type="number" min="0" id="create-uses" class="input ~neutral @low mr-2" value=1>
<label for="create-inf-uses" class="button ~neutral @low" title="Set uses to infinite">
<span>&infin;</span>
<input type="checkbox" class="unfocused" id="create-inf-uses" aria-label="Set uses to infinite">
</label>
</div>
<p class="support unfocused my-2" id="create-inf-uses-warning"><span class="badge ~critical">{{ .strings.warning }}</span> {{ .strings.inviteInfiniteUsesWarning }}</p>
<label class="label supra">{{ .strings.profile }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="create-profile">
</select>
</div>
<div id="create-send-to-container">
<label class="label supra">{{ .strings.inviteSendToEmail }}</label>
<div class="flex-expand mb-2 mt-4">
{{ if .discordEnabled }}
<input type="text" id="create-send-to" class="input ~neutral @low mr-2" placeholder="example@example.com | user#1234">
<span id="create-send-to-search" class="button ~neutral @low mr-2">
<i class="icon ri-search-2-line" title="{{ .strings.search }}"></i>
</span>
{{ else }}
<input type="email" id="create-send-to" class="input ~neutral @low mr-2" placeholder="example@example.com">
{{ end }}
<label for="create-send-to-enabled" class="button ~neutral @low">
<input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled">
</label>
<div class="card ~neutral @low flex flex-col justify-between gap-2 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>
<div class="flex flex-row gap-2">
<input type="number" min="0" id="create-uses" class="input ~neutral @low" value=1>
<label for="create-inf-uses" class="button ~neutral @low" title="Set uses to infinite">
<span>&infin;</span>
<input type="checkbox" class="unfocused" id="create-inf-uses" aria-label="Set uses to infinite">
</label>
</div>
</div>
<p class="support unfocused" id="create-inf-uses-warning"><span class="badge ~critical">{{ .strings.warning }}</span> {{ .strings.inviteInfiniteUsesWarning }}</p>
<div class="flex flex-col gap-4">
<label class="label supra">{{ .strings.profile }}</label>
<div class="select ~neutral @low">
<select id="create-profile">
</select>
</div>
</div>
<div id="create-send-to-container" class="flex flex-col gap-4">
<label class="label supra">{{ .strings.inviteSendToEmail }}</label>
<div class="flex flex-row gap-2">
{{ if .discordEnabled }}
<input type="text" id="create-send-to" class="input ~neutral @low" placeholder="example@example.com | user#1234">
<span id="create-send-to-search" class="button ~neutral @low">
<i class="icon ri-search-2-line" title="{{ .strings.search }}"></i>
</span>
{{ else }}
<input type="email" id="create-send-to" class="input ~neutral @low" placeholder="example@example.com">
{{ end }}
<label for="create-send-to-enabled" class="button ~neutral @low">
<input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled">
</label>
</div>
</div>
</div>
<span class="button ~urge @low supra full-width center lg" id="create-submit">{{ .strings.create }}</span>
<div>
<span class="button ~urge @low supra full-width center lg" id="create-submit">{{ .strings.create }}</span>
</div>
</div>
</div>
</div>
</div>
<div id="tab-accounts" class="unfocused">
<div id="tab-accounts" class="flex flex-col gap-4 unfocused">
<div class="card @low dark:~d_neutral accounts mb-4 overflow-visible">
<div class="flex-expand align-middle">
<span class="text-3xl font-bold mr-4">{{ .strings.accounts }}</span>
<div id="accounts-filter-dropdown" class="dropdown z-10" tabindex="0">
<span class="h-100 button ~neutral @low center" id="accounts-filter-button">{{ .strings.filters }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low mt-2" id="accounts-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</div>
<div id="accounts-filter-dropdown" class="dropdown manual z-10 w-full">
<div class="flex flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
<span class="text-3xl font-bold mr-4">{{ .strings.accounts }}</span>
<span class="dropdown-manual-toggle"><button class="h-full button ~neutral @low center" id="accounts-filter-button" tabindex="0">{{ .strings.filters }}</button></span>
</div>
<div class="flex flex-row align-middle w-full">
<input type="search" class="field ~neutral @low input search mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
</div>
</div>
<div class="dropdown-display max-w-full">
<div class="card ~neutral @low mt-2 overflow-x-scroll" id="accounts-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</div>
</div>
<input type="search" class="field ~neutral @low input search ml-2 mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
</div>
<div class="supra py-1 sm hidden" id="accounts-search-options-header">{{ .strings.searchOptions }}</div>
<div class="row -mx-2 mb-2">
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="accounts-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
<span id="accounts-filter-area"></span>
</div>
<div class="supra py-1 sm">{{ .strings.actions }}</div>
<div class="row -mx-2">
<span class="col button ~neutral @low center max-w-[20%]" id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
<div id="accounts-announce-dropdown" class="col dropdown pb-0i max-w-[20%]" tabindex="0">
<span class="w-100 button ~info @low center" id="accounts-announce">{{ .strings.announce }}</span>
<div class="supra pt-1 pb-2 sm">{{ .strings.actions }}</div>
<div class="flex flex-row flex-wrap gap-3 mb-4">
<span class="button ~neutral @low center " id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
<div id="accounts-announce-dropdown" class="dropdown pb-0i " tabindex="0">
<span class="w-full button ~info @low center items-baseline" id="accounts-announce">{{ .strings.announce }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<span class="supra sm">{{ .strings.templates }}</span>
@@ -726,12 +750,12 @@
</div>
</div>
</div>
<span class="col button ~urge @low center max-w-[20%]" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
<span class="button ~urge @low center " id="accounts-modify-user">{{ .strings.modifySettings }}</span>
{{ if .referralsEnabled }}
<span class="col button ~urge @low center max-w-[20%]" id="accounts-enable-referrals">{{ .strings.enableReferrals }}</span>
<span class="button ~urge @low center " id="accounts-enable-referrals">{{ .strings.enableReferrals }}</span>
{{ end }}
<div id="accounts-expiry-dropdown" class="col dropdown pb-0i max-w-[20%]" tabindex="0">
<span class="w-100 button ~positive @low center" id="accounts-expiry-dropdown-button">{{ .strings.expiry }} <i class="ri-arrow-down-s-line ml-2"></i></span>
<div id="accounts-expiry-dropdown" class="dropdown pb-0i " tabindex="0">
<span class="w-full button ~positive @low center items-baseline" id="accounts-expiry-dropdown-button">{{ .strings.expiry }} <i class="ri-arrow-down-s-line ml-2"></i></span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<span class="button ~warning full-width @low center" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
@@ -739,19 +763,19 @@
</div>
</div>
</div>
<div id="accounts-disable-enable-dropdown" class="col dropdown manual pb-0i max-w-[20%]" tabindex="0">
<span class="w-100 button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span>
<div id="accounts-disable-enable-dropdown" class="dropdown manual pb-0i " tabindex="0">
<span class="w-full button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<span class="button ~urge full-width accounts-announce-template-button" id="accounts-enable-expiry">{{ .strings.setExpiry }}</span>
</div>
</div>
</div>
<span class="col button ~info @low center unfocused max-w-[20%]" id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
<span class="col button ~critical @low center max-w-[20%]" id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
<span class="button ~info @low center unfocused " id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
<span class="button ~critical @low center " id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
</div>
<div class="card @low accounts-header table-responsive mt-2">
<table class="table text-base leading-4">
<table class="table text-base leading-5">
<thead>
<tr>
<th><input type="checkbox" value="" id="accounts-select-all"></th>
@@ -789,29 +813,35 @@
</div>
</div>
</div>
<div id="tab-activity" class="unfocused">
<div id="tab-activity" class="flex flex-col gap-4 unfocused">
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible">
<div class="flex-expand align-middle">
<span class="text-3xl font-bold mr-4">{{ .strings.activity }}</span>
<div id="activity-filter-dropdown" class="dropdown z-10" tabindex="0">
<span class="h-100 button ~neutral @low center" id="activity-filter-button">{{ .strings.filters }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low mt-2" id="activity-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
<div id="activity-filter-dropdown" class="dropdown manual z-10 w-full" tabindex="0">
<div class="flex flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
<span class="text-3xl font-bold mr-4">{{ .strings.activity }}</span>
<div class="flex flex-row align-middle">
<span class="dropdown-manual-toggle"><button class="h-full button ~neutral @low center" id="activity-filter-button">{{ .strings.filters }}</button></span>
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
</div>
</div>
<div class="flex flex-row align-middle w-full">
<input type="search" class="field ~neutral @low input search mr-2" id="activity-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<button class="button ~info @low ml-2" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
</div>
</div>
<div class="dropdown-display max-w-full">
<div class="card ~neutral @low mt-2 overflow-x-scroll" id="activity-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</div>
</div>
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
<input type="search" class="field ~neutral @low input search ml-2 mr-2" id="activity-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<button class="button ~info @low ml-2" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
</div>
<div class="flex flex-row justify-between py-2">
<div class="flex flex-row justify-between pt-3 pb-2">
<div class="supra sm hidden" id="activity-search-options-header">{{ .strings.searchOptions }}</div>
<div class="supra sm">
<span id="activity-total-records" class="mx-2"></span>
<span id="activity-loaded-records" class="mx-2"></span>
<span id="activity-shown-records" class="mx-2"></span>
<div class="supra sm flex flex-row gap-2">
<span id="activity-total-records"></span>
<span id="activity-loaded-records"></span>
<span id="activity-shown-records"></span>
</div>
</div>
<div class="row -mx-2 mb-2">
@@ -840,34 +870,37 @@
</div>
</div>
</div>
<div id="tab-settings" class="unfocused">
<div class="card @low dark:~d_neutral settings overflow">
<div class="flex-expand">
<div class="flex-row">
<div 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>
<label for="settings-advanced-enabled" class="button ~neutral @low ml-2 my-2">
<label for="settings-advanced-enabled" class="button ~neutral @low ml-2">
<input type="checkbox" id="settings-advanced-enabled" aria-label="Advanced settings enabled">
<span class="ml-2">{{ .strings.advancedSettings }} </span>
</label>
</div>
<div>
<span class="button ~neutral @low my-1" id="settings-logs">{{ .strings.logs }}</span>
<span class="button ~info @low my-1" id="settings-backups">{{ .strings.backups }}</span>
<span class="button ~neutral @low my-1" id="settings-restart">{{ .strings.settingsRestart }}</span>
<span class="button ~urge @low unfocused my-1" id="settings-save">{{ .strings.settingsSave }}</span>
<div class="flex flex-row justify-start md:justify-end gap-2 w-full">
<span class="button ~neutral @low" id="settings-logs">{{ .strings.logs }}</span>
<span class="button ~info @low" id="settings-backups">{{ .strings.backups }}</span>
<span class="button ~neutral @low" id="settings-restart">{{ .strings.settingsRestart }}</span>
<span class="button ~urge @low unfocused" id="settings-save">{{ .strings.settingsSave }}</span>
</div>
</div>
<div class="flex flex-col md:flex-row gap-3">
<div class="card @low dark:~d_neutral col" id="settings-sidebar">
<div class="flex-expand">
<input type="search" class="field ~neutral @low input settings-section-button justify-between mb-2" id="settings-search" placeholder="{{ .strings.search }}">
<button class="button ~neutral @low center -ml-10 rounded-s-none mb-2 settings-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></button>
<div 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" 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>
@@ -882,6 +915,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>
@@ -18,7 +19,7 @@
<a class="button ~critical mb-4" target="_blank" href="https://github.com/hrfee/jfa-go/issues/new/choose">Create an Issue</a>
</section>
<section class="section ~neutral @low">
<div class="flex-expand">
<div class="flex flex-row justify-between">
<span class="subheading">Full Log</span>
<span class="button ~urge ml-4" id="copy-log">Copy</span>
</div>
@@ -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 }};
@@ -31,11 +27,20 @@
window.reCAPTCHASiteKey = "{{ .reCAPTCHASiteKey }}";
window.userPageEnabled = {{ .userPageEnabled }};
window.userPageAddress = "{{ .userPageAddress }}";
{{ if index . "customSuccessCard" }}
window.customSuccessCard = {{ .customSuccessCard }};
{{ else }}
window.customSuccessCard = false;
{{ end }}
</script>
{{ if .passwordReset }}
<script src="js/pwr.js" type="module"></script>
<script>
window.pwrPIN = "{{ .pwrPIN }}";
</script>
{{ else }}
<script src="js/form.js" type="module"></script>
{{ end }}
{{ if .reCAPTCHA }}
<script>
var reCAPTCHACallback = () => {
@@ -49,4 +54,3 @@
<script src="https://www.google.com/recaptcha/api.js?onload=reCAPTCHACallback&render=explicit" async defer></script>
{{ end }}
{{ 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" . }}
{{ if .passwordReset }}
<title>{{ .strings.passwordReset }}</title>
@@ -14,12 +13,19 @@
</head>
<body class="max-w-full overflow-x-hidden section">
<div id="modal-success" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ if .passwordReset }}{{ .strings.passwordReset }}{{ else }}{{ .strings.successHeader }}{{ end }}</span>
<p class="content mb-4">{{ if .passwordReset }}{{ .strings.youCanLoginPassword }}{{ else }}{{ .successMessage }}{{ end }}</p>
{{ if .userPageEnabled }}<p class="content mb-4" id="modal-success-user-page-area" my-account-term="{{ .strings.myAccount }}">{{ .strings.userPageSuccessMessage }}</p>{{ end }}
<a class="button ~urge @low full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a>
</div>
{{ if .customSuccessCard }}
<div class="card @low dark:~d_neutral content break-words relative mx-auto my-[10%] w-4/5 lg:w-1/3">
{{ .customSuccessCardContent }}
<a class="button ~urge @low full-width center supra submit my-2" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a>
</div>
{{ else }}
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ if .passwordReset }}{{ .strings.passwordReset }}{{ else }}{{ .strings.successHeader }}{{ end }}</span>
<p class="content mb-4">{{ if .passwordReset }}{{ .strings.youCanLoginPassword }}{{ else }}{{ .successMessage }}{{ end }}</p>
{{ if .userPageEnabled }}<p class="content mb-4" id="modal-success-user-page-area" my-account-term="{{ .strings.myAccount }}">{{ .strings.userPageSuccessMessage }}</p>{{ end }}
<a class="button ~urge @low full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a>
</div>
{{ end }}
</div>
<div id="modal-confirmation" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
@@ -28,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,12 +1,32 @@
<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 }}",
"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

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

View File

@@ -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 }}
@@ -35,11 +34,11 @@
<aside class="aside ~warning">
{{ .strings.changeYourPassword }}
</aside>
<span class="button ~urge @low w-100 text-center text-xl p-1 mt-4" id="pin" title="{{ .strings.copy }}">{{ .pin }}</span>
<span class="button ~urge @low w-full text-center text-xl p-1 mt-4" id="pin" title="{{ .strings.copy }}">{{ .pin }}</span>
{{ end }}
</div>
<i class="content">{{ .contactMessage }}</i>
</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,40 +70,22 @@
{{ 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>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="columns-1 sm:columns-2 gap-4" id="user-cardlist">
{{ if index . "PageMessageEnabled" }}
{{ if .PageMessageEnabled }}
<div class="card @low dark:~d_neutral content" id="card-message">
<div class="card @low dark:~d_neutral content break-words" id="card-message">
{{ .PageMessageContent }}
</div>
{{ end }}
@@ -162,7 +135,7 @@
<div class="card @low dark:~d_neutral unfocused" id="card-referrals">
<span class="heading mb-2">{{ .strings.referrals }}</span>
<aside class="aside ~neutral my-4 col user-referrals-description"></aside>
<div class="row flex-expand">
<div class="flex flex-row justify-between gap-2">
<div class="user-referrals-info"></div>
<div class="grid my-2">
<button type="button" class="user-referrals-button button ~info dark:~d_info @low" title="Copy">{{ .strings.copyReferral }}<i class="ri-file-copy-line ml-2"></i></button>
@@ -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: 56 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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -1,3 +1,4 @@
//go:build !external
// +build !external
package main
@@ -10,6 +11,8 @@ import (
const binaryType = "internal"
func BuildTagsExternal() {}
//go:embed data data/html data/web data/web/css data/web/js
var loFS embed.FS

82
jellyseerr-d.go Normal file
View File

@@ -0,0 +1,82 @@
package main
import (
"strconv"
"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(lm.FailedImportUser, lm.Jellyseerr, jfID, err)
return
}
if imported {
app.debug.Printf(lm.ImportJellyseerrUser, jfID, user.ID)
}
notif, err := app.js.GetNotificationPreferencesByID(user.ID)
if err != nil {
app.debug.Printf(lm.FailedGetJellyseerrNotificationPrefs, jfID, err)
return
}
contactMethods := map[jellyseerr.NotificationsField]any{}
email, ok := app.storage.GetEmailsKey(jfID)
if ok && email.Addr != "" && user.Email != email.Addr {
err = app.js.ModifyMainUserSettings(jfID, jellyseerr.MainUserSettings{Email: email.Addr})
if err != nil {
app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, jfID, err)
} else {
contactMethods[jellyseerr.FieldEmailEnabled] = email.Contact
}
}
if discordEnabled {
dcUser, ok := app.storage.GetDiscordKey(jfID)
if ok && dcUser.ID != "" && notif.DiscordID != dcUser.ID {
contactMethods[jellyseerr.FieldDiscord] = dcUser.ID
contactMethods[jellyseerr.FieldDiscordEnabled] = dcUser.Contact
}
}
if telegramEnabled {
tgUser, ok := app.storage.GetTelegramKey(jfID)
chatID, _ := strconv.ParseInt(notif.TelegramChatID, 10, 64)
if ok && tgUser.ChatID != 0 && chatID != tgUser.ChatID {
u, _ := app.storage.GetTelegramKey(jfID)
contactMethods[jellyseerr.FieldTelegram] = u.ChatID
contactMethods[jellyseerr.FieldTelegramEnabled] = tgUser.Contact
}
}
if len(contactMethods) != 0 {
err := app.js.ModifyNotifications(jfID, contactMethods)
if err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
}
}
func (app *appContext) SynchronizeJellyseerrUsers() {
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,
// but past issues with the Jellyfin db scare me from
// running these concurrently. W/e, its a bg task anyway.
for _, user := range users {
app.SynchronizeJellyseerrUser(user.ID)
}
}
func newJellyseerrDaemon(interval time.Duration, app *appContext) *GenericDaemon {
d := NewGenericDaemon(interval, app,
func(app *appContext) {
app.SynchronizeJellyseerrUsers()
},
)
d.Name("Jellyseerr import")
return d
}

9
jellyseerr/go.mod Normal file
View File

@@ -0,0 +1,9 @@
module github.com/hrfee/jfa-go/jellyseerr
replace github.com/hrfee/jfa-go/common => ../common
go 1.18
require github.com/hrfee/jfa-go/common v0.0.0-20240728190513-dabef831d769
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=

423
jellyseerr/jellyseerr.go Normal file
View File

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

View File

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

136
jellyseerr/models.go Normal file
View File

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

64
lang.go
View File

@@ -1,5 +1,7 @@
package main
import "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 +15,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 +44,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 +67,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,29 +84,30 @@ 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
}
type emailLang struct {
Meta langMeta `json:"meta"`
Strings langSection `json:"strings"`
UserCreated langSection `json:"userCreated"`
InviteExpiry langSection `json:"inviteExpiry"`
PasswordReset langSection `json:"passwordReset"`
UserDeleted langSection `json:"userDeleted"`
UserDisabled langSection `json:"userDisabled"`
UserEnabled langSection `json:"userEnabled"`
InviteEmail langSection `json:"inviteEmail"`
WelcomeEmail langSection `json:"welcomeEmail"`
EmailConfirmation langSection `json:"emailConfirmation"`
UserExpired langSection `json:"userExpired"`
Meta langMeta `json:"meta"`
Strings langSection `json:"strings"`
UserCreated langSection `json:"userCreated"`
InviteExpiry langSection `json:"inviteExpiry"`
PasswordReset langSection `json:"passwordReset"`
UserDeleted langSection `json:"userDeleted"`
UserDisabled langSection `json:"userDisabled"`
UserEnabled langSection `json:"userEnabled"`
UserExpiryAdjusted langSection `json:"userExpiryAdjusted"`
InviteEmail langSection `json:"inviteEmail"`
WelcomeEmail langSection `json:"welcomeEmail"`
EmailConfirmation langSection `json:"emailConfirmation"`
UserExpired langSection `json:"userExpired"`
}
type setupLangs map[string]setupLang
@@ -121,6 +124,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"`
@@ -133,11 +137,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
@@ -150,11 +154,11 @@ 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

View File

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

View File

@@ -3,7 +3,7 @@
"name": "Deutsch (DE)"
},
"strings": {
"invites": "Invites",
"invites": "Einladungen",
"accounts": "Konten",
"settings": "Einstellungen",
"inviteDays": "Tage",
@@ -94,7 +94,7 @@
"accessJFA": "jfa-go Zugriff",
"sendPWRValidFor": "Der Link ist 30m gültig.",
"logs": "Logdaten",
"setExpiry": "Ablauf setzen",
"setExpiry": "Ablaufdatum setzen",
"sendPWRSuccess": "Link zur Passwortrücksetzung versandt.",
"sendPWRSuccessManual": "Falls der Benutzer ihn nicht erhalten hat, klicke \"Kopieren\" und sende ihm den Link manuell.",
"sendPWR": "Sende Passwortrücksetzung",
@@ -115,7 +115,28 @@
"after": "nach",
"before": "vor",
"unlink": "Account trennen",
"sortingBy": "Sortieren nach"
"sortingBy": "Sortieren nach",
"activity": "Aktivität",
"settingsMaybeUnderAdvanced": "Tipp: Du könntest finden, wonach Du suchst, indem Du die erweiterten Einstellungen aktivierst.",
"enableReferralsProfileDescription": "Gib Benutzern, die mit diesem Profil erstellt wurden, einen persönlichen Empfehlungslink, ähnlich einer Einladung, den sie an Freunde und Familie senden können. Erstelle eine Einladung mit den gewünschten Einstellungen und wähle sie dann hier aus. Jede Empfehlung basiert dann auf dieser Einladung. Du kannst die Einladung nach Abschluss löschen.",
"removeExpiry": "Ablaufdatum entfernen",
"enterExpiry": "Ablaufdatum eingeben",
"keepSearchingDescription": "Die Suche umfasst nur bereits geladene Aktivitäten. Klicke unten um alle Aktivitäten zu durchsuchen.",
"useInviteExpiry": "Ablaufdatum des Profils/der Einladung setzen",
"useInviteExpiryNote": "Standardmässig laufen Einladungen nach 90 Tagen ab, können jedoch vom Benutzer erneuert werden. Aktiviere diese Option, damit die Empfehlung nach der festgelegten Zeit deaktiviert wird.",
"settingsHiddenDependency": "Zutreffende Einstellungen sind ausgeblendet, da sie vom Wert einer anderen Einstellung abhängen:",
"deleted": "Gelöscht",
"disabled": "Deaktiviert",
"keepSearching": "Weiter suchen",
"enableReferralsDescription": "Gib Benutzern einen persönlichen Empfehlungslink, ähnlich einer Einladung, den sie an Freunde und Familie senden können. Dieser kann aus einer Empfehlungsvorlage im Profil oder aus einer bestehenden Einladung stammen.",
"settingsDependsOn": "{setting}: abhängig von {dependency}",
"settingsAdvancedMode": "{setting}: Erweiterte Einstellungen müssen aktiviert sein",
"invite": "Einladung",
"userLabelDescription": "Label welches auf Benutzer angewendet wird, die mit dieser Einladung erstellt wurden.",
"enableReferrals": "Empfehlungen aktivieren",
"disableReferrals": "Empfehlungen deaktivieren",
"userLabel": "Benutzer Label",
"noResultsFound": "Keine Resultate gefunden"
},
"notifications": {
"changedEmailAddress": "E-Mail-Adresse von {n} geändert.",

View File

@@ -81,6 +81,9 @@
"useInviteExpiry": "Set expiry from profile/invite",
"useInviteExpiryNote": "By default, invites expire after 90 days but can be renewed by the user. Enable for the referral to be disabled after the time set.",
"applyHomescreenLayout": "Apply homescreen layout",
"applyConfigurationAndPolicy": "Apply Jellyfin configuration/policy",
"applyOmbi": "Apply Ombi profile (if available)",
"applyJellyseerr": "Apply Jellyseerr profile (if available)",
"sendDeleteNotificationEmail": "Send notification message",
"sendDeleteNotifiationExample": "Your account has been deleted.",
"settingsRestart": "Restart",
@@ -99,6 +102,8 @@
"settingsMaybeUnderAdvanced": "Tip: You might find what you're looking for by enabling Advanced Settings.",
"ombiProfile": "Ombi user profile",
"ombiUserDefaultsDescription": "Create an Ombi user and configure it, then select it below. It's settings/permissions will be stored and applied to new Ombi users created by jfa-go when this profile is selected.",
"jellyseerrProfile": "Jellyseerr user profile",
"jellyseerrUserDefaultsDescription": "Create a Jellyseerr user and configure it, then select it below. It's settings/permissions will be stored and applied to new Jellyseerr users created by jfa-go when this profile is selected.",
"userProfiles": "User Profiles",
"userProfilesDescription": "Profiles are applied to users when they create an account. A profile includes library access rights and homescreen layout.",
"userProfilesIsDefault": "Default",
@@ -137,8 +142,11 @@
"jellyfinID": "Jellyfin ID",
"userPageLogin": "User Page: Login",
"userPagePage": "User Page: Page",
"postSignupCard": "Post-signup help card",
"postSignupCardDescription": "Card shown to user after signing up. Overrides \"Success Message\". Overriden by \"Auto redirect on success\" setting.",
"buildTime": "Build Time",
"builtBy": "Built By",
"buildTags": "Build Tags",
"loginNotAdmin": "Not an Admin?",
"referrer": "Referrer",
"accountLinked": "{contactMethod} linked: {user}",
@@ -150,7 +158,8 @@
"accountDisabled": "Account disabled: {user}",
"accountReEnabled": "Account re-enabled: {user}",
"accountExpired": "Account expired: {user}",
"accountWillExpire": "Account will expire on {date}",
"accountWillExpire": "Account will expire on {date}.",
"expirationBasedOn": "Given date based on 1st user.",
"userDeleted": "User was deleted.",
"userDisabled": "User was disabled",
"inviteCreated": "Invite created: {invite}",
@@ -193,7 +202,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.",
@@ -205,6 +215,7 @@
"sentAnnouncement": "Announcement sent.",
"savedAnnouncement": "Announcement saved.",
"setOmbiProfile": "Stored ombi profile.",
"savedProfile": "Stored profile changes.",
"updateApplied": "Update applied, please restart.",
"updateAppliedRefresh": "Update applied, please refresh.",
"telegramVerified": "Telegram account verified.",
@@ -221,6 +232,7 @@
"errorDeleteProfile": "Failed to delete profile {n}",
"errorLoadProfiles": "Failed to load profiles.",
"errorCreateProfile": "Failed to create profile {n}",
"errorSavedProfile": "Failed to save profile {n}",
"errorSetDefaultProfile": "Failed to set default profile.",
"errorLoadUsers": "Failed to load users.",
"errorLoadSettings": "Failed to load settings.",

View File

@@ -116,7 +116,27 @@
"after": "Después",
"before": "Antes",
"unlink": "Desvincular cuenta",
"clickToRemoveFilter": "Haga clic para eliminar el filtro."
"clickToRemoveFilter": "Haga clic para eliminar el filtro.",
"removeExpiry": "Eliminar caducidad",
"enterExpiry": "Introduzca una caducidad",
"useInviteExpiry": "Establecer caducidad desde el perfil/invitación",
"noResultsFound": "Ningún resultado encontrado",
"settingsDependsOn": "{setting}: Depende de {dependency}",
"activity": "Actividad",
"disabled": "Desactivado",
"deleted": "Eliminado",
"keepSearching": "Seguir buscando",
"keepSearchingDescription": "Solo se ha buscado en las actividades cargadas actualmente. Clique a continuación si quiere buscar en todas las actividades.",
"settingsAdvancedMode": "{setting}: Los ajustes avanzados deben estar habilitados",
"settingsMaybeUnderAdvanced": "Consejo: Puede que encuentre lo que busca si habilita los Ajustes avanzados.",
"invite": "Invitar",
"userLabel": "Etiqueta de usuario",
"userLabelDescription": "Etiqueta que aplicar a usuarios creados con esta invitación.",
"enableReferrals": "Habilitar referencias",
"disableReferrals": "Deshabilitar referencias",
"enableReferralsDescription": "Proporciona a los usuarios un enlace personal de referencia, parecido a una invitación, para que lo compartan con amigos y familiares. Puede conseguirse a través de una plantilla de referencia en un perfil, o a través de una invitación existente.",
"enableReferralsProfileDescription": "Proporciona a los usuarios creados con este perfil un enlace personal de referencia, parecido a una invitación, para que lo compartan con amigos y familiares. Cree una invitación con los ajustes deseados y selecciónela aquí. Cada referencia se basará en esta invitación. Puede eliminar la invitación una vez completado.",
"useInviteExpiryNote": "Por defecto las invitaciones caducan a los 90 días, pero pueden ser renovadas por el usuario. Habilite que la referencia sea desactivada cuando pase el tiempo establecido."
},
"notifications": {
"changedEmailAddress": "Se cambió la dirección de correo electrónico de {n}.",

View File

@@ -125,7 +125,78 @@
"userLabel": "Étiquette",
"userLabelDescription": "Étiquette à appliquer aux utilisateurs créés avec cette invitation.",
"disableReferrals": "Désactiver Parrainage",
"enableReferralsProfileDescription": "Donnez aux utilisateurs créés avec ce profil un lien de parrainage personnel semblable à une invitation, à envoyer à vos amis/famille. Créez une invitation avec les paramètres souhaités, puis sélectionnez-la ici. Chaque référence sera alors basée sur cette invitation. Vous pouvez supprimer l'invitation une fois terminée."
"enableReferralsProfileDescription": "Donnez aux utilisateurs créés avec ce profil un lien de parrainage personnel semblable à une invitation, à envoyer à vos amis/famille. Créez une invitation avec les paramètres souhaités, puis sélectionnez-la ici. Chaque référence sera alors basée sur cette invitation. Vous pouvez supprimer l'invitation une fois terminée.",
"loginNotAdmin": "Vous n'êtes pas administrateur ?",
"removeExpiry": "Supprimer l'expiration",
"enterExpiry": "Entrez une date d'expiration",
"useInviteExpiry": "Définir l'expiration du profil/invitation",
"sortDirection": "Ordre de trie",
"referrer": "Référence",
"accountLinked": "{contactMethod} lié : {user}",
"accountUnlinked": "{contactMethod} supprimé : {user}",
"accountResetPassword": "{user} réinitialise son mot de passe",
"expirationBasedOn": "Date donnée basée sur le 1er utilisateur.",
"accountDeleted": "Compte supprimé : {user}",
"accountChangedPassword": "{user} a changé son mot de passe",
"accountCreated": "Compte créé : {user}",
"accountDisabled": "Compte désactivé : {user}",
"accountReEnabled": "Compte réactivé : {user}",
"accountExpired": "Compte expiré : {user}",
"accountWillExpire": "Le compte expirera le {date}.",
"backups": "Sauvegardes",
"backupsDescription": "Des sauvegardes de la base de données peuvent être effectuées, restaurées ou téléchargées à partir d'ici.",
"backupsCopy": "Lors de l'application d'une sauvegarde, une copie du dossier \"db\" d'origine sera créée à côté, en cas de problème.",
"backupDownloadRestore": "Télécharger/Restaurer",
"backupUpload": "Télécharger et restaurer la sauvegarde",
"backupDownload": "Télécharger la sauvegarde",
"backupRestore": "Restaurer la sauvegarde",
"backupNow": "Sauvegarder maintenant",
"backupCreated": "Sauvegarde créée",
"backupCanDownload": "Vous pouvez également cliquer ci-dessous pour télécharger la sauvegarde.",
"wikiPage": "Wiki page",
"activity": "Activité",
"deleted": "Supprimé",
"disabled": "Désactivé",
"keepSearching": "Continuer la recherche",
"keepSearchingDescription": "Seules les activités actuellement chargées ont été recherchées. Cliquez ci-dessous si vous souhaitez rechercher toutes les activités.",
"settingsHiddenDependency": "Les paramètres correspondants sont masqués car ils dépendent de la valeur d'un autre paramètre :",
"settingsDependsOn": "{setting} : dépend de {dependency}",
"settingsMaybeUnderAdvanced": "Astuce : Vous trouverez peut-être ce que vous cherchez en activant les paramètres avancés.",
"settingsAdvancedMode": "{setting} : les paramètres avancés doivent être activés",
"actorDescription": "La chose qui a provoqué cette action. \"user\"/\"admin\"/\"service\" ou un nom d'utilisateur.",
"activityID": "ID d'activité",
"byUser": "Par Utilisateur",
"inviteExpired": "Invitation expirée : {invite}",
"byJfaGo": "Par jfa-go",
"accountDisabledFilter": "Compte désactivé",
"inviteCreated": "Invitation créée : {invite}",
"inviteDeleted": "Invitation supprimée : {invite}",
"fromInvite": "À partir de l'invitation",
"accountDeletionFilter": "Suppression de compte",
"userDeleted": "L'utilisateur a été supprimé.",
"userDisabled": "L'utilisateur a été désactivé",
"accountCreationFilter": "Création de compte",
"title": "Titre",
"usersMentioned": "Utilisateur mentionné",
"actor": "Acteur",
"byAdmin": "Par Administrateur",
"passwordResetFilter": "Réinitialisation du mot de passe",
"loadMore": "Charger plus",
"accountEnabledFilter": "Compte activé",
"inviteCreatedFilter": "Invitation crée",
"inviteDeletedFilter": "Invitation supprimée/expirée",
"noMoreResults": "Plus de résultats.",
"totalRecords": "{n} Nombre total d'enregistrements",
"passwordChangeFilter": "Mot de passe changé",
"loadedRecords": "{n} Chargé",
"shownRecords": "{n} affiché",
"contactUnlinkedFilter": "Contact sans lien",
"contactLinkedFilter": "Contact lié",
"loadAll": "Tout charger",
"noResultsFound": "Aucun résultat trouvé",
"useInviteExpiryNote": "Par défaut, les invitations expirent après 90 jours mais peuvent être renouvelées par l'utilisateur. Activez la désactivation de la référence après le délai défini.",
"backupsFormatNote": "Seuls les fichiers de sauvegarde au format standard seront affichés ici. Pour en utiliser un autre, veuillez charger la sauvegarde manuellement.",
"backupCanBeFound": "La sauvegarde peut être trouvée sur le serveur à {filepath}."
},
"notifications": {
"changedEmailAddress": "Adresse e-mail modifiée de {n}.",
@@ -165,7 +236,13 @@
"setOmbiProfile": "Profil ombi enregistré.",
"errorSetOmbiProfile": "Echec de la sauvegarde du profil ombi.",
"errorNoReferralTemplate": "Le profil ne contient pas de modèle de référence, ajoutez-en un dans les paramètres.",
"referralsEnabled": "Parrainage activer."
"referralsEnabled": "Parrainage activer.",
"errorLoadActivities": "Échec du chargement des activités.",
"pathCopied": "Chemin complet copié dans le presse-papiers.",
"activityDeleted": "Activité supprimée.",
"errorInviteNoLongerExists": "L'invitation n'existe plus.",
"errorInviteNotFound": "Invitation introuvable.",
"errorInvalidDate": "La date n'est pas valide."
},
"quantityStrings": {
"modifySettingsFor": {

View File

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

View File

@@ -130,7 +130,72 @@
"searchOptions": "Zoekopties",
"matchText": "Tekstovereenkomst",
"jellyfinID": "Jellyfin ID",
"userPagePage": "Gebruikerspagina: Pagina"
"userPagePage": "Gebruikerspagina: Pagina",
"activity": "Activiteit",
"deleted": "Verwijderd",
"disabled": "Uitgeschakeld",
"keepSearching": "Blijf zoeken",
"keepSearchingDescription": "Alleen momenteel ingeladen activiteiten zijn doorzocht. Klik hieronder om alle activiteiten te doorzoeken.",
"sortDirection": "Sorteerrichting",
"referrer": "Verwijzer",
"accountLinked": "{contactMethod} gekoppeld: {user}",
"accountUnlinked": "{contactMethod} verwijderd: {user}",
"accountResetPassword": "{user} heeft hun wachtwoord gereset",
"accountChangedPassword": "{user} heeft hun wachtwoord gewijzigd",
"accountDisabled": "Account uitgeschakeld: {user}",
"accountDeleted": "Account verwijderd: {user}",
"accountCreated": "Account aangemaakt: {user}",
"accountReEnabled": "Account opnieuw ingeschakeld: {user}",
"accountExpired": "Account verlopen: {user}",
"userDeleted": "Gebruiker is verwijderd.",
"userDisabled": "Gebruiker is uitgeschakeld",
"inviteCreated": "Uitnodiging aangemaakt: {invite}",
"inviteDeleted": "Uitnodiging verwijderd: {invite}",
"inviteExpired": "Uitnodiging verlopen: {invite}",
"fromInvite": "Via uitnodiging",
"byAdmin": "Door beheerder",
"byUser": "Door gebruiker",
"byJfaGo": "Door jfa-go",
"activityID": "Activiteit ID",
"title": "Titel",
"usersMentioned": "Genoemde gebruiker",
"actor": "Uitvoerder",
"actorDescription": "Wat deze actie veroorzaakt heeft. \"gebruiker\"/\"beheerder\"/\"daemon\" of een gebruikersnaam.",
"accountCreationFilter": "Aanmaken van account",
"accountDeletionFilter": "Verwijderen van account",
"accountDisabledFilter": "Account uitgeschakeld",
"accountEnabledFilter": "Account ingeschakeld",
"contactLinkedFilter": "Contact gekoppeld",
"contactUnlinkedFilter": "Contact ontkoppeld",
"passwordChangeFilter": "Wachtwoord gewijzigd",
"passwordResetFilter": "Wachtwoord gereset",
"inviteCreatedFilter": "Uitnodiging aangemaakt",
"inviteDeletedFilter": "Uitnodiging verwijderd/verlopen",
"loadMore": "Laad meer",
"loadAll": "Laad alles",
"noMoreResults": "Niet meer resultaten.",
"totalRecords": "{n} documenten totaal",
"loadedRecords": "{n} geladen",
"shownRecords": "{n} getoond",
"useInviteExpiry": "Neem verloop over van profiel/uitnodiging",
"backups": "Backups",
"removeExpiry": "Verwijder verloop",
"enterExpiry": "Voer verloop in",
"accountWillExpire": "Account verloopt op {date}.",
"expirationBasedOn": "Datum gebaseerd op 1e gebruiker.",
"backupsFormatNote": "Alleen backupbestanden met het standaard naamformaat worden hier getoond. Upload handmatig om een ander bestand te gebruiken.",
"backupDownloadRestore": "Downloaden / Terugzetten",
"backupUpload": "Upload backup & zet terug",
"backupDownload": "Download backup",
"backupRestore": "Backup terugzetten",
"backupNow": "Nu backup maken",
"backupCreated": "Backup gemaakt",
"backupCanBeFound": "De backup kan op de server gevonden worden onder {filepath}.",
"backupCanDownload": "Of klik hieronder om de backup te downloaden.",
"wikiPage": "Wiki pagina",
"useInviteExpiryNote": "Standaard verlopen uitnodigingen na 90 dagen, maar kunnen ze vernieuwd worden door de gebruiker. Schakel in om de verwijzing uit te schakelen na de ingestelde tijd.",
"backupsDescription": "Hier kunnen backups van de database gemaakt, teruggezet, of gedownload worden.",
"backupsCopy": "Bij het toepassen van een backup wordt er een kopie van de originele \"db\" folder naast gemaakt, voor het geval er iets misgaat."
},
"notifications": {
"changedEmailAddress": "E-mailadres van {n} gewijzigd.",
@@ -170,7 +235,13 @@
"setOmbiProfile": "Opgeslagen ombi-profiel.",
"errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt.",
"errorNoReferralTemplate": "Profiel bevat geen verwijzingssjabloon, voeg er een toe bij instellingen.",
"referralsEnabled": "Verwijzingen actief."
"referralsEnabled": "Verwijzingen actief.",
"activityDeleted": "Activiteit verwijderd.",
"errorInviteNoLongerExists": "Uitnodiging bestaat niet meer.",
"errorInviteNotFound": "Uitnodiging niet gevonden.",
"errorLoadActivities": "Laden van activiteiten mislukt.",
"pathCopied": "Volledig pad gekopieerd naar klembord.",
"errorInvalidDate": "Ongeldige datum."
},
"quantityStrings": {
"modifySettingsFor": {

View File

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

View File

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

View File

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

View File

@@ -48,7 +48,8 @@
"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 +65,4 @@
"plural": "{n} Days"
}
}
}
}

View File

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

View File

@@ -5,7 +5,7 @@
"strings": {
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"emailAddress": "Adresse Email",
"emailAddress": "Adresse mail",
"name": "Nom",
"submit": "Soumettre",
"send": "Envoyer",
@@ -29,9 +29,9 @@
"logout": "Se déconnecter",
"admin": "Administrateur",
"enabled": "Activé",
"disabled": "Désactiver",
"disabled": "Désactivé",
"reEnable": "Ré-activé",
"disable": "Désactivé",
"disable": "Désactiver",
"expiry": "Expiration",
"add": "Ajouter",
"edit": "Éditer",

View File

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

View File

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

View File

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

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

@@ -0,0 +1,88 @@
{
"meta": {
"name": "کوردی سۆرانی"
},
"strings": {
"pageTitle": "دروستکردنی هەژماری جێڵیفن",
"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": "",
"resetPassword": "",
"resetPasswordThroughJellyfin": "",
"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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,6 +34,7 @@
"endPage": {
"finished": "Finished!",
"restartMessage": "Features like Discord/Telegram/Matrix bots, custom Markdown messages, and a user-accessible \"My Account\" page can be found in Settings, so make sure to give it a browse. Click below to restart, then refresh the page.",
"urlChangedNotice": "If you've changed the host, port, subfolder etc. that jfa-go is hosted on, check the URL is right.",
"refreshPage": "Refresh"
},
"language": {
@@ -47,7 +48,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 +97,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 +144,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 +166,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",

View File

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

View File

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

View File

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

View File

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

View File

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

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