Compare commits

...

895 Commits

Author SHA1 Message Date
Harvey Tindall
1d7d82b793 search: fix server-side date behaviour
OffsetMinutesFromUTC was being passed incorrectly by the web app
(getTimezoneOffset if UTC - Timezone, we wanted Timezone - UTC), now
fixed. This value is now used if given in comparisons. Times are
truncated to minute-deep precision, and Any date comparison ignores
empty date fields (i.e. a unix timestamp being 0 or a time.Time.IsZero()
== true).
2025-05-27 14:57:56 +01:00
Harvey Tindall
b40abafb95 list: cleanup, include offset in DateAttempt
included UTC offset in minutes in DateAttempt, will be used shortly.
Also moved this stuff (ParsedDate, DateAttempt) to the common d.ts, and
the method for parsing from a string (now parseDateString) to common.
Also pre-emptively load the user cache when the admin page loads.
2025-05-27 14:29:09 +01:00
Harvey Tindall
18f8921eba list: add back default sort to _load
accidentally deleted it when merging reload and loadMore.
2025-05-27 11:00:58 +01:00
Harvey Tindall
285215cf4b Merge branch 'main' into user-pagination 2025-05-27 10:41:22 +01:00
Harvey Tindall
fe4097a724 email: remove ssltls mention 2025-05-27 10:27:35 +01:00
Harvey Tindall
364b010ceb list: refactor loading methods 2025-05-27 10:27:02 +01:00
Harvey Tindall
37bdf50bb0 Merge branch 'user-pagination' of github.com:hrfee/jfa-go into user-pagination 2025-05-26 21:52:45 +01:00
Harvey Tindall
70e35b8bd7 list: fix activities sort, css improvements
fixed activities sorting.
less oddities from me not knowing how to use flex boxes. Also kinda
standardised a button-inside-input thing, so added a .gap-<n> >
.inside-input selector which works on gap-1 and gap-2.
2025-05-26 21:52:31 +01:00
Harvey Tindall
2657e74803 search: fix "search all" button disabling logic, more
just a few more general fixes. Also changed the "Search all" button to
say "Search/sort all".
2025-05-26 21:52:31 +01:00
Harvey Tindall
372514709d search: add localOnly to web app queries, fix string+bool queries
localOnly: true in a queryType means it won't be sent to the server, but
will be evaluated by the web app on the returned search results.
2025-05-26 21:52:31 +01:00
Harvey Tindall
c922dc5b50 search: fix server-side dates, add mentionedUsers, referrer, time
QueryDTO.Value being classed as "any" meant DateAttempts would be
unmarshaled as map[string]any, so a custom UnmarshalJSON checks the data
type and unmarshals into a DateAttempt if needed. mentionedUers,
referrer and time matching implemented for activity search. Also, fixed
multi-class queries (e.g. date -and- bool for last-active).
2025-05-26 21:52:31 +01:00
Harvey Tindall
6fff8a887e activity: basic query support
only supports queries of fields actually in Activity. The web UI only
directly queries two of these, ID and Time (mistakenly referneced as date in
the web ui previously). Later commits will come up with creative ways of
dealing with all the other query types.
2025-05-26 21:52:31 +01:00
Harvey Tindall
0a7093a3b4 activities: fix updateExistingElements, rename
forgot to wipe this._ordering, so search was attempting to run on
non-existent items. Also renamed the two functions to appendNewItems and
replaceWithNewItems, this makes more sense to me.
2025-05-26 21:52:31 +01:00
Harvey Tindall
d8fe593323 list: fix RecordCounter, inf scroll in search, activities 2025-05-26 21:52:31 +01:00
Harvey Tindall
4dcec4b9c7 accounts: fix infinite scroll over-loading, use scrollend+polyfill
calculation for number of rows to be drawn was wrong, fixed now. To
compensate for overshooting with fast scrolling, speed is calculated
using previous scrollY in rows/scroll, and used to render more rows.
Also, the "scrollend" event is used to load more at the end of a scroll
always. Since this isn't available on safari/webkit(2gtk), a polyfill
has been added.
2025-05-26 21:52:31 +01:00
Harvey Tindall
ac56ad1400 accounts: add credit for infinite scroll 2025-05-26 21:52:31 +01:00
Harvey Tindall
d09ee59a1a accounts: infinite scroll for performance
Found out the bottleneck when ~2000 or more elements are loaded isn't
the search or sort or anything, but the DOM. An infinite scroll
implementation is added, where elements are added to the DOM as you
scroll. May still be a little buggy, and can't yet cope with screen
resizes. Also, the "shown" indicator is broken.
2025-05-26 21:52:31 +01:00
Harvey Tindall
3299398806 config: add user cache async/sync timeout options
previously were constants in usercache.go, now app.userCache is
instantiated in main.go with NewUserCache(time.Duration, time.Duration).
2025-05-26 21:52:31 +01:00
Harvey Tindall
b53120f271 usercache: cleanup, also elsewhere
removed some old FIXMEs and documented usercache nicely for once,
renaming some things too.
2025-05-26 21:52:31 +01:00
Harvey Tindall
1dfe13951f activities: fix slow load w/ lots of users
Added ID/Name indexing to mediabrowser, and cleaned it up a little bit.
Was taking ~2s with 5000 users and firing tons of requests at Jellyfin,
now take ~160ms from cold boot, ~1ms with cache.
2025-05-26 21:52:31 +01:00
Harvey Tindall
732ce1bc57 search: more server-search refinement
fixed bugs, added extra text on "no results found" to suggest server
searching, and conditionally disable the button based on search content
and current sort. Activities page still broken. Also fixed up cache
generation, only one should ever run now, as should sorting. Two time
thresholds exist, one to trigger a re-sync but do it in the background
(i.e. send the old one to the requester), and one to re-sync and wait
for it.
2025-05-26 21:52:31 +01:00
Harvey Tindall
94e076401e accounts: pagination, server-side search
Pagination fully factored out, and both Activities and Accounts now use
a PaginatedList superclass. Server-side search is done by pressing enter
after typing a search, or by pressing the search button. Works on
accounts, soon will on activities if it doesn't already.
2025-05-26 21:52:31 +01:00
Harvey Tindall
fb83094532 search: factor out date and bool comparison 2025-05-26 21:52:31 +01:00
Harvey Tindall
dec5197bfd usercache: we'll do it ourselves
we don't need expr or anything like that, cmp.Less and vim macros exist.
2025-05-26 21:52:31 +01:00
Harvey Tindall
ebff016b5d accounts: add "record count", start searchable user cache
RecordCounter class created from that in activityList, and put in
accountsList. PageCount-type route standardized and made for /users
(/users/count). Created userCache, which regularly generates the
respUser list returned by /users. Added a currently dumb POST /users for
searching/pagination, GET /users is now just for getting -all- users.
go-getted expr, an expression language that seems like it'll be useful
for evaluating local searches. We don't store this data in the badger
    DB, so we can't use the nice query form provided by badgerhold.
2025-05-26 21:52:31 +01:00
Harvey Tindall
da0dc7f1c0 email: Allow no SMTP encryption
added a "none" option.
2025-05-26 21:44:38 +01:00
Harvey Tindall
f6044578c0 list: fix activities sort, css improvements
fixed activities sorting.
less oddities from me not knowing how to use flex boxes. Also kinda
standardised a button-inside-input thing, so added a .gap-<n> >
.inside-input selector which works on gap-1 and gap-2.
2025-05-26 21:42:37 +01:00
Harvey Tindall
699cbee240 search: fix "search all" button disabling logic, more
just a few more general fixes. Also changed the "Search all" button to
say "Search/sort all".
2025-05-26 17:28:09 +01:00
Harvey Tindall
ef253de56b search: add localOnly to web app queries, fix string+bool queries
localOnly: true in a queryType means it won't be sent to the server, but
will be evaluated by the web app on the returned search results.
2025-05-26 16:06:41 +01:00
Harvey Tindall
9715f90a48 search: fix server-side dates, add mentionedUsers, referrer, time
QueryDTO.Value being classed as "any" meant DateAttempts would be
unmarshaled as map[string]any, so a custom UnmarshalJSON checks the data
type and unmarshals into a DateAttempt if needed. mentionedUers,
referrer and time matching implemented for activity search. Also, fixed
multi-class queries (e.g. date -and- bool for last-active).
2025-05-26 15:09:40 +01:00
Harvey Tindall
792296e3bc activity: basic query support
only supports queries of fields actually in Activity. The web UI only
directly queries two of these, ID and Time (mistakenly referneced as date in
the web ui previously). Later commits will come up with creative ways of
dealing with all the other query types.
2025-05-23 16:37:42 +01:00
Harvey Tindall
31d3e52229 activities: fix updateExistingElements, rename
forgot to wipe this._ordering, so search was attempting to run on
non-existent items. Also renamed the two functions to appendNewItems and
replaceWithNewItems, this makes more sense to me.
2025-05-23 15:02:42 +01:00
Harvey Tindall
4a92712c90 list: fix RecordCounter, inf scroll in search, activities 2025-05-23 14:54:00 +01:00
Harvey Tindall
47188da5c2 accounts: fix infinite scroll over-loading, use scrollend+polyfill
calculation for number of rows to be drawn was wrong, fixed now. To
compensate for overshooting with fast scrolling, speed is calculated
using previous scrollY in rows/scroll, and used to render more rows.
Also, the "scrollend" event is used to load more at the end of a scroll
always. Since this isn't available on safari/webkit(2gtk), a polyfill
has been added.
2025-05-23 13:58:04 +01:00
Harvey Tindall
bdae52fad7 accounts: add credit for infinite scroll 2025-05-22 21:38:30 +01:00
Harvey Tindall
1ec3ddad9f accounts: infinite scroll for performance
Found out the bottleneck when ~2000 or more elements are loaded isn't
the search or sort or anything, but the DOM. An infinite scroll
implementation is added, where elements are added to the DOM as you
scroll. May still be a little buggy, and can't yet cope with screen
resizes. Also, the "shown" indicator is broken.
2025-05-22 21:10:49 +01:00
Harvey Tindall
64a144034d config: add user cache async/sync timeout options
previously were constants in usercache.go, now app.userCache is
instantiated in main.go with NewUserCache(time.Duration, time.Duration).
2025-05-22 18:03:18 +01:00
Harvey Tindall
d0f740f99d usercache: cleanup, also elsewhere
removed some old FIXMEs and documented usercache nicely for once,
renaming some things too.
2025-05-22 14:08:17 +01:00
Harvey Tindall
58c7b695c9 activities: fix slow load w/ lots of users
Added ID/Name indexing to mediabrowser, and cleaned it up a little bit.
Was taking ~2s with 5000 users and firing tons of requests at Jellyfin,
now take ~160ms from cold boot, ~1ms with cache.
2025-05-21 21:45:53 +01:00
Harvey Tindall
b19efc4ee6 search: more server-search refinement
fixed bugs, added extra text on "no results found" to suggest server
searching, and conditionally disable the button based on search content
and current sort. Activities page still broken. Also fixed up cache
generation, only one should ever run now, as should sorting. Two time
thresholds exist, one to trigger a re-sync but do it in the background
(i.e. send the old one to the requester), and one to re-sync and wait
for it.
2025-05-21 15:23:26 +01:00
Harvey Tindall
8ba6131d22 accounts: pagination, server-side search
Pagination fully factored out, and both Activities and Accounts now use
a PaginatedList superclass. Server-side search is done by pressing enter
after typing a search, or by pressing the search button. Works on
accounts, soon will on activities if it doesn't already.
2025-05-20 18:57:16 +01:00
Harvey Tindall
c5683dbc71 search: factor out date and bool comparison 2025-05-16 16:50:13 +01:00
Harvey Tindall
3067db9c31 usercache: we'll do it ourselves
we don't need expr or anything like that, cmp.Less and vim macros exist.
2025-05-15 20:08:52 +01:00
Harvey Tindall
28440a9096 accounts: add "record count", start searchable user cache
RecordCounter class created from that in activityList, and put in
accountsList. PageCount-type route standardized and made for /users
(/users/count). Created userCache, which regularly generates the
respUser list returned by /users. Added a currently dumb POST /users for
searching/pagination, GET /users is now just for getting -all- users.
go-getted expr, an expression language that seems like it'll be useful
for evaluating local searches. We don't store this data in the badger
    DB, so we can't use the nice query form provided by badgerhold.
2025-05-15 19:19:51 +01:00
Harvey Tindall
07d02f8302 discord: fix admin-check for /inv
it was being checked in the EmailAddress record, only set if Jellyfin
login is disabled, or "access jfa-go" is checked for a
non-Jellyfin-admin user in Accounts. Instead, i've factored out the
actual auth code into a "canAccessAdminPage"-ish function, which is
called for this too. Should fix #378.
2025-05-15 17:50:18 +01:00
Harvey Tindall
01a75c3e23 settings: add jellyseerr wiki link, clarify API key src
An API key is shown in Jellyseerr's setup which is actually for
Jellyfin. I (and I imagine other users have) copied it expecting it was
for Jellyseerr and was surprised the app didn't work. It's now clarified
    in the API Key setting description to get it from the first tab in
    Jellyseerr, and not the "Jellyfin" tab.
2025-05-15 16:14:15 +01:00
Harvey Tindall
4cc5fd7189 mediabrowser: bump for parental rating setting
fixes #382.
2025-05-15 15:38:35 +01:00
Harvey Tindall
16c5420c6f setup: show internal and external links on finish
internal generated from host, port and url_base, external is just
jfa_url. Both are shown (if jfa_url is set).
2025-05-15 15:10:07 +01:00
Harvey Tindall
eab33d9f6d captcha: fix for custom invite form subpath 2025-05-14 22:58:37 +01:00
Harvey Tindall
471021623b userpage: fix invite code gen
as the admin page, use window.pages to generate link entirely.
2025-05-14 22:55:34 +01:00
Harvey Tindall
e7f4de2202 router: fix webFS on form subpath 2025-05-14 22:55:31 +01:00
Harvey Tindall
44e8035ff0 config: add http:// to all urls if needed, fix invite link generation
changed invites.ts to generate links using window.Pages entirely, rather
than chopping up window.location.href.
2025-05-14 22:46:46 +01:00
Harvey Tindall
e38ac62ae4 settings: fix search with disabled/deprecated sections/settings
it was assumed all sections and settings would exist (either in
this._sections or on the DOM with the "data-name" attribute). Now it
checks they do and just ignores them if not.
2025-05-14 22:11:24 +01:00
Harvey Tindall
b47a481678 Merge pull request #405 from hrfee/paramaterized-paths
Paramaterized paths: Put pages in different places
2025-05-14 21:52:54 +01:00
Harvey Tindall
632393b88d setup: adjust card width
use a container to kinda fix the max-width. Tried this on admin but it
messed too much up to bother (the accounts table specifically
doesn't like it).
2025-05-14 21:35:08 +01:00
Harvey Tindall
d2da9048d7 setup: add note about changed url
just mentioned that you should check the URL before refreshing if you
changed host/port/subfolder/etc.
2025-05-14 21:16:52 +01:00
Harvey Tindall
f1b56268bb setup: fix url-based navigation
popstate messages from the browser don't have an event.state, and i
don't even know what the overridePopState stuff is trying to do. If
event.state is null, try window.location.hash, or the last part of the
URL path.
2025-05-14 21:15:55 +01:00
Harvey Tindall
acba411c3a build: fix constant re-build of css
tailwind command taking part-bundle and turning it into v3bundle didn't
do anything when no changes occurred, so v3bundle kept on being left
    untouched, and therefore with an old timestamp. part-bundle and
    v3bundle are both deleted before CSS is built, so the tailwind
    command always generates a new file.
2025-05-14 21:11:44 +01:00
Harvey Tindall
f26042a21e config: change base url-related text 2025-05-14 20:23:16 +01:00
Harvey Tindall
0967d471ee urlpaths: seemingly full functionality
various subpath combos seem to work, and trailing slashes from them are
trimmed (including for the empty admin path "/", which is now "" by
default).
2025-05-14 20:09:50 +01:00
Harvey Tindall
302c4c189c build: check and re-copy modified config-base 2025-05-14 19:42:50 +01:00
Harvey Tindall
c52ba2162e config: start adding path parameters
to change the urls of the admin page, the my account page and of
invites. Seems to work, but need to check all the code over and test.
2025-05-13 21:10:40 +01:00
Harvey Tindall
2d98c6cff4 settings: add email test note
just send an announcement to yourself.
2025-05-13 17:31:09 +01:00
Harvey Tindall
d710b9ad4d db: fix valuelogfilesize calc
for some reason I thought it was in kibibytes? no, its in bytes as it
    should be.
2025-05-13 16:58:26 +01:00
Harvey Tindall
5cc97eaf17 goreleaser: fix deprecations
this sucks
2025-05-13 15:32:59 +01:00
Harvey Tindall
dca83dcc8e db: reduce default vlog size to 256M 2025-05-13 15:24:26 +01:00
Harvey Tindall
3c0f3e90d8 Merge branch 'main' of github.com:hrfee/jfa-go 2025-05-13 15:11:00 +01:00
Harvey Tindall
d6f5c91d78 backups: add more info, keep 1 of each version opt
backup's filename format has changed, and includes the commit too now. a
Backup struct for going to/from the filename has been added, and the
option "keep 1 backup from each version" has been added, leaving the
most recent backup from each version always. All pre-this-commit backups
are considered the same "old" version.
2025-05-13 15:07:55 +01:00
Harvey Tindall
0c257b7342 Merge branch 'main' of github.com:hrfee/jfa-go 2025-05-13 14:04:01 +01:00
Harvey Tindall
c5f4098b5b db: add max vlog size setting 2025-05-13 14:01:41 +01:00
Harvey Tindall
0b9206012f Merge branch 'main' of github.com:hrfee/jfa-go 2025-03-15 14:54:54 +00:00
Harvey Tindall
41dff3d5bb user: fix welcome message sent value on NewUserFromAdmin
inverted since WelcomeNewUser returns a bool called "failed", rather
than one indicating success.
2025-03-15 14:54:23 +00:00
Harvey Tindall
8f3f1fcda8 Merge pull request #380 from hrfee/dependabot/npm_and_yarn/nanoid-3.3.8
build(deps): bump nanoid from 3.3.6 to 3.3.8
2025-03-15 14:51:41 +00:00
Harvey Tindall
93ae2ac6d8 Merge pull request #386 from hrfee/dependabot/npm_and_yarn/site/esbuild-0.25.0
build(deps): bump esbuild from 0.18.1 to 0.25.0 in /site
2025-03-15 14:51:31 +00:00
Harvey Tindall
6404fda04d Merge pull request #392 from hrfee/dependabot/go_modules/easyproxy/golang.org/x/net-0.36.0
build(deps): bump golang.org/x/net from 0.23.0 to 0.36.0 in /easyproxy
2025-03-15 14:51:20 +00:00
Harvey Tindall
3133996f33 Merge pull request #376 from Mavyre/main
fix: added discord and telegram linking verification on sign up page
2025-03-15 14:51:00 +00:00
dependabot[bot]
cef84fad10 build(deps): bump golang.org/x/net from 0.23.0 to 0.36.0 in /easyproxy
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.23.0 to 0.36.0.
- [Commits](https://github.com/golang/net/compare/v0.23.0...v0.36.0)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/ckb/
2024-08-04 18:23:27 +02:00
Muhammad Bayiz
598859ae31 add translation from Weblate (Kurdish (Central)) 2024-08-04 18:23:27 +02:00
FiSTWHO
d8dcb84870 translation from Weblate (German)
Currently translated at 100.0% (68 of 68 strings)

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

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

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

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

Font in banner has been converted to path, so should render correctly on
web.
2024-07-31 16:21:35 +01:00
Harvey Tindall
efa113ab5f Merge Jellyseerr Support
Jellyseerr integration (similar to Ombi, but better)
2024-07-31 13:58:37 +00:00
Harvey Tindall
d60dea61db accounts: make all components of profile application optional
Basically, added the ability to -not- apply the profile's policy.
2024-07-31 15:54:05 +01:00
Harvey Tindall
a136800ff2 jellyseerr: set appropriate daemon period
was 30s for testing, is now every 10 minutes.
2024-07-31 15:32:49 +01:00
Harvey Tindall
db1c62cc46 jellyseerr: cleanup requests method, read proper errors
single req() function is wrapped by methods for each http method, and
error messages are parsed and returned if given by the server.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

this was still used when creating a new user as admin for some reason.
template is now sourced from the default profile.
2023-06-25 18:59:55 +01:00
Harvey Tindall
3bb9272f06 db: mark profile store as deprecated 2023-06-24 21:32:25 +01:00
Harvey Tindall
a735e4ff29 db: migrate user profiles 2023-06-24 21:29:54 +01:00
Harvey Tindall
63948a6de0 db: migrate invites, user expiry
some fixes to stuff in there too, probably
2023-06-24 19:13:05 +01:00
Harvey Tindall
a470d77938 db: fix contact method cleaning daemons
don't think there's a way to negate a query with badgerhold, so i can't
do "delete(not (where JellyfinID in <ExistingUsers>))", and the old
    method of rebuilding the store is no longer possible.
2023-06-24 18:38:52 +01:00
Harvey Tindall
833be688ac storage: start db migration (badger(hold))
migrating to badger, with the badgerhold frontend. So far, done:
* Announcements (small, for a quick test)
* Discord/Telegram/Matrix/Email

most interaction with badgerhold is done through the standard
Get<x>/Get<x>Key/Set<x>Key/Delete<x>Key. UserExists functions have been
added for email and matrix, and those and the original ones now use a
query against the database rather than sifting through every record.
I've tagged these searched fields as "index" for badgerhold, although this
definitely isn't used yet, and i'm not entirely sure if it'll be useful.

migrateToBadger is now in migrations.go, and a temporary config key
"migrated_to_badger" has been added, although it isn't being used yet,
migration is just running every time during development.
2023-06-24 17:05:04 +01:00
Harvey Tindall
fc7ae0ec4e userpage: respect 12h/24h choice 2023-06-24 12:32:28 +01:00
Harvey Tindall
753f5fc517 compile_mjml: use multiprocessing instead of thread 2023-06-24 11:36:15 +01:00
Harvey Tindall
f1b7ef303d Makefile: GOESBUIILD changes
doesn't ever install it if it's already present. Also moved it to
optional dependencies in package.json.
2023-06-23 21:31:33 +01:00
Harvey Tindall
e7d4b5051b build: cleanup reprepro incoming after processing 2023-06-23 14:52:51 +01:00
Harvey Tindall
b7b3aa1eb7 build: fix goreleaser, include optional builder name
builder name shows up in about section again, as does the build time.
2023-06-23 14:41:21 +01:00
Harvey Tindall
f083d6b53f updater: include build date, check against updates
build time is included in the binary, so the buildrone release date is
compared to it when deciding if something is an update or not.
2023-06-23 14:16:36 +01:00
Harvey Tindall
7caa5c5d57 lang: fix the usual on slovenian
someone directly translated "English (US)" again. Why?
2023-06-23 13:49:19 +01:00
Harvey Tindall
65c2722a20 font: switch to hanken grotesk
thought it looked quite nice License included in about section.
2023-06-23 13:45:04 +01:00
Harvey Tindall
6b3fc3d492 lang: correct language names
Low German/Saxon (NDS) is empty entirely, which caused discord lang
registration to error, so i've just filled in the name. Somebody
directly translated "English (US)" into italian instead of putting
Italian in italian, corrected that. Use some common sense!
2023-06-23 13:09:26 +01:00
Harvey Tindall
fec9776def build: fix up goreleaser
removed deprecated options, fixed to work with new user page.
2023-06-23 13:00:46 +01:00
Harvey Tindall
bfeab3648c form: change contact-via radios to checks 2023-06-23 12:30:52 +01:00
Harvey Tindall
c0f2409fcc readme/site: make project status message less pessimistic
I think my current activity on the project justifies the change.
2023-06-23 12:20:18 +01:00
Harvey Tindall
ef5d89f323 Merge "My Account"
User Page/My Account
2023-06-22 22:03:50 +01:00
Harvey Tindall
9bcbffde5d merge lang changes back in 2023-06-22 22:01:37 +01:00
c9rnelius
c37735f2e8 Added translation using Weblate (German (Low)) 2023-06-22 23:00:57 +02:00
c9rnelius
165abc7bea Added translation using Weblate (German (Low)) 2023-06-22 23:00:57 +02:00
Harvey Tindall
7aaafb90e3 form: actually link to the my account page
forgot to do this before. shown on the success modal.
2023-06-22 21:57:19 +01:00
Harvey Tindall
f07c60afb0 userpage: mention link reset requirement 2023-06-22 21:05:54 +01:00
Harvey Tindall
6adbba54ce userpage: invalid refresh token on pw change
user has to log in again, although this is not strictly enforced, as the
standard token remains valid until its expiry.
2023-06-22 20:58:56 +01:00
Harvey Tindall
97db4d714a userpage: implement change password functionality 2023-06-22 20:54:52 +01:00
Harvey Tindall
12ce669566 userpage: add password change card, validation, rearrange page
functionality not done yet, just comitting here because there were lots
of adjustments to layout stuff, accomodating for most combinations of
card presence/size.
2023-06-22 18:51:30 +01:00
Harvey Tindall
4496e1d509 pwr: ensure internal pwr pin is deleted after use 2023-06-22 17:35:34 +01:00
Harvey Tindall
3b3f37365a userpage: autofill username in pwr modal 2023-06-22 12:39:13 +01:00
Harvey Tindall
22c91be127 userpage: make pwr accept username too 2023-06-22 12:39:05 +01:00
Harvey Tindall
3ec3e9672e userpage: time-pad pwr request for ambiguity
the user shouldn't know if the reset has actually been sent (i.e. if an
account with the given contact address exists), so the backend response
is always sent after 1 second.
2023-06-22 12:27:44 +01:00
Harvey Tindall
86daa70ccb userpage: password resets
click "forgot password" on login modal, enter a contact method
address/username, submit and check for a link. Requires link reset to be
enabled.
2023-06-22 12:08:18 +01:00
Harvey Tindall
db97c3b2d4 form: add notice about userpage on success modal, userpage title
uses new strings in the form lang section.
2023-06-22 10:12:22 +01:00
Harvey Tindall
4f298bbc8c userpage: add "back to admin" button 2023-06-22 09:41:41 +01:00
Harvey Tindall
8113f794ab form: fix confirmation success page css 2023-06-21 21:22:05 +01:00
Harvey Tindall
14c18bd668 form: rework email confirmation
realized half the info from the signup form wasnt being stored in the JWT
used to create the account after email confirmation, and instead of
adding them, the -whole request- from the browser is stored temporarily
by the server, indexed by a smaller JWT that only includes the invite
code. Someone complained on reddit about me storing the password in the
JWT a while back, and although security-wise that isn't an issue (only
the server can decrypt the token), it doesn't happen anymore. Happy?
2023-06-21 21:14:41 +01:00
Harvey Tindall
f779f0345e storage: Use familiar api for invite access
An almost identical set of functions to the discord/telegram/matrix
storage ones is now used for accessing invites. No more
parallelism-related issues, yay. Need to do this for everything
eventually.
2023-06-21 20:39:16 +01:00
Harvey Tindall
ebacfd43be form: fix captcha, matrix, telegram
new issue though: discord/telegram/matrix aren't linked when email
confirmation is used!
2023-06-21 20:00:48 +01:00
Harvey Tindall
e4a7172517 messages: assign tokens to jf users on userpage
pins generated on the user page are assigned to that user, no other
jellyifn user can verify them.
2023-06-21 18:26:08 +01:00
Harvey Tindall
3747eaa3a7 messages: refactor dc/tg, fix tg
less external access to Discord/TelegramDaemon internals, will be easier
to keep user/admin-side uses functioning similarly. Also changed their
internal token stores to use a map, and store an expiry. verifiedTokens
is also now a map in telegram. Also fixed issue where token wasn't being
deleted after use on the user page.
2023-06-21 18:02:33 +01:00
Harvey Tindall
761d8d1c03 userpage: refresh pin when contact changed > once 2023-06-21 17:07:02 +01:00
Harvey Tindall
4e7f720214 userpage: hide bg on login, dont refresh page ever 2023-06-21 17:02:57 +01:00
Harvey Tindall
757c3a8aed userpage: move cards around 2023-06-21 13:31:43 +01:00
Harvey Tindall
87b0ae6614 userpage: adjust message row span depending on length 2023-06-21 13:30:09 +01:00
Harvey Tindall
920161b920 settings: add "note" type, shows as card
also comes with a "style" attribute, to apply a color to the aside it's
shown in. Used in User Page/Messages to mention the customize button,
and on User page w/ a critical color to mention the jellyfin login
requirement.
2023-06-21 12:28:52 +01:00
Harvey Tindall
e7f7dcbb78 userpage: show placeholder message card for admins 2023-06-21 11:27:51 +01:00
Harvey Tindall
cc4a97db28 userpage: fix card color in light mode 2023-06-21 11:05:38 +01:00
Harvey Tindall
b546aeb440 userpage: don't wrap contact methods, ellipsise 2023-06-20 22:18:38 +01:00
Harvey Tindall
99679a800d userpage: add customizable message on page 2023-06-20 21:54:55 +01:00
Harvey Tindall
7b9b0d8a84 userpage: implement login message card
Shares code with custom emails, so most related functions have had a
%s/Email/Message/g. Press the edit button on the user page setting to
add a message.
2023-06-20 21:43:25 +01:00
Harvey Tindall
8e153cd92f userpage: unlink accounts 2023-06-20 16:44:12 +01:00
Harvey Tindall
d509abdd5c userpage: add matrix 2023-06-20 13:28:13 +01:00
Harvey Tindall
96c51af15a matrix: modularize 2023-06-20 12:57:52 +01:00
Harvey Tindall
68004e1d34 storage: user set/get methods for contact method access
Get/GetKey/SetKey/DeleteKey methods are used for access to
email/discord/telegram/matrix, everywhere. Mutex added for each, avoids
concurrent read/write issues. Will also make potential transition to
database easier.
2023-06-20 12:19:24 +01:00
Harvey Tindall
fcedea110d telegram: modularize, add to userpage 2023-06-19 22:11:35 +01:00
Harvey Tindall
68aedf07ae discord: pad, underline invite link 2023-06-19 18:03:35 +01:00
Harvey Tindall
094f7cea94 discord: use placeholder if guild icon not available
also centers the invite on the form/user discord modal.
2023-06-19 17:48:24 +01:00
Harvey Tindall
765a749959 discord: modularize user-facing code
will be done for others too, code for discord account linking in form
and userpage is now in ts/modules/account-linking.ts as a configurable
class.
2023-06-19 11:58:09 +01:00
Harvey Tindall
cf7983ca11 userpage: add/edit discord
works identically to on the form, would like to eventually factor out
the discord/telegram/matrix verif stuff so it can be shared between the
two pages though.
2023-06-18 21:38:12 +01:00
Harvey Tindall
609039baeb userpage: change email (+ confirmation)
edit/add button added for email address. Confirmation works too.
2023-06-18 19:38:09 +01:00
Harvey Tindall
03f1a3dbc0 userpage: expand contact card to fill height 2023-06-18 13:04:22 +01:00
Harvey Tindall
75dc9d4d1d userpage: store refresh token separately
stored as "user-refresh" fixes weird issues when two accounts are logged
in.
2023-06-18 12:30:23 +01:00
Harvey Tindall
5beeeb958b userpage: show expiry 2023-06-18 12:27:18 +01:00
Harvey Tindall
a22f032924 userpage: show and allow modification of contact methods 2023-06-17 17:27:44 +01:00
Harvey Tindall
3e034c85d6 auth: provide error message if account is disabled 2023-06-17 13:57:48 +01:00
Harvey Tindall
d3c5feaf1b userpage: use form langfile, move login strings to common
login-related stuff was moved into common using the langmover script, so
that the user page doesn't have to use the admin language files.
2023-06-17 12:48:28 +01:00
Harvey Tindall
96c62f556b langmover: rewrite whole directory when using --extract
--extract now takes a path argument, a new copy of the source folder is
made there. Rebuilding the whole folder gets rid of annoying things like
mis-capitalized files.
2023-06-17 12:45:00 +01:00
Harvey Tindall
ebdad3f7c7 scripts: fix langmover for non-ascii chars 2023-06-16 20:59:06 +01:00
Harvey Tindall
2fc2f1ddb3 lang: add patchable notifications to common 2023-06-16 18:29:49 +01:00
Harvey Tindall
a1af6e3892 scripts: add langmover
a tool to move strings between language file sections. Will be used to
move login strings from admin into their own "login" file section.
2023-06-16 17:27:09 +01:00
Harvey Tindall
726acb9c29 userpage: initial page
login, lang, and theme work. Currently only makes a request to a
hello-world type endpoint to verify auth works. Accessible at
/my/account.
2023-06-16 14:43:37 +01:00
Harvey Tindall
54fde33a20 admin: a little more refactoring
all theme functionality is now in theme.ts, and the tab stuff has been
changed a little but kept in admin as it won't be in use anywhere else
for the time being.
2023-06-16 13:43:34 +01:00
Harvey Tindall
b8cc75c6b4 login: modularize frontend code
all in ts/modules/login.ts
2023-06-15 23:52:16 +01:00
Harvey Tindall
b13fe7f3e4 html: move login modal to own file 2023-06-15 22:00:08 +01:00
Harvey Tindall
81372d6a6b auth: fix "ok" issue
the "ok" returned when the JWT claims are read was being overridden with
"false" before it could be checked.
2023-06-15 21:59:34 +01:00
Harvey Tindall
918f8816c5 auth: slight refactor, setup user auth
user-auth.go contains slightly adjusted versions of auth.go functions,
for authorizing jellyfin users (admin or not). Refactored auth.go so that
most code is shared. User auth isn't hooked up yet, nor has it been
tested.
2023-06-15 21:32:18 +01:00
Harvey Tindall
bf981935cb form: fix header alignment 2023-06-15 18:20:46 +01:00
Harvey Tindall
1fa92f78e4 merge captcha changes 2023-06-15 17:36:17 +01:00
Harvey Tindall
07564bbde3 captcha: recaptcha respects dark mode
also removed the ugly border around it.
2023-06-15 17:35:51 +01:00
Harvey Tindall
4014e93155 signup: add reCAPTCHA
can be enabled in settings > captcha, requires a site key & secret key
from google. New wiki article explains getting these. currently a little
ugly looking on the page itself, hopefully fixable.
2023-06-15 17:11:27 +01:00
Qutyba
f81224a2a6 translation from Weblate (Arabic)
Currently translated at 100.0% (42 of 42 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/ar/
2023-06-14 21:42:07 +02:00
Qutyba
8760152159 Translated using Weblate (Arabic)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/ar/
2023-06-14 21:42:07 +02:00
Kovács Tamás
5694f30a94 translation from Weblate (Hungarian)
Currently translated at 100.0% (42 of 42 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/hu/
2023-06-14 21:42:07 +02:00
Gabriele Bizzon
156478b381 translation from Weblate (Portuguese (Brazil))
Currently translated at 100.0% (42 of 42 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/pt_BR/
2023-06-14 21:42:07 +02:00
Rafael Gale
ad416b9cb2 translation from Weblate (Spanish)
Currently translated at 100.0% (42 of 42 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/es/
2023-06-14 21:42:07 +02:00
StunBeta
2e39a5e573 Translated using Weblate (French)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/fr/
2023-06-14 21:42:07 +02:00
StunBeta
cab099d77f Translated using Weblate (French)
Currently translated at 100.0% (112 of 112 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/fr/
2023-06-14 21:42:07 +02:00
StunBeta
0b5e93fd60 Translated using Weblate (French)
Currently translated at 100.0% (23 of 23 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/fr/
2023-06-14 21:42:07 +02:00
StunBeta
6e2ba78204 translation from Weblate (French)
Currently translated at 97.6% (41 of 42 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/fr/
2023-06-14 21:42:07 +02:00
Harvey Tindall
115f5ae6a3 Merge accounts sort/filter
Accounts Sort/Filter, UI adjustments
2023-06-14 20:41:27 +01:00
Harvey Tindall
bf12016315 accounts: make filter names translatable 2023-06-14 19:59:38 +01:00
Harvey Tindall
b544931ee5 accounts: fix id filtering, make string translatable 2023-06-14 18:57:30 +01:00
Harvey Tindall
9cef626b28 accounts: filter dropdown appears over announce one 2023-06-14 18:52:33 +01:00
Harvey Tindall
708d382a3f accounts: fix hiding of search options header for default sort 2023-06-14 18:43:46 +01:00
Harvey Tindall
f24ea4a5f8 accounts: fix sizing of sorting by button 2023-06-14 18:41:47 +01:00
Harvey Tindall
6ddd09ff1f accounts: add header to "actions" and "search options" 2023-06-14 18:38:12 +01:00
Harvey Tindall
ddc560e862 accounts: move filter button, add clear search
filter button now on left due to the dropdown being huge.
2023-06-14 17:36:41 +01:00
Harvey Tindall
6f452c62de accounts: fix search bugs, adjust top bar layout
search bar is now massive with a small filter button next to it.
Action buttons are on their own row.
Also fixed dealing with going from a search with filters in to an empty
one, search() is now called for any change at all to the input.
2023-06-14 17:15:24 +01:00
Harvey Tindall
76bb95098c accounts: add list of available filters, fix deletion of existing date filters
The "Filters" button gives a list of filterable fields, and buttons to
select the type, including true/false, text match, and on/before/after a
date. When clicked, the appropriate values are put in the search box and
the cursor is placed if any input is needed.

Dates and strings are also now matched correctly, and case-insensitively when
deleting a filter.
2023-06-14 14:01:05 +01:00
Harvey Tindall
0e241f56fb scripts: add script to generate fake accounts
might be useful for screenshots too, currently just using for testing
the sorting/filtering.
2023-06-14 11:50:22 +01:00
Harvey Tindall
8ac3bb9711 accounts: show list row of search filters, click to remove
any filters in your search box will show as little button/chip things on
the row below. Clicking them will remove them from the search.
2023-06-13 22:27:08 +01:00
Harvey Tindall
ff62f8821a accounts: filter by date, with =, <, >
Uses "any-date-parser" library to understand more date/time types.
Format is: "<field>:<equals, less than, greater than><date>", where the
part after the colon uses =, <, >. Omitting a symbol is the same as
using "=".
2023-06-13 17:19:24 +01:00
Harvey Tindall
90c433443f accounts: filter by string field, general search
string fields can now be searched by with the "<field>:<value>" syntax,
also added back a better general search, that supports essentially all
string fields, including Jellyfin ID.
2023-06-13 13:55:40 +01:00
Harvey Tindall
8a37663c89 accounts: start advanced search filter support
uses the same format "<field>:<value>", but supports quoted <values>
(allows for spaces in them), and lays groundwork to support string and
date-type field filtering. Truthiness is supported, meaning you can
check if an email is set with "email:yes" for example.
2023-06-13 13:39:13 +01:00
Harvey Tindall
bc4015ac50 accounts: add indicator of sorted column at top of list
When clicking on a column to sort by it, a button with "Sorting By:
<column>" appears. Clicking it will reset the sort, which defaults for
ascending username.
2023-06-13 12:13:12 +01:00
Harvey Tindall
f13c0d78a8 accounts: ability to sort by columns
click on column header in account table to sort by it, click again to
change sort direction. Works for all of them.
2023-06-13 11:07:56 +01:00
Harvey Tindall
cc3871adf6 site: update node deps 2023-06-12 18:49:42 +01:00
Harvey Tindall
3e52beef14 update node deps, fix resulting issues 2023-06-12 18:44:10 +01:00
Harvey Tindall
48403ce940 discord: proper support for discriminator-less
names with no discriminator are shown as @username, and search works
with the @ too. Also bumped go version to 1.20, since it was stuck on
1.16 and i felt like trying generics (in an ugly way).
2023-06-12 16:32:40 +01:00
Harvey Tindall
6564df8082 about: fix donation buttons in dark mode 2023-06-12 16:05:50 +01:00
Harvey Tindall
73202e1483 crash: render ANSI colors on crash page
Could not for the life of me get any regex to properly strip the ANSI
escape sequences, so the text log will have to suffer with them, but the
ansihtml library is now used to render them into HTML.
2023-06-12 15:48:07 +01:00
Harvey Tindall
dc60e97415 update license 2023-06-11 20:00:30 +01:00
Harvey Tindall
ad40d7d8a9 fix bugs with restarts/interrupts
The password reset daemon wasn't being closed on restarts, so an extra
pwr would be sent w/ every restart. Restarts & Interrupts (Ctrl-C)
rarely worked, as there were multiple listeners to the "RESTART"
channel, and I didn't know the message was consumed by whoever got it
first, meaning if the main thread didn't get it first, the app wouldn't
quit. Listeners are now registered, and the restart message is
re-broadcasted until everyone's got it.

Fixes #264
2023-06-11 19:50:50 +01:00
Harvey Tindall
f88f71d933 pwreset: Stop daemon on restart, dont fail if json is malformed
daemon now stops on the RESTART signal, and when it fails to read JSON,
it no longer stops the whole daemon.
2023-06-11 16:35:41 +01:00
Harvey Tindall
d688dd02c8 args: respect other args when start/stop/daemon are used
these being present in os.Args messes up "flag"'s parsing of the rest of
the arguments, so when they are found, they are removed.

Fixes #267
2023-06-11 16:06:18 +01:00
Harvey Tindall
456c99d7db args: fix help not usually showing
another weird side effect of the line cache/logging stuff.
The true stderr is now stored in the "stderr" global variable and is
used to print the help screen.
2023-06-11 15:48:27 +01:00
Harvey Tindall
e4f03fac4b setup: report panic errors to user
many issues occur with setup, all this does is tell the user something
bad happened and to check the logs. Might help with solving issues.
Also fixed some now invalid typescript.
2023-06-11 15:11:00 +01:00
Harvey Tindall
2cb72e1f48 module update, add notice to site 2023-06-11 14:22:58 +01:00
Harvey Tindall
d800b97f69 README: add note about project status 2023-04-21 18:01:08 +01:00
Harvey Tindall
0674e04ee1 go module update/tidy 2023-02-20 10:47:54 +00:00
Harvey Tindall
d56f321aad site changes 2023-02-05 01:08:29 +00:00
Harvey Tindall
bedd2bbb23 README changes 2023-02-05 00:59:04 +00:00
Harvey Tindall
27ef7ce560 css: revert global change to absolute position for dropdowns
changing dropdowns to always have absolute positioning (in c187b94)
caused issues with all other dropdowns, where neighbors were positioned
below and hidden. adding the "over-top" class to a dropdown now gives
it absolute positioning.
2023-02-05 00:49:13 +00:00
Harvey Tindall
775ebd3b1e Accounts: Unlink Telegram/Discord/Matrix through cog
Added an unlinking section to the little cog dropdown next to users so
that one can remove and re-link a different account for a Jellyfin user.
Also adjusted padding in the dropdown.
2023-02-05 00:23:16 +00:00
Harvey Tindall
49c7d83840 fix logging and crash reports on Windows
"-H=windowsgui" disables stdout on Windows, and the io.Multiwriter used
for logging had stdout as it's first entry, which failed and caused
    logging and line caching to be skipped. stdout is now removed from
    the multiwriter in this situation. Other portion of the issue was
    because crash reports had colons in their names, which Windows
    doesn't like. Fixes #168.
2023-02-02 13:42:15 +00:00
BatavianX
a30469f6ec translation from Weblate (Indonesian)
Currently translated at 100.0% (40 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/id/
2023-02-02 14:07:20 +01:00
BatavianX
045f9ef827 Translated using Weblate (Indonesian)
Currently translated at 60.8% (14 of 23 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/id/
2023-02-02 14:07:20 +01:00
BatavianX
abc13575c9 Translated using Weblate (Indonesian)
Currently translated at 70.5% (36 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/id/
2023-02-02 14:07:20 +01:00
Farès Chati
958b824dbc translation from Weblate (French)
Currently translated at 100.0% (179 of 179 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/fr/
2023-02-02 14:07:20 +01:00
collateral127
7b5f5abd22 Translated using Weblate (English (United Kingdom))
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/en_GB/
2023-02-02 14:07:20 +01:00
collateral127
5b7060c6a3 Translated using Weblate (English (United Kingdom))
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/en_GB/
2023-02-02 14:07:20 +01:00
collateral127
bbcba005c0 Translated using Weblate (English (United Kingdom))
Currently translated at 100.0% (23 of 23 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/en_GB/
2023-02-02 14:07:20 +01:00
collateral127
11d8b90f88 translation from Weblate (English (United Kingdom))
Currently translated at 100.0% (40 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/en_GB/
2023-02-02 14:07:20 +01:00
collateral127
b531ec9b50 Translated using Weblate (English (United Kingdom))
Currently translated at 100.0% (112 of 112 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/en_GB/
2023-02-02 14:07:20 +01:00
collateral127
79790303a9 Translated using Weblate (English (United Kingdom))
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/en_GB/
2023-02-02 14:07:20 +01:00
collateral127
11c45a67b3 translation from Weblate (English (United Kingdom))
Currently translated at 100.0% (179 of 179 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/en_GB/
2023-02-02 14:07:20 +01:00
ilyigna
413ea07cbd translation from Weblate (Spanish)
Currently translated at 100.0% (40 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/es/
2023-02-02 14:07:20 +01:00
Kovács Tamás
890148051f Translated using Weblate (Hungarian)
Currently translated at 16.9% (19 of 112 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/hu/
2023-02-02 14:07:20 +01:00
Kovács Tamás
3ba5d1f3fd translation from Weblate (Hungarian)
Currently translated at 95.0% (38 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/hu/
2023-02-02 14:07:20 +01:00
Kovács Tamás
ef5a0c3f75 Added translation using Weblate (Hungarian) 2023-02-02 14:07:20 +01:00
Zabpehely
0323b2783b translation from Weblate (Hungarian)
Currently translated at 32.4% (58 of 179 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/hu/
2023-02-02 14:07:20 +01:00
Linyue-GitHub
b1e8bc4a46 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (112 of 112 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/zh_Hans/
2023-02-02 14:07:20 +01:00
Linyue-GitHub
e3a33d102e translation from Weblate (Chinese (Simplified))
Currently translated at 100.0% (179 of 179 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/zh_Hans/
2023-02-02 14:07:20 +01:00
BIG-OP01
b9a3ed1d74 translation from Weblate (Chinese (Traditional))
Currently translated at 100.0% (40 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/zh_Hant/
2023-02-02 14:07:20 +01:00
BIG-OP01
cb0d4e8bd7 Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/zh_Hant/
2023-02-02 14:07:20 +01:00
BIG-OP01
5ee7bdc55e Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/zh_Hant/
2023-02-02 14:07:20 +01:00
BIG-OP01
35ed4e20f0 Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (23 of 23 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/zh_Hant/
2023-02-02 14:07:20 +01:00
BIG-OP01
a5faf0699a Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/zh_Hant/
2023-02-02 14:07:20 +01:00
BIG-OP01
cb249b30af Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (112 of 112 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/zh_Hant/
2023-02-02 14:07:20 +01:00
BIG-OP01
bb1e3a2c72 translation from Weblate (Chinese (Traditional))
Currently translated at 100.0% (179 of 179 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/zh_Hant/
2023-02-02 14:07:20 +01:00
BIG-OP01
540b8d7c13 Added translation using Weblate (Chinese (Traditional)) 2023-02-02 14:07:20 +01:00
BIG-OP01
99e524589c Added translation using Weblate (Chinese (Traditional)) 2023-02-02 14:07:20 +01:00
BIG-OP01
295343be85 Added translation using Weblate (Chinese (Traditional)) 2023-02-02 14:07:20 +01:00
BIG-OP01
6f61d2246d add translation from Weblate (Chinese (Traditional)) 2023-02-02 14:07:20 +01:00
BIG-OP01
e0cd4ed379 Added translation using Weblate (Chinese (Traditional)) 2023-02-02 14:07:20 +01:00
BIG-OP01
9f3269bce7 Added translation using Weblate (Chinese (Traditional)) 2023-02-02 14:07:20 +01:00
BIG-OP01
464fabc3bb add translation from Weblate (Chinese (Traditional)) 2023-02-02 14:07:20 +01:00
Harvey Tindall
c187b948d9 accounts: display "Contact through" above container
stops most of it being hidden at the bottom of the page.
2023-02-02 12:18:34 +00:00
Harvey Tindall
eb85ee4d35 add option to auto redirect to jellyfin (or given link)
if enabled (General>Auto redirect on success), the user will not have to
    click "continue" on the form or creation success page and will
    insted be redirected. For #242.
2023-02-02 10:34:22 +00:00
Harvey Tindall
de6bc02ad8 Merge branch 'main' of github.com:hrfee/jfa-go 2023-02-02 09:41:56 +00:00
Harvey Tindall
d355b3167f fix GOESBUILD for newer go versions
switched to 'go install' syntax. for #248.
2023-02-02 09:41:14 +00:00
Harvey Tindall
6cd846dfd8 Merge pull request #251 from aleksasiriski/patch-1
fixed setExpiry() and added checked to admin only
2023-02-02 09:27:12 +00:00
Aleksa Siriški
0c102f5324 added checked to admin only
because it is confusing if you go straight to `Manually set the username and password.` and you can't go next since nothing is checked above but it becomes hidden
2023-02-01 23:31:55 +01:00
Aleksa Siriški
f50596c4a1 fixed setExpiry()
The bug was that setExpiry didn't work if account was enabled and didn't have an expiry already.
2023-02-01 23:11:21 +01:00
Harvey Tindall
5d289ce023 Admin: auto enable contact when an email is added
"Contact through email" is now automatically enabled when adding an
email address to an account without one on the admin page. Solves #233.
2023-02-01 15:25:47 +00:00
Harvey Tindall
2722e8482d Invites: unique email/ID requirement
"Require unique ..." Settings (`require_unique` in relevant sections of
config.ini) are now available for email/discord/telegram/matrix. An
error is shown on the invite form if a non-unique address/ID is used.
This was on my kanban without a link to an issue, so i'm guessing it was
requested on Discord.
2023-02-01 15:11:10 +00:00
Harvey Tindall
895dcf5a30 fix missing create-success css
part of #242
2023-02-01 14:14:13 +00:00
Harvey Tindall
ac25c9cd7f accounts: Show "contact through" cog when only email is given"
one portion of #233: When adding an email address to an account, the
admin (for now) will manually have to enable its use by clicking the cog icon and
enabling "contact through email". Only for now, currently some other
unfinished work is stopping me from committing the other portion of code
for this issue.
2023-01-30 14:00:29 +00:00
Harvey Tindall
47d00d1f27 site: properly fix modals
uncss was clearing out the "block" and "animate-fade-in/out", so removed
that step since tailwind does a similar thing anyway.
2022-12-29 21:15:07 +00:00
Harvey Tindall
6bab805528 site: fix modals 2022-12-29 20:31:29 +00:00
Harvey Tindall
6efd28d904 fix translation conflict 2022-12-29 17:48:37 +00:00
Harvey Tindall
04329bf171 fix issue with light mode colors 2022-12-29 17:32:59 +00:00
Harvey Tindall
3d56b6864e Merge pull request #234 from HackingHackers/main
Fix Chinese translation issues
2022-12-29 16:51:25 +00:00
undy
e2e675e469 Fix Chinese translation errors 2022-11-30 15:45:45 -05:00
alison2033
aceb98b4a0 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/pt_BR/
2022-11-03 02:49:12 +01:00
alison2033
b848faa2c0 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (23 of 23 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/pt_BR/
2022-11-03 02:49:12 +01:00
alison2033
ea04f5391e Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/pt_BR/
2022-11-03 02:49:12 +01:00
alison2033
58e61e514a Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (112 of 112 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/pt_BR/
2022-11-03 02:49:12 +01:00
alison2033
b91918b04d translation from Weblate (Portuguese (Brazil))
Currently translated at 100.0% (179 of 179 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/pt_BR/
2022-11-03 02:49:11 +01:00
alison2033
8032fa0bcc Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/pt_BR/
2022-11-03 02:49:11 +01:00
Mateusz Tasz
1f0c641610 Translated using Weblate (Polish)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/pl/
2022-09-23 12:49:01 +02:00
Mateusz Tasz
37fa9345cf Translated using Weblate (Polish)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/pl/
2022-09-23 12:49:01 +02:00
Mateusz Tasz
2c31032a1c Translated using Weblate (Polish)
Currently translated at 100.0% (23 of 23 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/pl/
2022-09-23 12:49:01 +02:00
Mateusz Tasz
aeb85486c4 Translated using Weblate (Polish)
Currently translated at 42.8% (48 of 112 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/pl/
2022-09-23 12:49:00 +02:00
Mateusz Tasz
4f5fe6723b Translated using Weblate (Polish)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/pl/
2022-09-23 12:49:00 +02:00
Mateusz Tasz
53a8e6df51 translation from Weblate (Polish)
Currently translated at 100.0% (40 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/pl/
2022-09-23 12:49:00 +02:00
Mateusz Tasz
f45409e456 translation from Weblate (Polish)
Currently translated at 30.7% (55 of 179 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/pl/
2022-09-23 12:48:59 +02:00
Mateusz Tasz
34df600350 Added translation using Weblate (Polish) 2022-09-22 12:47:16 +02:00
Mateusz Tasz
255640a385 add translation from Weblate (Polish) 2022-09-22 12:38:48 +02:00
Mateusz Tasz
442bcf7e4f Added translation using Weblate (Polish) 2022-09-22 12:34:37 +02:00
Mateusz Tasz
3a8540a439 Added translation using Weblate (Polish) 2022-09-22 12:19:36 +02:00
Mateusz Tasz
681038cbd4 Added translation using Weblate (Polish) 2022-09-22 12:13:24 +02:00
Mateusz Tasz
bb8c450452 Added translation using Weblate (Polish) 2022-09-22 12:08:02 +02:00
Mateusz Tasz
5e41de8edd add translation from Weblate (Polish) 2022-09-22 11:52:57 +02:00
josecbail
47f7987210 Translated using Weblate (Spanish)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/es/
2022-08-31 06:48:52 +02:00
josecbail
3515aee8e8 translation from Weblate (Spanish)
Currently translated at 100.0% (179 of 179 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/es/
2022-08-31 06:48:52 +02:00
josecbail
23223f3925 Translated using Weblate (Spanish)
Currently translated at 100.0% (23 of 23 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/es/
2022-08-31 06:48:52 +02:00
josecbail
f049973349 translation from Weblate (Spanish)
Currently translated at 100.0% (40 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/es/
2022-08-31 06:48:51 +02:00
josecbail
2cdef91d11 Translated using Weblate (Spanish)
Currently translated at 100.0% (112 of 112 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/es/
2022-08-31 06:48:51 +02:00
MiGeek
297ec33e8e translation from Weblate (Spanish)
Currently translated at 100.0% (40 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/es/
2022-07-21 22:48:39 +02:00
kuesttman
dc55959df4 translation from Weblate (Portuguese (Brazil))
Currently translated at 100.0% (40 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/pt_BR/
2022-07-21 22:48:38 +02:00
joecom7
311b64acd1 Translated using Weblate (Italian)
Currently translated at 10.0% (1 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/it/
2022-07-02 13:48:34 +02:00
joecom7
89f11ab630 Translated using Weblate (Italian)
Currently translated at 100.0% (23 of 23 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/it/
2022-07-02 13:48:33 +02:00
joecom7
9c68a7970d Translated using Weblate (Italian)
Currently translated at 39.2% (20 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/it/
2022-07-02 13:48:33 +02:00
joecom7
18d619efa1 translation from Weblate (Italian)
Currently translated at 77.5% (31 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/it/
2022-07-02 13:48:33 +02:00
joecom7
6490c67a6c Added translation using Weblate (Italian) 2022-07-01 13:30:14 +02:00
joecom7
8cdf87d72b Added translation using Weblate (Italian) 2022-07-01 13:20:10 +02:00
tenninjas
46da6d0ddc translation from Weblate (Chinese (Simplified))
Currently translated at 95.5% (171 of 179 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/zh_Hans/
2022-04-26 18:46:35 +02:00
tenninjas
89b9f0a4f9 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/zh_Hans/
2022-04-26 18:46:34 +02:00
tenninjas
887f1f7c71 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/zh_Hans/
2022-04-26 18:46:34 +02:00
tenninjas
c1f7b665d5 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/zh_Hans/
2022-04-26 18:46:34 +02:00
tenninjas
26fc6b7056 translation from Weblate (Chinese (Simplified))
Currently translated at 100.0% (40 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/zh_Hans/
2022-04-26 18:46:34 +02:00
tobycm
3d45db2606 translation from Weblate (Vietnamese)
Currently translated at 35.0% (14 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/vi/
2022-04-16 19:48:07 +02:00
tobycm
91603945ef translation from Weblate (Vietnamese)
Currently translated at 70.3% (126 of 179 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/vi/
2022-04-16 19:48:07 +02:00
Felix Neumann
d6df3b980c Translated using Weblate (Chinese (Simplified))
Currently translated at 94.6% (106 of 112 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/zh_Hans/
2022-04-16 19:48:07 +02:00
Felix Neumann
d1185d0f5f Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (23 of 23 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/zh_Hans/
2022-04-16 19:48:06 +02:00
ZakiZtraki
f35132e182 Translated using Weblate (German)
Currently translated at 100.0% (23 of 23 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/de/
2022-04-16 19:48:06 +02:00
ZakiZtraki
09d22a9f2d translation from Weblate (German)
Currently translated at 100.0% (40 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/de/
2022-04-16 19:48:06 +02:00
tobycm
b0ee05f07d add translation from Weblate (Vietnamese) 2022-04-15 18:57:40 +02:00
tobycm
bb33c11a6b translation from Weblate (Vietnamese)
Currently translated at 70.3% (126 of 179 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/vi/
2022-04-15 06:48:05 +02:00
3ole
728152a31c Translated using Weblate (German)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/de/
2022-04-11 21:48:05 +02:00
DragoPrime
048f4bdf90 Translated using Weblate (Romanian)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/ro/
2022-04-06 20:48:08 +02:00
DragoPrime
8c405b251f Translated using Weblate (Romanian)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/ro/
2022-04-06 20:48:08 +02:00
DragoPrime
53ba09a2fe Translated using Weblate (Romanian)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/ro/
2022-04-06 20:48:07 +02:00
DragoPrime
0d62c5ecfa translation from Weblate (Romanian)
Currently translated at 100.0% (40 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/ro/
2022-04-06 20:48:07 +02:00
DragoPrime
44bb1e6803 Added translation using Weblate (Romanian) 2022-04-05 20:48:33 +02:00
DragoPrime
6f69f3b8f5 Added translation using Weblate (Romanian) 2022-04-05 20:34:52 +02:00
DragoPrime
d97576678d Added translation using Weblate (Romanian) 2022-04-05 20:32:13 +02:00
DragoPrime
88bf4f9903 add translation from Weblate (Romanian) 2022-04-05 20:16:05 +02:00
theGUI001
f07227e560 translation from Weblate (Portuguese (Brazil))
Currently translated at 92.5% (37 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/pt_BR/
2022-04-04 00:48:02 +02:00
mLgz0rn
b197c678ef translation from Weblate (Danish)
Currently translated at 100.0% (179 of 179 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/da/
2022-03-26 10:47:59 +01:00
mLgz0rn
d13981b489 Translated using Weblate (Danish)
Currently translated at 100.0% (23 of 23 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/da/
2022-03-26 10:47:59 +01:00
mLgz0rn
90d4681ae8 Translated using Weblate (Danish)
Currently translated at 100.0% (112 of 112 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/da/
2022-03-26 10:47:59 +01:00
mLgz0rn
ce228630ce Translated using Weblate (Danish)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/da/
2022-03-26 10:47:59 +01:00
mLgz0rn
855fdee332 translation from Weblate (Danish)
Currently translated at 100.0% (40 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/da/
2022-03-26 10:47:59 +01:00
3ole
f8745636f2 Translated using Weblate (German)
Currently translated at 100.0% (112 of 112 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/de/
2022-03-26 10:47:58 +01:00
Richard de Boer
aa07ff1682 Translated using Weblate (Dutch)
Currently translated at 100.0% (112 of 112 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/nl/
2022-03-24 10:48:03 +01:00
Harvey Tindall
4c3e310634 use go install for swag in goreleaser 2022-03-22 15:16:04 +00:00
Harvey Tindall
8b52330304 remove unused nightwind dep 2022-03-22 15:09:38 +00:00
Harvey Tindall
200dc1c91a lowercase lang 2022-03-22 15:03:53 +00:00
3ole
531d4aaefc Translated using Weblate (German)
Currently translated at 100.0% (111 of 111 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/de/
2022-03-22 16:01:21 +01:00
3ole
f8d98fb66f Translated using Weblate (German)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/de/
2022-03-22 16:01:21 +01:00
3ole
5b2472853b translation from Weblate (German)
Currently translated at 100.0% (179 of 179 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/de/
2022-03-22 16:01:21 +01:00
mossh
2dc234a94d Translated using Weblate (Arabic)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/ar/
2022-03-22 16:01:21 +01:00
mossh
fdb43b2c53 translation from Weblate (Arabic)
Currently translated at 100.0% (40 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/ar/
2022-03-22 16:01:21 +01:00
mossh
2161d1aa2f Translated using Weblate (Arabic)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/ar/
2022-03-22 16:01:21 +01:00
mossh
08c8534d39 Added translation using Weblate (Arabic) 2022-03-22 16:01:21 +01:00
mossh
9e7914e0b6 add translation from Weblate (Arabic) 2022-03-22 16:01:21 +01:00
mossh
daebfec0a2 Added translation using Weblate (Arabic) 2022-03-22 16:01:21 +01:00
3ole
e81411cb9b Translated using Weblate (German)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/de/
2022-03-22 16:01:21 +01:00
3ole
6cdae16752 Translated using Weblate (German)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/de/
2022-03-22 16:01:21 +01:00
3ole
65a0c6cb23 Translated using Weblate (German)
Currently translated at 100.0% (111 of 111 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/de/
2022-03-22 16:01:21 +01:00
3ole
36d0550bf9 Translated using Weblate (German)
Currently translated at 100.0% (23 of 23 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/de/
2022-03-22 16:01:20 +01:00
3ole
92aee997da Translated using Weblate (German)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/de/
2022-03-22 16:01:20 +01:00
3ole
63e6f3a3fa translation from Weblate (German)
Currently translated at 100.0% (40 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/de/
2022-03-22 16:01:20 +01:00
Tim
bfafbad9dc translation from Weblate (German)
Currently translated at 100.0% (40 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/de/
2022-03-22 16:01:20 +01:00
Sundune
4435fead5a translation from Weblate (German)
Currently translated at 100.0% (179 of 179 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/de/
2022-03-22 16:01:20 +01:00
3ole
e73715f30e translation from Weblate (German)
Currently translated at 100.0% (179 of 179 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/de/
2022-03-22 16:01:20 +01:00
Herodev
f31e9c0d81 translation from Weblate (German)
Currently translated at 95.0% (38 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/de/
2022-03-22 16:01:20 +01:00
DornJ
2d1a4737db Translated using Weblate (Slovenian)
Currently translated at 100.0% (23 of 23 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/sl/
2022-03-22 16:01:20 +01:00
DornJ
91f89793da Translated using Weblate (Slovenian)
Currently translated at 0.9% (1 of 111 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/sl/
2022-03-22 16:01:20 +01:00
DornJ
d201644a5b Translated using Weblate (Slovenian)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/sl/
2022-03-22 16:01:20 +01:00
DornJ
982dd001ef Translated using Weblate (Slovenian)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/sl/
2022-03-22 16:01:20 +01:00
DornJ
80bd4d134e translation from Weblate (Slovenian)
Currently translated at 100.0% (40 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/sl/
2022-03-22 16:01:20 +01:00
DornJ
92fda348cd Added translation using Weblate (Slovenian) 2022-03-22 16:01:20 +01:00
DornJ
ef3fdb7555 Added translation using Weblate (Slovenian) 2022-03-22 16:01:20 +01:00
DornJ
9eb30ffab3 Added translation using Weblate (Slovenian) 2022-03-22 16:01:20 +01:00
DornJ
0bf6c25a14 Added translation using Weblate (Slovenian) 2022-03-22 16:01:20 +01:00
DornJ
5bb7a7d30c add translation from Weblate (Slovenian) 2022-03-22 16:01:20 +01:00
Richard de Boer
85c9319dfd Translated using Weblate (Dutch)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/nl/
2022-03-22 16:01:20 +01:00
Richard de Boer
3fea051691 Translated using Weblate (Dutch)
Currently translated at 100.0% (111 of 111 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/nl/
2022-03-22 16:01:20 +01:00
Richard de Boer
7826fdffeb Translated using Weblate (Dutch)
Currently translated at 100.0% (23 of 23 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/nl/
2022-03-22 16:01:20 +01:00
Richard de Boer
1105605370 translation from Weblate (Dutch)
Currently translated at 100.0% (40 of 40 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/nl/
2022-03-22 16:01:20 +01:00
Richard de Boer
3de3dd426b translation from Weblate (Dutch)
Currently translated at 100.0% (179 of 179 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/nl/
2022-03-22 16:01:20 +01:00
Richard de Boer
0c7187d53f translation from Weblate (English)
Currently translated at 100.0% (179 of 179 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/en/
2022-03-22 16:01:20 +01:00
Harvey Tindall
12db53d1eb reorganise api, add "Connection refused" error in setup
fixes #201
2022-03-22 14:58:39 +00:00
Harvey Tindall
cebde9d4c0 setup: report errors when saving config 2022-02-03 19:19:21 +00:00
Harvey Tindall
9395165916 updater: check build is ready 2022-01-30 18:25:47 +00:00
Harvey Tindall
a8daa2c77e makefile changes 2022-01-30 18:03:21 +00:00
Harvey Tindall
49c873c858 dont ignore esbuild errors 2022-01-30 17:59:52 +00:00
Harvey Tindall
c6fc5765f3 update node version in Dockerfile 2022-01-30 17:18:51 +00:00
Harvey Tindall
62cbbf57e7 form: fix mono text in discord linking modal 2022-01-30 16:47:29 +00:00
Harvey Tindall
b81c5636cc settings: better top button padding on mobile 2022-01-30 16:43:28 +00:00
Harvey Tindall
d867649a93 padding; fix hungarian lang names 2022-01-30 16:38:19 +00:00
Harvey Tindall
cd08259012 lowercase lang 2022-01-30 16:28:20 +00:00
Harvey Tindall
e814af1af5 Merge pull request #191 from LubricantJam/main
Further Mobile Optimisations
2022-01-30 16:27:33 +00:00
Harvey Tindall
ecbff16a88 modal: change transition
now a simple fade-in/fade-out, which is part of tailwind.config.js
rather than modal.css.
2022-01-30 16:24:40 +00:00
Harvey Tindall
baffa4a38c add NOTEMPLATE env var to missing-colors.js 2022-01-30 14:41:11 +00:00
mLgz0rn
fad507d2dd translation from Weblate (Danish)
Currently translated at 100.0% (175 of 175 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/da/
2022-01-30 15:33:52 +01:00
mLgz0rn
053ee8284d Translated using Weblate (Danish)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/da/
2022-01-30 15:33:52 +01:00
mLgz0rn
4f05fa9375 Translated using Weblate (Danish)
Currently translated at 100.0% (6 of 6 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/da/
2022-01-30 15:33:52 +01:00
mLgz0rn
7ecf1bcf94 Translated using Weblate (Danish)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/da/
2022-01-30 15:33:52 +01:00
mLgz0rn
f030cdcb02 translation from Weblate (Danish)
Currently translated at 100.0% (37 of 37 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/da/
2022-01-30 15:33:52 +01:00
Frizles
7474a1868e translation from Weblate (Hungarian)
Currently translated at 86.4% (32 of 37 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/hu/
2022-01-30 15:33:52 +01:00
Tim
9a4d90790a translation from Weblate (German)
Currently translated at 100.0% (37 of 37 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/de/
2022-01-30 15:33:52 +01:00
Frizles
7e38ee07c7 add translation from Weblate (Hungarian) 2022-01-30 15:33:52 +01:00
Frizles
b9574e2d67 add translation from Weblate (Hungarian) 2022-01-30 15:33:52 +01:00
Harvey Tindall
6431613363 update version; mention log button in bug report template 2022-01-30 14:27:00 +00:00
Harvey Tindall
a00392166c Merge pull request #194 from KeyboardDabbler/main
Cursor pointer UX consistency
2022-01-30 14:10:37 +00:00
KeyboardDabbler
bfcad6c5f2 pointer on modal close 2022-01-30 01:49:44 +13:00
KeyboardDabbler
7daf2162ef checkbox, edit email and captcha refresh 2022-01-30 00:33:25 +13:00
James Finch
dec8d75083 Optimised modals for mobile. 2022-01-28 22:34:34 +00:00
James Finch
f486f8de1d Removed deprecated code. 2022-01-28 22:33:39 +00:00
James Finch
93b1e9c371 Fixed dark text on dark mode. 2022-01-28 22:33:30 +00:00
James Finch
6440f57467 Fixed Tailwind compiling issues. 2022-01-28 22:33:07 +00:00
Harvey Tindall
25451eb763 form: show radios for only linked contact methods 2022-01-27 16:56:21 +00:00
Harvey Tindall
dbefb80f63 form: reliably disable submit button, communicate if account linking is required 2022-01-27 16:48:46 +00:00
James Finch
889d68dc7b Updated Tailwind version. 2022-01-27 16:32:01 +00:00
Harvey Tindall
42dbc04ff9 Merge pull request #189 from LubricantJam/main
Mobile Form Support.
2022-01-27 15:01:30 +00:00
James Finch
82c8ef1e4b Added better mobile support for sign-up form. 2022-01-27 01:58:24 +00:00
Harvey Tindall
4deb45df3c fix code text color in dark mode 2022-01-26 22:25:33 +00:00
Harvey Tindall
4b02960fd1 form: add captcha regen button
lots of false negatives so this is a solution for now.

Also the registering of each discord command is now logged for debugging
purposes.
2022-01-26 22:14:16 +00:00
Harvey Tindall
15e5564b12 discord: add/move to slash commands
the version of the discord library with support for this isn't
necessarily stable, so normal ! commands will still be available. The
user is no longer DMed for the PIN, instead they type /pin <PIN>.
2022-01-26 21:47:02 +00:00
Harvey Tindall
e66241ddcb fix crash page css when using goreleaser 2022-01-26 15:45:21 +00:00
Harvey Tindall
d3990a6c55 upgrade vulnerable dependency 2022-01-26 14:37:14 +00:00
Harvey Tindall
be1d081629 build: fix css bundling bug with new esbuild
local testing was being done with an older version of esbuild which
didn't mind @tailwind statements before @imports (it complained, but did
its job). On the latest version used in Docker builds, it would leave
the @import statements intact which broke things like modals.
2022-01-26 14:26:10 +00:00
Harvey Tindall
fafb524a47 accounts: link ombi/discord when adding 2022-01-25 18:05:17 +00:00
Harvey Tindall
da1b9ccac7 ombi: add migration to link telegram/discord 2022-01-25 18:02:04 +00:00
Harvey Tindall
7b97e1ca26 fix discord/telegram linking with ombi
original issue creator replied with the fix.
2022-01-25 17:37:14 +00:00
Harvey Tindall
7f11549337 ombi: broken discord/telegram linking
doesn't work due to https://github.com/ombi-app/ombi/issues/3484
committing to save progress.
2022-01-25 17:01:18 +00:00
Harvey Tindall
987e0ddd4e accounts: show "Set expiry" button for non-disabled users
previously hidden in a dropdown on the "re-enable" button for disabled
users only, now can be used on active ones.
2022-01-25 15:27:16 +00:00
Harvey Tindall
8fd097836f discord: send in-channel reply to !start 2022-01-14 17:26:19 +00:00
Harvey Tindall
5acee68987 settings: rename URL base to reverse proxy subfolder 2022-01-14 17:04:17 +00:00
Harvey Tindall
25a1ca5a9f bots: fix language selection
you may have to re-apply your language choice.
2022-01-14 17:01:42 +00:00
Harvey Tindall
32af107699 discord: add role to user on signup
This requires an extra permission, so you'll have to modify your bot
settings, kick it from your server and re-add it for this to work. The
role you select must also be lower in hierarchy than the bot's group.
2022-01-13 22:34:52 +00:00
Harvey Tindall
b929e16f2c build: apply cssVersion for all ldflags 2022-01-13 21:49:56 +00:00
Harvey Tindall
1c942186aa form: fix captcha
wouldn't compile (not sure why i didn't notice) and after fixing, the
check was being performed after deleting the invite so would always
fail.
2022-01-13 20:40:58 +00:00
Harvey Tindall
d9f8785372 form: add CAPTCHAs
Enabled in Settings > Captchas, shows a captcha on the account creation
form.
2022-01-10 01:55:48 +00:00
Harvey Tindall
8758d74e32 fix titles for some pages 2022-01-10 00:46:01 +00:00
Harvey Tindall
6448a7db9e accounts: allow giving individual users jfa-go access
New "Access jfa-go" column allows you to select users for jfa-go access.
New "Allow All" setting allows all Jellyfin users access, as disabling
"Admin Only" no longer does this.
2022-01-09 19:37:17 +00:00
Richard de Boer
46d1da7cd3 translation from Weblate (Dutch)
Currently translated at 100.0% (175 of 175 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/nl/
2022-01-08 17:43:45 +01:00
Harvey Tindall
77c05a4d4f prefix css with version to avoid cache conflict 2022-01-08 16:42:36 +00:00
Harvey Tindall
4024334c0c accounts: add ability to label users
press the pen icon next to their username to add a nick-name to remind
you who they are.
2022-01-08 16:20:31 +00:00
Harvey Tindall
3294b27029 add replaceAll polyfill 2022-01-08 00:22:21 +00:00
Harvey Tindall
0d4747e8e9 Merge branch 'main' of github.com:hrfee/jfa-go 2022-01-08 00:09:35 +00:00
Harvey Tindall
1ebc648158 fix broken go template if statements
All available DOM parsers for node would move the contents of if
statements outside of them, breaking things like the accounts tab. Fixed
with a regex pre and post process to comment out then uncomment all template usage.

builds now depend on perl for some regex, this can likely be changed in
future though.
2022-01-08 00:07:23 +00:00
mLgz0rn
7e5bfd4b10 translation from Weblate (Danish)
Currently translated at 94.2% (164 of 174 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/da/
2022-01-05 04:57:53 +01:00
mLgz0rn
5074f4d7af Translated using Weblate (Danish)
Currently translated at 90.0% (9 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/da/
2022-01-05 04:57:53 +01:00
mLgz0rn
22feec49cb Translated using Weblate (Danish)
Currently translated at 100.0% (21 of 21 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/da/
2022-01-05 04:57:53 +01:00
mLgz0rn
4b59876d8b Translated using Weblate (Danish)
Currently translated at 100.0% (109 of 109 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/da/
2022-01-05 04:57:53 +01:00
mLgz0rn
3c278bc930 Translated using Weblate (Danish)
Currently translated at 100.0% (6 of 6 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/da/
2022-01-05 04:57:53 +01:00
mLgz0rn
0221d29ecb Translated using Weblate (Danish)
Currently translated at 96.0% (49 of 51 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/da/
2022-01-05 04:57:53 +01:00
mLgz0rn
130b56f02d translation from Weblate (Danish)
Currently translated at 97.2% (36 of 37 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/da/
2022-01-05 04:57:53 +01:00
Harvey Tindall
e86f5f4c3c site: use tailwind 2022-01-04 21:14:12 +00:00
Harvey Tindall
3b0701e772 fix dark mode script 2022-01-04 20:46:22 +00:00
Harvey Tindall
9874dce520 merge tailwind and upgraded a17t
a17t v0.10 became a tailwind plugin rather than standalone css, and made
some other changes. Much of the original custom CSS now uses tailwind
classes, and there have been some other UI changes.
2022-01-04 20:28:36 +00:00
Harvey Tindall
7ff492df6c site: allow html injection 2022-01-02 22:05:26 +00:00
Harvey Tindall
51b59ae103 settings: add button to get logs 2021-12-30 17:54:27 +00:00
Harvey Tindall
8888807780 remove debug print, add another 2021-12-30 17:30:29 +00:00
Harvey Tindall
d19f7d6b53 mailgun: better handle different api url formats 2021-12-30 02:55:00 +00:00
Harvey Tindall
07de4e5015 build: include systemd service 2021-12-29 23:29:34 +00:00
329 changed files with 39734 additions and 15299 deletions

View File

@@ -1,171 +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
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 '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 --rm-dist
- 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 '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
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
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
- wget https://builds.hrfee.pw/upload.py -O /mnt/buildx/jfa-go/jfa-go/upload.py
- pip3 install requests
- bash -c 'cd /mnt/buildx/jfa-go/jfa-go && BUILDRONE_KEY=$(cat /mnt/buildx/jfa-go/key) python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-unstable=true'
- 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 --rm-dist
trigger:
event:
include:
- pull_request

View File

@@ -21,7 +21,7 @@ What to do to reproduce the problem.
**If you're using a build with a tray icon, right-click on it and press "Open logs" to access your logs.**
When you notice the problem, check the output of `jfa-go`. If the problem is not obvious (e.g a panic (red text) or 'ERROR' log), re-run jfa-go with the `-debug` argument and reproduce the problem. You should then take a screenshot of the output, or paste it here, preferably between \`\`\` tags (e.g \`\`\``Log here`\`\`\`). Remember to censor any personal information.
When you notice the problem, check the output of `jfa-go` or get the logs by pressing the "Logs" button in the Settings tab. If the problem is not obvious (e.g a panic (red text) or 'ERROR' log), re-run jfa-go with the `-debug` argument and reproduce the problem. You should then take a screenshot of the output, or paste it here, preferably between \`\`\` tags (e.g \`\`\``Log here`\`\`\`). Remember to censor any personal information.
If nothing catches your eye in the log, access the admin page via your browser, go into the console (Right click > Inspect Element > Console), refresh, reproduce the problem then paste the output here in the same way as above.

7
.gitignore vendored
View File

@@ -1,6 +1,7 @@
node_modules/
site/node_modules/
site/out/
site/tempts/
mail/*.html
dist/
build/
@@ -20,3 +21,9 @@ cl.md
mautrix/
tempts/
matacc.txt
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,43 +8,37 @@ release:
name_template: "v{{.Version}}"
before:
hooks:
- go mod download
- rm -rf data/web
- mkdir -p data
- cp -r static data/web
- npm install
- npm install esbuild
- mkdir -p data/web/css
- npx esbuild --bundle css/base.css --outfile=./data/web/css/bundle.css --external:remixicon.css --minify
- cp node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 data/web/css/
- cp -r html data/
- cp -r lang data/
- cp LICENSE 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/
- npx esbuild --bundle ts/admin.ts --outfile=./data/web/js/admin.js --minify
- npx esbuild --bundle ts/pwr.ts --outfile=./data/web/js/pwr.js --minify
- npx esbuild --bundle ts/form.ts --outfile=./data/web/js/form.js --minify
- npx esbuild --bundle ts/setup.ts --outfile=./data/web/js/setup.js --minify
- npx esbuild --bundle ts/crash.ts --outfile=./data/crash.js --minify
- cp html/crash.html data/
- npx uncss data/crash.html --csspath web/css --output data/bundle.css
- node scripts/inline.js root data data/crash.html data/crash.html
- rm data/bundle.css
- mv data/crash.html data/html/
- go get -u github.com/swaggo/swag/cmd/swag
- swag init -g main.go
- npm i
- make precompile
builds:
- id: notray
dir: ./
env:
- CGO_ENABLED=0
flags:
- -tags={{ .Env.JFA_GO_TAG }}
ldflags:
- -s -w -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary
- -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
- windows
goarch:
- 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
@@ -54,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:
- -s -w -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary -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:
@@ -65,49 +61,62 @@ 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:
- -s -w -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary
- -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
name_template: "{{ .ProjectName }}_{{ .Version }}_TrayIcon_{{ .Os }}_{{ .Arch }}"
replacements:
darwin: macOS
linux: Linux
windows: Windows
amd64: x86_64
formats: [ "zip" ]
name_template: >-
{{ .ProjectName }}_{{ .Version }}_TrayIcon_
{{- if eq .Os "darwin" }}macOS
{{- else }}{{- title .Os }}{{ end }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else }}{{ .Arch }}{{ end }}
- id: linux-tray
builds:
ids:
- linux-tray
format: zip
name_template: "{{ .ProjectName }}_{{ .Version }}_TrayIcon_{{ .Os }}_{{ .Arch }}"
replacements:
darwin: macOS
linux: Linux
windows: Windows
amd64: x86_64
formats: [ "zip" ]
name_template: >-
{{ .ProjectName }}_{{ .Version }}_TrayIcon_
{{- if eq .Os "darwin" }}macOS
{{- else }}{{- title .Os }}{{ end }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else }}{{ .Arch }}{{ end }}
- id: notray
builds:
ids:
- notray
format: zip
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
replacements:
darwin: macOS
linux: Linux
windows: Windows
amd64: x86_64
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:
@@ -124,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
@@ -133,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
@@ -142,7 +161,7 @@ nfpms:
license: MIT
vendor: hrfee.dev
version_metadata: git
builds:
ids:
- linux-tray
contents:
- src: ./LICENSE
@@ -158,10 +177,13 @@ nfpms:
replaces:
- jfa-go
dependencies:
- libappindicator3-1
- libayatana-appindicator
- libolm-dev
rpm:
dependencies:
- libappindicator-gtk3
- libolm
apk:
dependencies:
- libappindicator
- 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_14.x | bash -) \
&& apt-get install nodejs \
&& (cd /opt/build; make configuration npm email typescript bundle-css inline 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" ]

View File

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

209
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
@@ -6,12 +9,16 @@ else
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')
COMMIT ?= $(shell git rev-parse --short HEAD || echo unknown)
BUILDTIME ?= $(shell date +%s)
UPDATER ?= off
LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT)
LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.cssVersion=$(CSSVERSION) -X main.buildTimeUnix=$(BUILDTIME) $(if $(BUILTBY),-X 'main.builtBy=$(BUILTBY)',)
ifeq ($(UPDATER), on)
LDFLAGS := $(LDFLAGS) -X main.updater=binary
else ifneq ($(UPDATER), off)
@@ -22,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)
@@ -50,17 +59,19 @@ endif
DEBUG ?= off
ifeq ($(DEBUG), on)
SOURCEMAP := --sourcemap
TYPECHECK := tsc -noEmit --project ts/tsconfig.json
MINIFY :=
TYPECHECK := npx tsc -noEmit --project ts/tsconfig.json
# jank
COPYTS := rm -r $(DATA)/web/js/ts; cp -r tempts $(DATA)/web/js
UNCSS := cp $(DATA)/web/css/bundle.css $(DATA)/bundle.css
TAILWIND := --content ""
COPYTS := rm -r $(DATA)/web/js/ts; cp -r tempts $(DATA)/web/js/ts
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
@@ -72,82 +83,123 @@ else
RACEDETECTOR :=
endif
npm:
$(info installing npm dependencies)
npm install
@if [ "$(GOESBUILD)" = "off" ]; then\
npm install esbuild;\
else\
go get -u github.com/evanw/esbuild/cmd/esbuild;\
fi
ifeq (, $(shell which esbuild))
ESBUILDINSTALL := go install github.com/evanw/esbuild/cmd/esbuild@latest
else
ESBUILDINSTALL :=
endif
configuration:
$(info Fixing config-base)
-mkdir -p $(DATA)
python3 scripts/enumerate_config.py -i config/config-base.json -o $(DATA)/config-base.json
ifeq ($(GOESBUILD), on)
NPMIGNOREOPTIONAL := --no-optional
NPMOPTS := $(NPMIGNOREOPTIONAL); $(ESBUILDINSTALL)
else
NPMOPTS :=
endif
ifeq (, $(shell which swag))
SWAGINSTALL := $(GOBINARY) install github.com/swaggo/swag/cmd/swag@latest
else
SWAGINSTALL :=
endif
CONFIG_BASE = config/config-base.yaml
# 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 -r tempts
rm -rf tempts
cp -r ts tempts
scripts/dark-variant.sh ts tempts
scripts/dark-variant.sh ts tempts/modules
$(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) --bundle tempts/admin.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/admin.js --minify
-$(ESBUILD) --bundle tempts/pwr.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/pwr.js --minify
-$(ESBUILD) --bundle tempts/form.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/form.js --minify
-$(ESBUILD) --bundle tempts/setup.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/setup.js --minify
-$(ESBUILD) --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:
$(GOBINARY) install github.com/swaggo/swag/cmd/swag
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 bundling css)
$(ESBUILD) --bundle css/base.css --outfile=$(DATA)/web/css/bundle.css --external:remixicon.css --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:
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)/
@@ -155,14 +207,27 @@ copy:
cp -r lang $(DATA)/
cp LICENSE $(DATA)/
# 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
@@ -174,4 +239,6 @@ clean:
-rm docs/docs.go docs/swagger.json docs/swagger.yaml
go clean
all: configuration npm email typescript variants-html bundle-css inline 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)
@@ -7,29 +7,41 @@
##### Downloads:
##### [docker](#docker) | [debian/ubuntu](#debian) | [arch (aur)](#aur) | [other platforms](#other-platforms)
---
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
😂).
---
## Project Status: Active-ish
Studies mean I can't work on this project a lot outside of breaks, however I hope i'll be able to fit in general support and things like bug fixes into my time. New features and such will likely come in short bursts throughout the year (if they do at all).
#### Does/Will it still work?
jfa-go currently works on Jellyfin 10.9.8, the latest version as of 31/07/2024. I should be able to maintain compatability in the future, unless any big changes occur.
#### 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.
* [jfa-go now integrates with Jellyseerr, much like Ombi, but better.](https://github.com/hrfee/jfa-go/pull/351)
* [Organizr](https://github.com/causefx/Organizr) doesn't focus on Jellyfin, but allows putting self-hosted services into "tabs" on a central page, and allows creating users, which lets one control who can access what.
---
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) (and [Emby](https://emby.media/) as 2nd class) that provides invite-based account creation as well as other features that make one's instance much easier to manage.
#### 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 and contact method verificatoin can be enabled to avoid bots.
* ⌛ User expiry: Specify a validity period, and new users accounts will be disabled/deleted after it. The period can be manually extended too.
* 🔗 Ombi Integration: Automatically creates Ombi accounts for new users using their email address and login details, and your own defined set of permissions.
* Account management: Apply settings to your users individually or en masse, and delete users, optionally sending them an email notification with a reason.
* Telegram/Discord/Matrix Integration: Verify users via a chat bot, and send Password Resets, Announcements, etc. through it.
* 📨 Email storage: Add your existing users email addresses through the UI, and jfa-go will ask new users for them on account creation.
* Email addresses can optionally be used instead of usernames
* 🔑 Password resets: When users forget their passwords and request a change in Jellyfin, jfa-go reads the PIN from the created file and sends it straight to the user via email/telegram.
* Notifications: Get notified when someone creates an account, or an invite expires.
* 🔗 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.
* Authentication via Jellyfin: Instead of using separate credentials for jfa-go and Jellyfin, jfa-go can use it as the authentication provider.
* Enables the usage of jfa-go by multiple people
* Telegram/Discord/Matrix Integration: Verify users via a chat bot, and send Password Resets, Announcements, etc. through it.
* "My Account" Page: Allows users to reset their password, manage contact details, view their account expiry date, and send referrals. Can be customized with markdown.
* Referrals: Users can be given special invites to send to their friends and families, similar to some invite-only services like Bluesky.
* 🔑 Password resets: When users forget their passwords and request a change in Jellyfin, jfa-go reads the PIN from the created file and sends it straight to them via email/telegram.
* Can also be done through the "My Account" page if enabled.
* Admin Notifications: Get notified when someone creates an account, or an invite expires.
* 🌓 Customizations
* Customize emails with variables and markdown
* Specify contact and help messages to appear in emails and pages
@@ -37,19 +49,18 @@ a rewrite of [jellyfin-accounts](https://github.com/hrfee/jellyfin-accounts) (or
#### Interface
<p align="center">
<img src="images/demo.gif" width="100%"></img>
</p>
<p align="center">
<img src="images/invites.png" width="31%" style="margin-left: 1.5%;" alt="Invites tab"></img>
<img src="images/accounts.png" width="31%" style="margin-right: 1.5%;" alt="Accounts tab"></img>
<img src="images/create.png" width="31%" style="margin-right: 1.5%;" alt="Accounts creation"></img>
<img src="images/invites.png" width="47%" style="margin-left: 1.5%;" align="top" alt="Invites tab"></img>
<img src="images/create.png" width="47%" style="margin-right: 1.5%;" align="top" alt="Accounts creation"></img>
<img src="images/myaccount.png" width="47%" style="margin-left: 1.5%; margin-top: 1rem;" align="top" alt="My Account Page"></img>
<img src="images/accounts.png" width="47%" style="margin-right: 1.5%; margin-top: 1rem;" align="top" alt="Accounts tab"></img>
</p>
#### Install
**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 \
@@ -57,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
```
@@ -65,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
@@ -79,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
# ------
```
@@ -93,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`.
@@ -132,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
```
@@ -139,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 [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/)

255
activitysort.go Normal file
View File

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

190
api-activities.go Normal file
View File

@@ -0,0 +1,190 @@
package main
import (
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/timshannon/badgerhold/v4"
)
func activityTypeToString(v ActivityType) string {
switch v {
case ActivityCreation:
return "creation"
case ActivityDeletion:
return "deletion"
case ActivityDisabled:
return "disabled"
case ActivityEnabled:
return "enabled"
case ActivityContactLinked:
return "contactLinked"
case ActivityContactUnlinked:
return "contactUnlinked"
case ActivityChangePassword:
return "changePassword"
case ActivityResetPassword:
return "resetPassword"
case ActivityCreateInvite:
return "createInvite"
case ActivityDeleteInvite:
return "deleteInvite"
}
return "unknown"
}
func stringToActivityType(v string) ActivityType {
switch v {
case "creation":
return ActivityCreation
case "deletion":
return ActivityDeletion
case "disabled":
return ActivityDisabled
case "enabled":
return ActivityEnabled
case "contactLinked":
return ActivityContactLinked
case "contactUnlinked":
return ActivityContactUnlinked
case "changePassword":
return ActivityChangePassword
case "resetPassword":
return ActivityResetPassword
case "createInvite":
return ActivityCreateInvite
case "deleteInvite":
return ActivityDeleteInvite
}
return ActivityUnknown
}
func stringToActivitySource(v string) ActivitySource {
switch v {
case "user":
return ActivityUser
case "admin":
return ActivityAdmin
case "anon":
return ActivityAnon
case "daemon":
return ActivityDaemon
}
return ActivityAnon
}
func activitySourceToString(v ActivitySource) string {
switch v {
case ActivityUser:
return "user"
case ActivityAdmin:
return "admin"
case ActivityAnon:
return "anon"
case ActivityDaemon:
return "daemon"
}
return "anon"
}
// @Summary Get the requested set of activities, Paginated, filtered and sorted. Is a POST because of some issues I was having, ideally should be a GET.
// @Produce json
// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search parameters"
// @Success 200 {object} GetActivitiesRespDTO
// @Router /activity [post]
// @Security Bearer
// @tags Activity
func (app *appContext) GetActivities(gc *gin.Context) {
req := ServerSearchReqDTO{}
gc.BindJSON(&req)
if req.SortByField == "" {
req.SortByField = USER_DEFAULT_SORT_FIELD
} else {
req.SortByField = activityDTONameToField(req.SortByField)
}
var query *badgerhold.Query
if len(req.SearchTerms) != 0 {
query = ActivityMatchesSearchAsDBBaseQuery(req.SearchTerms)
} else {
query = nil
}
for _, q := range req.Queries {
nq := q.AsDBQuery(query)
if nq == nil {
nq = ActivityDBQueryFromSpecialField(app.jf, query, q)
}
query = nq
}
if query == nil {
query = &badgerhold.Query{}
}
query = query.SortBy(req.SortByField)
if !req.Ascending {
query = query.Reverse()
}
query = query.Skip(req.Page * req.Limit).Limit(req.Limit)
var results []Activity
err := app.storage.db.Find(&results, query)
if err != nil {
app.err.Printf(lm.FailedDBReadActivities, err)
}
resp := GetActivitiesRespDTO{
Activities: make([]ActivityDTO, len(results)),
}
resp.LastPage = len(results) != req.Limit
for i, act := range results {
resp.Activities[i] = ActivityDTO{
ID: act.ID,
Type: activityTypeToString(act.Type),
UserID: act.UserID,
SourceType: activitySourceToString(act.SourceType),
Source: act.Source,
InviteCode: act.InviteCode,
Value: act.Value,
Time: act.Time.Unix(),
IP: act.IP,
Username: act.MustGetUsername(app.jf),
SourceUsername: act.MustGetSourceUsername(app.jf),
}
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
// Username would've been in here, clear it to avoid confusion to the consumer
resp.Activities[i].Value = ""
}
}
gc.JSON(200, resp)
}
// @Summary Delete the activity with the given ID. No-op if non-existent, always succeeds.
// @Produce json
// @Param id path string true "ID of activity to delete"
// @Success 200 {object} boolResponse
// @Router /activity/{id} [delete]
// @Security Bearer
// @tags Activity
func (app *appContext) DeleteActivity(gc *gin.Context) {
app.storage.DeleteActivityKey(gc.Param("id"))
respondBool(200, true, gc)
}
// @Summary Returns the total number of activities stored in the database.
// @Produce json
// @Success 200 {object} PageCountDTO
// @Router /activity/count [get]
// @Security Bearer
// @tags Activity
func (app *appContext) GetActivityCount(gc *gin.Context) {
resp := PageCountDTO{}
var err error
resp.Count, err = app.storage.db.Count(&Activity{}, &badgerhold.Query{})
if err != nil {
resp.Count = 0
}
gc.JSON(200, resp)
}

118
api-backups.go Normal file
View File

@@ -0,0 +1,118 @@
package main
import (
"os"
"path/filepath"
"sort"
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
)
// @Summary Creates a backup of the database.
// @Router /backups [post]
// @Success 200 {object} CreateBackupDTO
// @Security Bearer
// @tags Backups
func (app *appContext) CreateBackup(gc *gin.Context) {
backup := app.makeBackup()
gc.JSON(200, backup)
}
// @Summary Download a specific backup file. Requires auth, so can't be accessed plainly in the browser.
// @Param fname path string true "backup filename"
// @Router /backups/{fname} [get]
// @Produce octet-stream
// @Produce json
// @Success 200 {body} file
// @Failure 400 {object} boolResponse
// @Security Bearer
// @tags Backups
func (app *appContext) GetBackup(gc *gin.Context) {
fname := gc.Param("fname")
// Hopefully this is enough to ensure the path isn't malicious. Hidden behind bearer auth anyway so shouldn't matter too much I guess.
b := Backup{}
err := b.FromString(fname)
if err != nil || b.Date.IsZero() {
app.debug.Printf(lm.IgnoreInvalidFilename, fname, err)
respondBool(400, false, gc)
return
}
path := app.config.Section("backups").Key("path").String()
fullpath := filepath.Join(path, fname)
gc.FileAttachment(fullpath, fname)
}
// @Summary Get a list of backups.
// @Router /backups [get]
// @Produce json
// @Success 200 {object} GetBackupsDTO
// @Security Bearer
// @tags Backups
func (app *appContext) GetBackups(gc *gin.Context) {
path := app.config.Section("backups").Key("path").String()
backups := app.getBackups()
sort.Sort(backups)
resp := GetBackupsDTO{}
resp.Backups = make([]CreateBackupDTO, backups.count)
for i, item := range backups.files[:backups.count] {
resp.Backups[i].Name = item.Name()
fullpath := filepath.Join(path, item.Name())
resp.Backups[i].Path = fullpath
resp.Backups[i].Date = backups.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())
}
}
gc.JSON(200, resp)
}
// @Summary Restore a backup file stored locally to the server.
// @Param fname path string true "backup filename"
// @Router /backups/restore/{fname} [post]
// @Produce json
// @Failure 400 {object} boolResponse
// @Security Bearer
// @tags Backups
func (app *appContext) RestoreLocalBackup(gc *gin.Context) {
fname := gc.Param("fname")
// Hopefully this is enough to ensure the path isn't malicious. Hidden behind bearer auth anyway so shouldn't matter too much I guess.
b := Backup{}
err := b.FromString(fname)
if err != nil || b.Date.IsZero() {
app.debug.Printf(lm.IgnoreInvalidFilename, fname, err)
respondBool(400, false, gc)
return
}
path := app.config.Section("backups").Key("path").String()
fullpath := filepath.Join(path, fname)
LOADBAK = fullpath
app.restart(gc)
}
// @Summary Restore a backup file uploaded by the user.
// @Param file formData file true ".bak file"
// @Router /backups/restore [post]
// @Produce json
// @Failure 400 {object} boolResponse
// @Security Bearer
// @tags Backups
func (app *appContext) RestoreBackup(gc *gin.Context) {
file, err := gc.FormFile("backups-file")
if err != nil {
app.err.Printf(lm.FailedGetUpload, err)
respondBool(400, false, gc)
return
}
app.debug.Printf(lm.GetUpload, file.Filename)
path := app.config.Section("backups").Key("path").String()
b := Backup{Upload: true}
fullpath := filepath.Join(path, b.String())
gc.SaveUploadedFile(file, fullpath)
app.debug.Printf(lm.Write, fullpath)
LOADBAK = fullpath
app.restart(gc)
}

449
api-invites.go Normal file
View File

@@ -0,0 +1,449 @@
package main
import (
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/itchyny/timefmt-go"
"github.com/lithammer/shortuuid/v3"
)
const (
CAPTCHA_VALIDITY = 20 * 60 // Seconds
)
// GenerateInviteCode generates an invite code in the correct format.
func GenerateInviteCode() string {
// make sure code doesn't begin with number
inviteCode := shortuuid.New()
_, err := strconv.Atoi(string(inviteCode[0]))
for err == nil {
inviteCode = shortuuid.New()
_, err = strconv.Atoi(string(inviteCode[0]))
}
return inviteCode
}
// 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() {
captchas := data.Captchas
captchasExpired := false
for key, capt := range data.Captchas {
if time.Now().After(capt.Generated.Add(CAPTCHA_VALIDITY * time.Second)) {
delete(captchas, key)
captchasExpired = true
}
}
if captchasExpired {
data.Captchas = captchas
app.storage.SetInvitesKey(data.Code, data)
}
if data.IsReferral && (!data.UseReferralExpiry || data.ReferrerJellyfinID == "") {
continue
}
expiry := data.ValidTill
if !currentTime.After(expiry) {
continue
}
app.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)
if !match {
return false
}
expiry := inv.ValidTill
if currentTime.After(expiry) {
app.deleteExpiredInvite(inv)
match = false
} else if used {
del := false
newInv := inv
if newInv.RemainingUses == 1 {
del = true
app.storage.DeleteInvitesKey(code)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeleteInvite,
SourceType: ActivityDaemon,
InviteCode: code,
Value: inv.Label,
Time: time.Now(),
}, nil, false)
} else if newInv.RemainingUses != 0 {
// 0 means infinite i guess?
newInv.RemainingUses--
}
newInv.UsedBy = append(newInv.UsedBy, []string{username, strconv.FormatInt(currentTime.Unix(), 10)})
if !del {
app.storage.SetInvitesKey(code, newInv)
}
}
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"
// @Success 200 {object} boolResponse
// @Router /invites [post]
// @Security Bearer
// @tags Invites
func (app *appContext) GenerateInvite(gc *gin.Context) {
var req generateInviteDTO
app.debug.Println(lm.GenerateInvite)
gc.BindJSON(&req)
currentTime := time.Now()
validTill := currentTime.AddDate(0, req.Months, req.Days)
validTill = validTill.Add(time.Hour*time.Duration(req.Hours) + time.Minute*time.Duration(req.Minutes))
var invite Invite
invite.Code = GenerateInviteCode()
if req.Label != "" {
invite.Label = req.Label
}
if req.UserLabel != "" {
invite.UserLabel = req.UserLabel
}
invite.Created = currentTime
if req.MultipleUses {
if req.NoLimit {
invite.NoLimit = true
} else {
invite.RemainingUses = req.RemainingUses
}
} else {
invite.RemainingUses = 1
}
invite.UserExpiry = req.UserExpiry
if invite.UserExpiry {
invite.UserMonths = req.UserMonths
invite.UserDays = req.UserDays
invite.UserHours = req.UserHours
invite.UserMinutes = req.UserMinutes
}
invite.ValidTill = validTill
if req.SendTo != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
addressValid := false
discord := ""
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
users := app.discord.GetUsers(req.SendTo)
if len(users) == 0 {
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipNoUser, req.SendTo)
} else if len(users) > 1 {
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipMultiUser, req.SendTo)
} else {
invite.SendTo = req.SendTo
addressValid = true
discord = users[0].User.ID
}
} else if emailEnabled {
addressValid = true
invite.SendTo = req.SendTo
}
if addressValid {
msg, err := app.email.constructInvite(invite.Code, invite, app, false)
if err != nil {
// Slight misuse of the template
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, req.SendTo, err)
app.err.Printf(lm.FailedConstructInviteMessage, invite.Code, err)
} else {
var err error
if discord != "" {
err = app.discord.SendDM(msg, discord)
} else {
err = app.email.send(msg, req.SendTo)
}
if err != nil {
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, err)
app.err.Println(invite.SendTo)
} else {
app.info.Printf(lm.SentInviteMessage, invite.Code, req.SendTo)
}
}
}
}
if req.Profile != "" {
if _, ok := app.storage.GetProfileKey(req.Profile); ok {
invite.Profile = req.Profile
} else {
invite.Profile = "Default"
}
}
app.storage.SetInvitesKey(invite.Code, invite)
// Record activity
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityCreateInvite,
UserID: "",
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
InviteCode: invite.Code,
Value: invite.Label,
Time: time.Now(),
}, gc, false)
respondBool(200, true, gc)
}
// @Summary Get invites.
// @Produce json
// @Success 200 {object} getInvitesDTO
// @Router /invites [get]
// @Security Bearer
// @tags Invites
func (app *appContext) GetInvites(gc *gin.Context) {
currentTime := time.Now()
app.checkInvites()
var invites []inviteDTO
for _, inv := range app.storage.GetInvites() {
if inv.IsReferral {
continue
}
years, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
months += years * 12
invite := inviteDTO{
Code: inv.Code,
Months: months,
Days: days,
Hours: hours,
Minutes: minutes,
UserExpiry: inv.UserExpiry,
UserMonths: inv.UserMonths,
UserDays: inv.UserDays,
UserHours: inv.UserHours,
UserMinutes: inv.UserMinutes,
Created: inv.Created.Unix(),
Profile: inv.Profile,
NoLimit: inv.NoLimit,
Label: inv.Label,
UserLabel: inv.UserLabel,
}
if len(inv.UsedBy) != 0 {
invite.UsedBy = map[string]int64{}
for _, pair := range inv.UsedBy {
// These used to be stored formatted instead of as a unix timestamp.
unix, err := strconv.ParseInt(pair[1], 10, 64)
if err != nil {
date, err := timefmt.Parse(pair[1], app.datePattern+" "+app.timePattern)
if err != nil {
app.err.Printf(lm.FailedParseTime, err)
}
unix = date.Unix()
}
invite.UsedBy[pair[0]] = unix
}
}
invite.RemainingUses = 1
if inv.RemainingUses != 0 {
invite.RemainingUses = inv.RemainingUses
}
if inv.SendTo != "" {
invite.SendTo = inv.SendTo
}
if len(inv.Notify) != 0 {
var addressOrID string
if app.config.Section("ui").Key("jellyfin_login").MustBool(false) {
addressOrID = gc.GetString("jfId")
} else {
addressOrID = app.config.Section("ui").Key("email").String()
}
if _, ok := inv.Notify[addressOrID]; ok {
if _, ok = inv.Notify[addressOrID]["notify-expiry"]; ok {
invite.NotifyExpiry = inv.Notify[addressOrID]["notify-expiry"]
}
if _, ok = inv.Notify[addressOrID]["notify-creation"]; ok {
invite.NotifyCreation = inv.Notify[addressOrID]["notify-creation"]
}
}
}
invites = append(invites, invite)
}
resp := getInvitesDTO{
Invites: invites,
}
gc.JSON(200, resp)
}
// @Summary Set profile for an invite
// @Produce json
// @Param inviteProfileDTO body inviteProfileDTO true "Invite profile object"
// @Success 200 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Router /invites/profile [post]
// @Security Bearer
// @tags Invites
func (app *appContext) SetProfile(gc *gin.Context) {
var req inviteProfileDTO
gc.BindJSON(&req)
// "" means "Don't apply profile"
if _, ok := app.storage.GetProfileKey(req.Profile); !ok && req.Profile != "" {
app.err.Printf(lm.FailedGetProfile, req.Profile)
respond(500, "Profile not found", gc)
return
}
inv, _ := app.storage.GetInvitesKey(req.Invite)
inv.Profile = req.Profile
app.storage.SetInvitesKey(req.Invite, inv)
respondBool(200, true, gc)
}
// @Summary Set notification preferences for an invite.
// @Produce json
// @Param setNotifyDTO body setNotifyDTO true "Map of invite codes to notification settings objects"
// @Success 200
// @Failure 400 {object} stringResponse
// @Failure 500 {object} stringResponse
// @Router /invites/notify [post]
// @Security Bearer
// @tags Other
func (app *appContext) SetNotify(gc *gin.Context) {
var req map[string]map[string]bool
gc.BindJSON(&req)
changed := false
for code, settings := range req {
invite, ok := app.storage.GetInvitesKey(code)
if !ok {
msg := fmt.Sprintf(lm.InvalidInviteCode, code)
app.err.Println(msg)
respond(400, msg, gc)
return
}
var address string
jellyfinLogin := app.config.Section("ui").Key("jellyfin_login").MustBool(false)
if jellyfinLogin {
var addressAvailable bool = app.getAddressOrName(gc.GetString("jfId")) != ""
if !addressAvailable {
app.err.Printf(lm.FailedGetContactMethod, gc.GetString("jfId"))
respond(500, fmt.Sprintf(lm.FailedGetContactMethod, "admin"), gc)
return
}
address = gc.GetString("jfId")
} else {
address = app.config.Section("ui").Key("email").String()
}
if invite.Notify == nil {
invite.Notify = map[string]map[string]bool{}
}
if _, ok := invite.Notify[address]; !ok {
invite.Notify[address] = map[string]bool{}
} /*else {
if _, ok := invite.Notify[address]["notify-expiry"]; !ok {
*/
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)
}
}
}
// @Summary Delete an invite.
// @Produce json
// @Param deleteInviteDTO body deleteInviteDTO true "Delete invite object"
// @Success 200 {object} boolResponse
// @Failure 400 {object} stringResponse
// @Router /invites [delete]
// @Security Bearer
// @tags Invites
func (app *appContext) DeleteInvite(gc *gin.Context) {
var req deleteInviteDTO
gc.BindJSON(&req)
inv, ok := app.storage.GetInvitesKey(req.Code)
if ok {
app.storage.DeleteInvitesKey(req.Code)
// Record activity
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeleteInvite,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
InviteCode: req.Code,
Value: inv.Label,
Time: time.Now(),
}, gc, false)
app.info.Printf(lm.DeleteInvite, req.Code)
respondBool(200, true, gc)
return
}
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)
}

803
api-messages.go Normal file
View File

@@ -0,0 +1,803 @@
package main
import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
)
// @Summary Get a list of email names and IDs.
// @Produce json
// @Param lang query string false "Language for email titles."
// @Success 200 {object} emailListDTO
// @Router /config/emails [get]
// @Security Bearer
// @tags Configuration
func (app *appContext) GetCustomContent(gc *gin.Context) {
lang := gc.Query("lang")
if _, ok := app.storage.lang.Email[lang]; !ok {
lang = app.storage.lang.chosenEmailLang
}
adminLang := lang
if _, ok := app.storage.lang.Admin[lang]; !ok {
adminLang = app.storage.lang.chosenAdminLang
}
list := emailListDTO{
"UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.MustGetCustomContentKey("UserCreated").Enabled},
"InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.MustGetCustomContentKey("InviteExpiry").Enabled},
"PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.MustGetCustomContentKey("PasswordReset").Enabled},
"UserDeleted": {Name: app.storage.lang.Email[lang].UserDeleted["name"], Enabled: app.storage.MustGetCustomContentKey("UserDeleted").Enabled},
"UserDisabled": {Name: app.storage.lang.Email[lang].UserDisabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserDisabled").Enabled},
"UserEnabled": {Name: app.storage.lang.Email[lang].UserEnabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserEnabled").Enabled},
"UserExpiryAdjusted": {Name: app.storage.lang.Email[lang].UserExpiryAdjusted["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpiryAdjusted").Enabled},
"InviteEmail": {Name: app.storage.lang.Email[lang].InviteEmail["name"], Enabled: app.storage.MustGetCustomContentKey("InviteEmail").Enabled},
"WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.MustGetCustomContentKey("WelcomeEmail").Enabled},
"EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.MustGetCustomContentKey("EmailConfirmation").Enabled},
"UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpired").Enabled},
"UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.MustGetCustomContentKey("UserLogin").Enabled},
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("UserPage").Enabled},
"PostSignupCard": {Name: app.storage.lang.Admin[adminLang].Strings["postSignupCard"], Enabled: app.storage.MustGetCustomContentKey("PostSignupCard").Enabled, Description: app.storage.lang.Admin[adminLang].Strings["postSignupCardDescription"]},
}
filter := gc.Query("filter")
if filter == "user" {
list = emailListDTO{"UserLogin": list["UserLogin"], "UserPage": list["UserPage"]}
} else {
delete(list, "UserLogin")
delete(list, "UserPage")
}
gc.JSON(200, list)
}
// @Summary Sets the corresponding custom content.
// @Produce json
// @Param CustomContent body CustomContent true "Content = email (in markdown)."
// @Success 200 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Param id path string true "ID of content"
// @Router /config/emails/{id} [post]
// @Security Bearer
// @tags Configuration
func (app *appContext) SetCustomMessage(gc *gin.Context) {
var req CustomContent
gc.BindJSON(&req)
id := gc.Param("id")
if req.Content == "" {
respondBool(400, false, gc)
return
}
message, ok := app.storage.GetCustomContentKey(id)
if !ok {
respondBool(400, false, gc)
return
}
message.Content = req.Content
message.Enabled = true
app.storage.SetCustomContentKey(id, message)
respondBool(200, true, gc)
}
// @Summary Enable/Disable custom content.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Param enable/disable path string true "enable/disable"
// @Param id path string true "ID of email"
// @Router /config/emails/{id}/state/{enable/disable} [post]
// @Security Bearer
// @tags Configuration
func (app *appContext) SetCustomMessageState(gc *gin.Context) {
id := gc.Param("id")
s := gc.Param("state")
enabled := false
if s == "enable" {
enabled = true
} else if s != "disable" {
respondBool(400, false, gc)
}
message, ok := app.storage.GetCustomContentKey(id)
if !ok {
respondBool(400, false, gc)
return
}
message.Enabled = enabled
app.storage.SetCustomContentKey(id, message)
respondBool(200, true, gc)
}
// @Summary Returns the custom content/message (generating it if not set) and list of used variables in it.
// @Produce json
// @Success 200 {object} customEmailDTO
// @Failure 400 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Param id path string true "ID of email"
// @Router /config/emails/{id} [get]
// @Security Bearer
// @tags Configuration
func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
lang := app.storage.lang.chosenEmailLang
id := gc.Param("id")
var content string
var err error
var msg *Message
var variables []string
var conditionals []string
var values map[string]interface{}
username := app.storage.lang.Email[lang].Strings.get("username")
emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress")
customMessage, ok := app.storage.GetCustomContentKey(id)
if !ok && id != "Announcement" {
app.err.Printf(lm.FailedGetCustomMessage, id)
respondBool(400, false, gc)
return
}
if id == "WelcomeEmail" {
conditionals = []string{"{yourAccountWillExpire}"}
customMessage.Conditionals = conditionals
} else if id == "UserPage" {
variables = []string{"{username}"}
customMessage.Variables = variables
} else if id == "UserLogin" {
variables = []string{}
customMessage.Variables = variables
} else if id == "PostSignupCard" {
variables = []string{"{username}", "{myAccountURL}"}
customMessage.Variables = variables
}
content = customMessage.Content
noContent := content == ""
if !noContent {
variables = customMessage.Variables
}
switch id {
case "Announcement":
// Just send the email html
content = ""
case "UserCreated":
if noContent {
msg, err = app.email.constructCreated("", "", "", Invite{}, app, true)
}
values = app.email.createdValues("xxxxxx", username, emailAddress, Invite{}, app, false)
case "InviteExpiry":
if noContent {
msg, err = app.email.constructExpiry("", Invite{}, app, true)
}
values = app.email.expiryValues("xxxxxx", Invite{}, app, false)
case "PasswordReset":
if noContent {
msg, err = app.email.constructReset(PasswordReset{}, app, true)
}
values = app.email.resetValues(PasswordReset{Pin: "12-34-56", Username: username}, app, false)
case "UserDeleted":
if noContent {
msg, err = app.email.constructDeleted("", app, true)
}
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
case "UserDisabled":
if noContent {
msg, err = app.email.constructDisabled("", app, true)
}
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
case "UserEnabled":
if noContent {
msg, err = app.email.constructEnabled("", app, true)
}
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
case "UserExpiryAdjusted":
if noContent {
msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", app, true)
}
values = app.email.expiryAdjustedValues(username, time.Now(), app.storage.lang.Email[lang].Strings.get("reason"), app, false, true)
case "InviteEmail":
if noContent {
msg, err = app.email.constructInvite("", Invite{}, app, true)
}
values = app.email.inviteValues("xxxxxx", Invite{}, app, false)
case "WelcomeEmail":
if noContent {
msg, err = app.email.constructWelcome("", time.Time{}, app, true)
}
values = app.email.welcomeValues(username, time.Now(), app, false, true)
case "EmailConfirmation":
if noContent {
msg, err = app.email.constructConfirmation("", "", "", app, true)
}
values = app.email.confirmationValues("xxxxxx", username, "xxxxxx", app, false)
case "UserExpired":
if noContent {
msg, err = app.email.constructUserExpired(app, true)
}
values = app.email.userExpiredValues(app, false)
case "UserLogin", "UserPage", "PostSignupCard":
values = map[string]interface{}{}
}
if err != nil {
respondBool(500, false, gc)
return
}
if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" && id != "PostSignupCard" {
content = msg.Text
variables = make([]string, strings.Count(content, "{"))
i := 0
found := false
buf := ""
for _, c := range content {
if !found && c != '{' && c != '}' {
continue
}
found = true
buf += string(c)
if c == '}' {
found = false
variables[i] = buf
buf = ""
i++
}
}
customMessage.Variables = variables
}
if variables == nil {
variables = []string{}
}
app.storage.SetCustomContentKey(id, customMessage)
var mail *Message
if id != "UserLogin" && id != "UserPage" && id != "PostSignupCard" {
mail, err = app.email.constructTemplate("", "<div class=\"preview-content\"></div>", app)
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>",
}
mail.Markdown = mail.HTML
}
gc.JSON(200, customEmailDTO{Content: content, Variables: variables, Conditionals: conditionals, Values: values, HTML: mail.HTML, Plaintext: mail.Text})
}
// @Summary Returns a new Telegram verification PIN, and the bot username.
// @Produce json
// @Success 200 {object} telegramPinDTO
// @Router /telegram/pin [get]
// @Security Bearer
// @tags Other
func (app *appContext) TelegramGetPin(gc *gin.Context) {
gc.JSON(200, telegramPinDTO{
Token: app.telegram.NewAuthToken(),
Username: app.telegram.username,
})
}
// @Summary Link a Jellyfin & Telegram user together via a verification PIN.
// @Produce json
// @Param telegramSetDTO body telegramSetDTO true "Token and user's Jellyfin ID."
// @Success 200 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Router /users/telegram [post]
// @Security Bearer
// @tags Other
func (app *appContext) TelegramAddUser(gc *gin.Context) {
var req telegramSetDTO
gc.BindJSON(&req)
if req.Token == "" || req.ID == "" {
respondBool(400, false, gc)
return
}
tgToken, ok := app.telegram.TokenVerified(req.Token)
app.telegram.DeleteVerifiedToken(req.Token)
if !ok {
respondBool(500, false, gc)
return
}
tgUser := TelegramUser{
ChatID: tgToken.ChatID,
Username: tgToken.Username,
Contact: true,
}
if lang, ok := app.telegram.languages[tgToken.ChatID]; ok {
tgUser.Lang = lang
}
app.storage.SetTelegramKey(req.ID, tgUser)
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldTelegram: tgUser.ChatID,
jellyseerr.FieldTelegramEnabled: tgUser.Contact,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
linkExistingOmbiDiscordTelegram(app)
respondBool(200, true, gc)
}
// @Summary Sets whether to notify a user through telegram/discord/matrix/email or not.
// @Produce json
// @Param SetContactMethodsDTO body SetContactMethodsDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
// @Success 200 {object} boolResponse
// @Success 400 {object} boolResponse
// @Success 500 {object} boolResponse
// @Router /users/contact [post]
// @Security Bearer
// @tags Other
func (app *appContext) SetContactMethods(gc *gin.Context) {
var req SetContactMethodsDTO
gc.BindJSON(&req)
if req.ID == "" {
respondBool(400, false, gc)
return
}
app.setContactMethods(req, gc)
}
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 {
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 {
change := dcUser.Contact != req.Discord
dcUser.Contact = req.Discord
app.storage.SetDiscordKey(req.ID, dcUser)
if change {
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 {
change := mxUser.Contact != req.Matrix
mxUser.Contact = req.Matrix
app.storage.SetMatrixKey(req.ID, mxUser)
if change {
app.debug.Printf(lm.SetContactPrefForService, lm.Matrix, mxUser.UserID, req.Matrix)
}
}
if email, ok := app.storage.GetEmailsKey(req.ID); ok {
change := email.Contact != req.Email
email.Contact = req.Email
app.storage.SetEmailsKey(req.ID, email)
if change {
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)
}
// @Summary Returns true/false on whether or not a telegram PIN was verified. Requires bearer auth.
// @Produce json
// @Success 200 {object} boolResponse
// @Param pin path string true "PIN code to check"
// @Router /telegram/verified/{pin} [get]
// @Security Bearer
// @tags Other
func (app *appContext) TelegramVerified(gc *gin.Context) {
pin := gc.Param("pin")
_, ok := app.telegram.TokenVerified(pin)
respondBool(200, ok, gc)
}
// @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
// @Param pin path string true "PIN code to check"
// @Param invCode path string true "invite Code"
// @Router /invite/{invCode}/telegram/verified/{pin} [get]
// @tags Other
func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) {
code := gc.Param("invCode")
if _, ok := app.storage.GetInvitesKey(code); !ok {
respondBool(401, false, gc)
return
}
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.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. NOTE: "/invite" might have been changed in Settings > URL Paths.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 401 {object} boolResponse
// @Param pin path string true "PIN code to check"
// @Param invCode path string true "invite Code"
// @Router /invite/{invCode}/discord/verified/{pin} [get]
// @tags Other
func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
code := gc.Param("invCode")
if _, ok := app.storage.GetInvitesKey(code); !ok {
respondBool(401, false, gc)
return
}
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.MethodID().(string)) {
delete(app.discord.verifiedTokens, pin)
respondBool(400, false, gc)
return
}
respondBool(200, ok, gc)
}
// @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
// @Failure 401 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Param invCode path string true "invite Code"
// @Router /invite/{invCode}/discord/invite [get]
// @tags Other
func (app *appContext) DiscordServerInvite(gc *gin.Context) {
if app.discord.InviteChannel.Name == "" {
respondBool(400, false, gc)
return
}
code := gc.Param("invCode")
if _, ok := app.storage.GetInvitesKey(code); !ok {
respondBool(401, false, gc)
return
}
invURL, iconURL := app.discord.NewTempInvite(10*60, 1)
if invURL == "" {
respondBool(500, false, gc)
return
}
gc.JSON(200, DiscordInviteDTO{invURL, iconURL})
}
// @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
// @Failure 401 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Param invCode path string true "invite Code"
// @Param MatrixSendPINDTO body MatrixSendPINDTO true "User's Matrix ID."
// @Router /invite/{invCode}/matrix/user [post]
// @tags Other
func (app *appContext) MatrixSendPIN(gc *gin.Context) {
code := gc.Param("invCode")
if _, ok := app.storage.GetInvitesKey(code); !ok {
respondBool(401, false, gc)
return
}
var req MatrixSendPINDTO
gc.BindJSON(&req)
if req.UserID == "" {
respond(400, "errorNoUserID", gc)
return
}
if app.config.Section("matrix").Key("require_unique").MustBool(false) {
for _, u := range app.storage.GetMatrix() {
if req.UserID == u.UserID {
respondBool(400, false, gc)
return
}
}
}
ok := app.matrix.SendStart(req.UserID)
if !ok {
respondBool(500, false, gc)
return
}
respondBool(200, true, gc)
}
// @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
// @Param pin path string true "PIN code to check"
// @Param invCode path string true "invite Code"
// @Param userID path string true "Matrix User ID"
// @Router /invite/{invCode}/matrix/verified/{userID}/{pin} [get]
// @tags Other
func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
code := gc.Param("invCode")
if _, ok := app.storage.GetInvitesKey(code); !ok {
app.debug.Printf(lm.InvalidInviteCode, code)
respondBool(401, false, gc)
return
}
userID := gc.Param("userID")
pin := gc.Param("pin")
user, ok := app.matrix.tokens[pin]
if !ok {
app.debug.Printf(lm.InvalidPIN, pin)
respondBool(200, false, gc)
return
}
if user.User.UserID != userID {
app.debug.Printf(lm.UnauthorizedPIN, pin)
respondBool(200, false, gc)
return
}
user.Verified = true
app.matrix.tokens[pin] = user
respondBool(200, true, gc)
}
// @Summary Generates a Matrix access token from a username and password.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 400 {object} stringResponse
// @Failure 401 {object} boolResponse
// @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
gc.BindJSON(&req)
if req.Username == "" || req.Password == "" {
respond(400, "errorLoginBlank", gc)
return
}
token, err := app.matrix.generateAccessToken(req.Homeserver, req.Username, req.Password)
if err != nil {
app.err.Printf(lm.FailedGenerateToken, err)
respond(401, "Unauthorized", gc)
return
}
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(lm.FailedWriting, app.configPath, err)
respondBool(500, false, gc)
return
}
respondBool(200, true, gc)
}
// @Summary Links a Matrix user to a Jellyfin account via user IDs. Notifications are turned on by default.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 400 {object} boolResponse
// @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
gc.BindJSON(&req)
if app.storage.GetMatrix() == nil {
app.storage.deprecatedMatrix = matrixStore{}
}
roomID, err := app.matrix.CreateRoom(req.UserID)
if err != nil {
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,
})
respondBool(200, true, gc)
}
// @Summary Returns a list of matching users from a Discord guild, given a username (discriminator optional).
// @Produce json
// @Success 200 {object} DiscordUsersDTO
// @Failure 400 {object} boolResponse
// @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")
if name == "" {
respondBool(400, false, gc)
return
}
users := app.discord.GetUsers(name)
resp := DiscordUsersDTO{Users: make([]DiscordUserDTO, len(users))}
for i, u := range users {
resp.Users[i] = DiscordUserDTO{
Name: RenderDiscordUsername(u.User),
ID: u.User.ID,
AvatarURL: u.User.AvatarURL("32"),
}
}
gc.JSON(200, resp)
}
// @Summary Links a Discord account to a Jellyfin account via user IDs. Notifications are turned on by default.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 400 {object} boolResponse
// @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
gc.BindJSON(&req)
if req.JellyfinID == "" || req.DiscordID == "" {
respondBool(400, false, gc)
return
}
user, ok := app.discord.NewUser(req.DiscordID)
if !ok {
respondBool(500, false, gc)
return
}
app.storage.SetDiscordKey(req.JellyfinID, user)
if err := app.js.ModifyNotifications(req.JellyfinID, map[jellyseerr.NotificationsField]any{
jellyseerr.FieldDiscord: req.DiscordID,
jellyseerr.FieldDiscordEnabled: true,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: req.JellyfinID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
}, gc, false)
linkExistingOmbiDiscordTelegram(app)
respondBool(200, true, gc)
}
// @Summary unlink a Discord account from a Jellyfin user. Always succeeds.
// @Produce json
// @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
gc.BindJSON(&req)
/* user, status, err := app.jf.UserByID(req.ID, false)
if req.ID == "" || status != 200 || err != nil {
respond(400, "User not found", gc)
return
} */
app.storage.DeleteDiscordKey(req.ID)
// May not actually remove Discord ID, but should disable interaction.
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier,
jellyseerr.FieldDiscordEnabled: false,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: req.ID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
}, gc, false)
respondBool(200, true, gc)
}
// @Summary unlink a Telegram account from a Jellyfin user. Always succeeds.
// @Produce json
// @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
gc.BindJSON(&req)
/* user, status, err := app.jf.UserByID(req.ID, false)
if req.ID == "" || status != 200 || err != nil {
respond(400, "User not found", gc)
return
} */
app.storage.DeleteTelegramKey(req.ID)
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier,
jellyseerr.FieldTelegramEnabled: false,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: req.ID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
}, gc, false)
respondBool(200, true, gc)
}
// @Summary unlink a Matrix account from a Jellyfin user. Always succeeds.
// @Produce json
// @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
gc.BindJSON(&req)
/* user, status, err := app.jf.UserByID(req.ID, false)
if req.ID == "" || status != 200 || err != nil {
respond(400, "User not found", gc)
return
} */
app.storage.DeleteMatrixKey(req.ID)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: req.ID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
}, gc, false)
respondBool(200, true, gc)
}

223
api-ombi.go Normal file
View File

@@ -0,0 +1,223 @@
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{}, 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, err
}
}
// 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 (ombi *OmbiWrapper) getImportedUser(name string) (map[string]interface{}, error) {
// Ombi User Types: 3/4 = Emby, 5 = Jellyfin
ombiUsers, err := ombi.GetUsers()
if err != nil {
return nil, err
}
for _, ombiUser := range ombiUsers {
if ombiUser["userName"].(string) == name {
uType, ok := ombiUser["userType"].(int)
if !ok { // Don't know if Ombi somehow allows duplicate usernames
continue
}
if serverType == mediabrowser.JellyfinServer && uType != 5 { // Jellyfin
continue
} else if uType != 3 && uType != 4 { // Emby
continue
}
return ombiUser, err
}
}
// Gets a generic "not found" type error
return nil, common.GenericErr(404, err)
}
// @Summary Get a list of Ombi users.
// @Produce json
// @Success 200 {object} ombiUsersDTO
// @Failure 500 {object} stringResponse
// @Router /ombi/users [get]
// @Security Bearer
// @tags Ombi
func (app *appContext) OmbiUsers(gc *gin.Context) {
users, err := app.ombi.GetUsers()
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Ombi, err)
respond(500, "Couldn't get users", gc)
return
}
userlist := make([]ombiUser, len(users))
for i, data := range users {
userlist[i] = ombiUser{
Name: data["userName"].(string),
ID: data["id"].(string),
}
}
gc.JSON(200, ombiUsersDTO{Users: userlist})
}
// @Summary Store Ombi user template in an existing profile.
// @Produce json
// @Param ombiUser body ombiUser true "User to source settings 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/ombi/{profile} [post]
// @Security Bearer
// @tags Ombi
func (app *appContext) SetOmbiProfile(gc *gin.Context) {
var req ombiUser
gc.BindJSON(&req)
escapedProfileName := gc.Param("profile")
profileName, _ := url.QueryUnescape(escapedProfileName)
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respondBool(400, false, gc)
return
}
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
}
profile.Ombi = template
app.storage.SetProfileKey(profileName, profile)
respondBool(204, true, gc)
}
// @Summary Remove ombi 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/ombi/{profile} [delete]
// @Security Bearer
// @tags Ombi
func (app *appContext) DeleteOmbiProfile(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.Ombi = nil
app.storage.SetProfileKey(profileName, profile)
respondBool(204, true, gc)
}
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{}:
user[k] = v
default:
if v != user[k] {
user[k] = v
}
}
}
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)
}

231
api-profiles.go Normal file
View File

@@ -0,0 +1,231 @@
package main
import (
"fmt"
"time"
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/timshannon/badgerhold/v4"
)
// @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) {
out := getProfilesDTO{
DefaultProfile: app.storage.GetDefaultProfile().Name,
Profiles: map[string]profileDTO{},
}
referralsEnabled := app.config.Section("user_page").Key("referrals").MustBool(false)
baseInv := Invite{}
for _, p := range app.storage.GetProfiles() {
pdto := profileDTO{
Admin: p.Admin,
LibraryAccess: p.LibraryAccess,
FromUser: p.FromUser,
Ombi: p.Ombi != nil,
Jellyseerr: p.Jellyseerr.Enabled,
ReferralsEnabled: false,
}
if referralsEnabled {
err := app.storage.db.Get(p.ReferralTemplateKey, &baseInv)
if p.ReferralTemplateKey != "" && err == nil {
pdto.ReferralsEnabled = true
}
}
out.Profiles[p.Name] = pdto
}
gc.JSON(200, out)
}
// @Summary Set the default profile to use.
// @Produce json
// @Param profileChangeDTO body profileChangeDTO true "Default profile object"
// @Success 200 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Router /profiles/default [post]
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) SetDefaultProfile(gc *gin.Context) {
req := profileChangeDTO{}
gc.BindJSON(&req)
app.info.Printf(lm.SetDefaultProfile, req.Name)
if _, ok := app.storage.GetProfileKey(req.Name); !ok {
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 {
if profile.Name == req.Name {
profile.Default = true
} else {
profile.Default = false
}
app.storage.SetProfileKey(profile.Name, *profile)
return nil
})
respondBool(200, true, gc)
}
// @Summary Create a profile based on a Jellyfin user's settings.
// @Produce json
// @Param newProfileDTO body newProfileDTO true "New profile object"
// @Success 200 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Router /profiles [post]
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) CreateProfile(gc *gin.Context) {
var req newProfileDTO
gc.BindJSON(&req)
app.jf.CacheExpiry = time.Now()
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
}
profile := Profile{
FromUser: user.Name,
Policy: user.Policy,
Homescreen: req.Homescreen,
}
app.debug.Printf(lm.CreateProfileFromUser, user.Name)
if req.Homescreen {
profile.Configuration = user.Configuration
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
}
}
app.storage.SetProfileKey(req.Name, profile)
// Refresh discord bots, profile list
if discordEnabled {
app.discord.UpdateCommands()
}
respondBool(200, true, gc)
}
// @Summary Delete an existing profile
// @Produce json
// @Param profileChangeDTO body profileChangeDTO true "Delete profile object"
// @Success 200 {object} boolResponse
// @Router /profiles [delete]
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) DeleteProfile(gc *gin.Context) {
req := profileChangeDTO{}
gc.BindJSON(&req)
name := req.Name
app.storage.DeleteProfileKey(name)
respondBool(200, true, gc)
}
// @Summary Enable referrals for a profile, sourced from the given invite by its code.
// @Produce json
// @Param profile path string true "name of profile to enable referrals for."
// @Param invite path string true "invite code to create referral template from."
// @Param useExpiry path string true "with-expiry or none."
// @Success 200 {object} boolResponse
// @Failure 400 {object} stringResponse
// @Failure 500 {object} stringResponse
// @Router /profiles/referral/{profile}/{invite}/{useExpiry} [post]
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) EnableReferralForProfile(gc *gin.Context) {
profileName := gc.Param("profile")
invCode := gc.Param("invite")
useExpiry := gc.Param("useExpiry") == "with-expiry"
inv, ok := app.storage.GetInvitesKey(invCode)
if !ok {
respond(400, "Invalid invite code", gc)
app.err.Printf(lm.InvalidInviteCode, invCode)
return
}
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respond(400, "Invalid profile", gc)
app.err.Printf(lm.FailedGetProfile, profileName)
return
}
// Generate new code for referral template
inv.Code = GenerateInviteCode()
expiryDelta := inv.ValidTill.Sub(inv.Created)
inv.Created = time.Now()
if useExpiry {
inv.ValidTill = inv.Created.Add(expiryDelta)
} else {
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
}
inv.IsReferral = true
inv.UseReferralExpiry = useExpiry
// Since this is a template for multiple users, ReferrerJellyfinID is not set.
// inv.ReferrerJellyfinID = ...
app.storage.SetInvitesKey(inv.Code, inv)
profile.ReferralTemplateKey = inv.Code
app.storage.SetProfileKey(profile.Name, profile)
respondBool(200, true, gc)
}
// @Summary Disable referrals for a profile, and removes the referral template. no-op if not enabled.
// @Produce json
// @Param profile path string true "name of profile to enable referrals for."
// @Success 200 {object} boolResponse
// @Router /profiles/referral/{profile} [delete]
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) DisableReferralForProfile(gc *gin.Context) {
profileName := gc.Param("profile")
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respondBool(200, true, gc)
return
}
app.storage.DeleteInvitesKey(profile.ReferralTemplateKey)
profile.ReferralTemplateKey = ""
app.storage.SetProfileKey(profileName, profile)
respondBool(200, true, gc)
}

806
api-userpage.go Normal file
View File

@@ -0,0 +1,806 @@
package main
import (
"fmt"
"net/http"
"os"
"strings"
"time"
"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"
)
const (
REFERRAL_EXPIRY_DAYS = 90
)
// @Summary Returns the logged-in user's Jellyfin ID & Username, and other details.
// @Produce json
// @Success 200 {object} MyDetailsDTO
// @Router /my/details [get]
// @tags User Page
func (app *appContext) MyDetails(gc *gin.Context) {
resp := MyDetailsDTO{
Id: gc.GetString("jfId"),
}
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
}
resp.Username = user.Name
resp.Admin = user.Policy.IsAdministrator
resp.AccountsAdmin = false
if !app.config.Section("ui").Key("allow_all").MustBool(false) {
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
if emailStore, ok := app.storage.GetEmailsKey(resp.Id); ok {
resp.AccountsAdmin = emailStore.Admin
}
resp.AccountsAdmin = resp.AccountsAdmin || (adminOnly && resp.Admin)
}
resp.Disabled = user.Policy.IsDisabled
if exp, ok := app.storage.GetUserExpiryKey(user.ID); ok {
resp.Expiry = exp.Expiry.Unix()
}
if emailEnabled {
resp.Email = &MyDetailsContactMethodsDTO{}
if email, ok := app.storage.GetEmailsKey(user.ID); ok && email.Addr != "" {
resp.Email.Value = email.Addr
resp.Email.Enabled = email.Contact
}
}
if discordEnabled {
resp.Discord = &MyDetailsContactMethodsDTO{}
if discord, ok := app.storage.GetDiscordKey(user.ID); ok {
resp.Discord.Value = RenderDiscordUsername(discord)
resp.Discord.Enabled = discord.Contact
}
}
if telegramEnabled {
resp.Telegram = &MyDetailsContactMethodsDTO{}
if telegram, ok := app.storage.GetTelegramKey(user.ID); ok {
resp.Telegram.Value = telegram.Username
resp.Telegram.Enabled = telegram.Contact
}
}
if matrixEnabled {
resp.Matrix = &MyDetailsContactMethodsDTO{}
if matrix, ok := app.storage.GetMatrixKey(user.ID); ok {
resp.Matrix.Value = matrix.UserID
resp.Matrix.Enabled = matrix.Contact
}
}
if app.config.Section("user_page").Key("referrals").MustBool(false) {
// 1. Look for existing template bound to this Jellyfin ID
// If one exists, that means its just for us and so we
// can use it directly.
inv := Invite{}
err := app.storage.db.FindOne(&inv, badgerhold.Where("ReferrerJellyfinID").Eq(resp.Id))
if err == nil {
resp.HasReferrals = true
} else {
// 2. Look for a template matching the key found in the user storage
// Since this key is shared between users in a profile, we make a copy.
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
err = app.storage.db.Get(user.ReferralTemplateKey, &inv)
if ok && err == nil {
resp.HasReferrals = true
}
}
}
gc.JSON(200, resp)
}
// @Summary Sets whether to notify yourself through telegram/discord/matrix/email or not.
// @Produce json
// @Param SetContactMethodsDTO body SetContactMethodsDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
// @Success 200 {object} boolResponse
// @Success 400 {object} boolResponse
// @Success 500 {object} boolResponse
// @Router /my/contact [post]
// @Security Bearer
// @tags User Page
func (app *appContext) SetMyContactMethods(gc *gin.Context) {
var req SetContactMethodsDTO
gc.BindJSON(&req)
req.ID = gc.GetString("jfId")
if req.ID == "" {
respondBool(400, false, gc)
return
}
app.setContactMethods(req, gc)
}
// @Summary Logout by deleting refresh token from cookies.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Router /my/logout [post]
// @Security Bearer
// @tags User Page
func (app *appContext) LogoutUser(gc *gin.Context) {
cookie, err := gc.Cookie("user-refresh")
if err != nil {
msg := fmt.Sprintf(lm.FailedGetCookies, "user-refresh", err)
app.debug.Println(msg)
respond(500, msg, gc)
return
}
app.invalidTokens = append(app.invalidTokens, cookie)
gc.SetCookie("refresh", "invalid", -1, "/my", gc.Request.URL.Hostname(), true, true)
respondBool(200, true, gc)
}
// @Summary confirm an action (e.g. changing an email address.)
// @Produce json
// @Param jwt path string true "jwt confirmation code"
// @Router /my/confirm/{jwt} [post]
// @Success 200 {object} boolResponse
// @Failure 400 {object} stringResponse
// @Failure 404
// @Success 303
// @Failure 500 {object} stringResponse
// @tags User Page
func (app *appContext) ConfirmMyAction(gc *gin.Context) {
app.confirmMyAction(gc, "")
}
func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
var claims jwt.MapClaims
var target ConfirmationTarget
var id string
fail := func() {
app.gcHTML(gc, 404, "404.html", OtherPage, gin.H{
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}
// Validate key
if key == "" {
key = gc.Param("jwt")
}
token, err := jwt.Parse(key, checkToken)
if err != nil {
app.err.Printf(lm.FailedParseJWT, err)
fail()
// respond(500, "unknownError", gc)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
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.Println(lm.InvalidJWT)
fail()
// respond(400, "invalidKey", gc)
return
}
target = ConfirmationTarget(int(claims["target"].(float64)))
id = claims["id"].(string)
// Perform an Action
if target == NoOp {
gc.Redirect(http.StatusSeeOther, PAGES.MyAccount)
return
} else if target == UserEmailChange {
app.modifyEmail(id, claims["email"].(string))
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "email",
Time: time.Now(),
}, gc, true)
app.info.Printf(lm.UserEmailAdjusted, gc.GetString("jfId"))
gc.Redirect(http.StatusSeeOther, PAGES.MyAccount)
return
}
}
// @Summary Modify your email address.
// @Produce json
// @Param ModifyMyEmailDTO body ModifyMyEmailDTO true "New email address."
// @Success 200 {object} boolResponse
// @Failure 400 {object} stringResponse
// @Failure 401 {object} stringResponse
// @Failure 500 {object} stringResponse
// @Router /my/email [post]
// @Security Bearer
// @tags User Page
func (app *appContext) ModifyMyEmail(gc *gin.Context) {
var req ModifyMyEmailDTO
gc.BindJSON(&req)
if !strings.ContainsRune(req.Email, '@') {
respond(400, "Invalid Email Address", gc)
return
}
id := gc.GetString("jfId")
// We'll use the ConfirmMyAction route to do the work, even if we don't need to confirm the address.
claims := jwt.MapClaims{
"valid": true,
"id": id,
"email": req.Email,
"type": "confirmation",
"target": UserEmailChange,
"exp": time.Now().Add(time.Hour).Unix(),
}
tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET")))
if err != nil {
app.err.Printf(lm.FailedSignJWT, err)
respond(500, "errorUnknown", gc)
return
}
if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) {
user, err := app.jf.UserByID(id, false)
name := ""
if err == nil {
name = user.Name
}
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(lm.FailedConstructConfirmationEmail, id, err)
} else if err := app.email.send(msg, req.Email); err != nil {
app.err.Printf(lm.FailedSendConfirmationEmail, id, req.Email, err)
} else {
app.err.Printf(lm.SentConfirmationEmail, id, req.Email)
}
return
}
app.confirmMyAction(gc, key)
return
}
// @Summary Returns a 10-minute, one-use Discord server invite
// @Produce json
// @Success 200 {object} DiscordInviteDTO
// @Failure 400 {object} boolResponse
// @Failure 401 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Param invCode path string true "invite Code"
// @Router /my/discord/invite [get]
// @Security Bearer
// @tags User Page
func (app *appContext) MyDiscordServerInvite(gc *gin.Context) {
if app.discord.InviteChannel.Name == "" {
respondBool(400, false, gc)
return
}
invURL, iconURL := app.discord.NewTempInvite(10*60, 1)
if invURL == "" {
respondBool(500, false, gc)
return
}
gc.JSON(200, DiscordInviteDTO{invURL, iconURL})
}
// @Summary Returns a linking PIN for discord/telegram
// @Produce json
// @Success 200 {object} GetMyPINDTO
// @Failure 400 {object} stringResponse
// Param service path string true "discord/telegram"
// @Router /my/pin/{service} [get]
// @Security Bearer
// @tags User Page
func (app *appContext) GetMyPIN(gc *gin.Context) {
service := gc.Param("service")
resp := GetMyPINDTO{}
switch service {
case "discord":
resp.PIN = app.discord.NewAssignedAuthToken(gc.GetString("jfId"))
break
case "telegram":
resp.PIN = app.telegram.NewAssignedAuthToken(gc.GetString("jfId"))
break
default:
respond(400, "invalid service", gc)
return
}
gc.JSON(200, resp)
}
// @Summary Returns true/false on whether or not your discord PIN was verified, and assigns the discord user to you.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 401 {object} boolResponse
// @Param pin path string true "PIN code to check"
// @Router /my/discord/verified/{pin} [get]
// @Security Bearer
// @tags User Page
func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
pin := gc.Param("pin")
dcUser, ok := app.discord.AssignedUserVerified(pin, gc.GetString("jfId"))
app.discord.DeleteVerifiedToken(pin)
if !ok {
respondBool(200, false, gc)
return
}
if app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(dcUser.ID) {
respondBool(400, false, gc)
return
}
existingUser, ok := app.storage.GetDiscordKey(gc.GetString("jfId"))
if ok {
dcUser.Lang = existingUser.Lang
dcUser.Contact = existingUser.Contact
}
app.storage.SetDiscordKey(gc.GetString("jfId"), dcUser)
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldDiscord: dcUser.ID,
jellyseerr.FieldDiscordEnabled: dcUser.Contact,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
}, gc, true)
respondBool(200, true, gc)
}
// @Summary Returns true/false on whether or not your telegram PIN was verified, and assigns the telegram user to you.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 401 {object} boolResponse
// @Param pin path string true "PIN code to check"
// @Router /my/telegram/verified/{pin} [get]
// @Security Bearer
// @tags User Page
func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
pin := gc.Param("pin")
token, ok := app.telegram.AssignedTokenVerified(pin, gc.GetString("jfId"))
app.telegram.DeleteVerifiedToken(pin)
if !ok {
respondBool(200, false, gc)
return
}
if app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(token.Username) {
respondBool(400, false, gc)
return
}
tgUser := TelegramUser{
ChatID: token.ChatID,
Username: token.Username,
Contact: true,
}
if lang, ok := app.telegram.languages[tgUser.ChatID]; ok {
tgUser.Lang = lang
}
existingUser, ok := app.storage.GetTelegramKey(gc.GetString("jfId"))
if ok {
tgUser.Lang = existingUser.Lang
tgUser.Contact = existingUser.Contact
}
app.storage.SetTelegramKey(gc.GetString("jfId"), tgUser)
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldTelegram: tgUser.ChatID,
jellyseerr.FieldTelegramEnabled: tgUser.Contact,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
}, gc, true)
respondBool(200, true, gc)
}
// @Summary Generate and send a new PIN to your given matrix user.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 400 {object} stringResponse
// @Failure 401 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Param MatrixSendPINDTO body MatrixSendPINDTO true "User's Matrix ID."
// @Router /my/matrix/user [post]
// @Security Bearer
// @tags User Page
func (app *appContext) MatrixSendMyPIN(gc *gin.Context) {
var req MatrixSendPINDTO
gc.BindJSON(&req)
if req.UserID == "" {
respond(400, "errorNoUserID", gc)
return
}
if app.config.Section("matrix").Key("require_unique").MustBool(false) {
for _, u := range app.storage.GetMatrix() {
if req.UserID == u.UserID {
respondBool(400, false, gc)
return
}
}
}
ok := app.matrix.SendStart(req.UserID)
if !ok {
respondBool(500, false, gc)
return
}
respondBool(200, true, gc)
}
// @Summary Check whether your matrix PIN is valid, and link the account to yours if so.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 401 {object} boolResponse
// @Param pin path string true "PIN code to check"
// @Param invCode path string true "invite Code"
// @Param userID path string true "Matrix User ID"
// @Router /my/matrix/verified/{userID}/{pin} [get]
// @Security Bearer
// @tags User Page
func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
userID := gc.Param("userID")
pin := gc.Param("pin")
user, ok := app.matrix.tokens[pin]
if !ok {
app.debug.Printf(lm.InvalidPIN, pin)
respondBool(200, false, gc)
return
}
if user.User.UserID != userID {
app.debug.Printf(lm.UnauthorizedPIN, pin)
respondBool(200, false, gc)
return
}
mxUser := *user.User
mxUser.Contact = true
existingUser, ok := app.storage.GetMatrixKey(gc.GetString("jfId"))
if ok {
mxUser.Lang = existingUser.Lang
mxUser.Contact = existingUser.Contact
}
app.storage.SetMatrixKey(gc.GetString("jfId"), mxUser)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
}, gc, true)
delete(app.matrix.tokens, pin)
respondBool(200, true, gc)
}
// @Summary unlink the Discord account from your Jellyfin user. Always succeeds.
// @Produce json
// @Success 200 {object} boolResponse
// @Router /my/discord [delete]
// @Security Bearer
// @Tags User Page
func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
app.storage.DeleteDiscordKey(gc.GetString("jfId"))
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier,
jellyseerr.FieldDiscordEnabled: false,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
}, gc, true)
respondBool(200, true, gc)
}
// @Summary unlink the Telegram account from your Jellyfin user. Always succeeds.
// @Produce json
// @Success 200 {object} boolResponse
// @Router /my/telegram [delete]
// @Security Bearer
// @Tags User Page
func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
app.storage.DeleteTelegramKey(gc.GetString("jfId"))
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier,
jellyseerr.FieldTelegramEnabled: false,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
}, gc, true)
respondBool(200, true, gc)
}
// @Summary unlink the Matrix account from your Jellyfin user. Always succeeds.
// @Produce json
// @Success 200 {object} boolResponse
// @Router /my/matrix [delete]
// @Security Bearer
// @Tags User Page
func (app *appContext) UnlinkMyMatrix(gc *gin.Context) {
app.storage.DeleteMatrixKey(gc.GetString("jfId"))
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: gc.GetString("jfId"),
SourceType: ActivityUser,
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
}, gc, true)
respondBool(200, true, gc)
}
// @Summary Generate & send a password reset link if the given username/email/contact method exists. Doesn't give you any info about it's success.
// @Produce json
// @Param address path string true "address/contact method associated w/ your account."
// @Success 204 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Router /my/password/reset/{address} [post]
// @Tags User Page
func (app *appContext) ResetMyPassword(gc *gin.Context) {
// All requests should take 1 second, to make it harder to tell if a success occured or not.
timerWait := make(chan bool)
cancel := time.AfterFunc(1*time.Second, func() {
timerWait <- true
})
usernameAllowed := app.config.Section("user_page").Key("allow_pwr_username").MustBool(true)
emailAllowed := app.config.Section("user_page").Key("allow_pwr_email").MustBool(true)
contactMethodAllowed := app.config.Section("user_page").Key("allow_pwr_contact_method").MustBool(true)
address := gc.Param("address")
if address == "" {
cancel.Stop()
respondBool(400, false, gc)
return
}
var pwr InternalPWR
var err error
jfUser, ok := app.ReverseUserSearch(address, usernameAllowed, emailAllowed, contactMethodAllowed)
if !ok {
app.debug.Printf(lm.FailedGetUsers, lm.Jellyfin, "no results")
for range timerWait {
respondBool(204, true, gc)
return
}
return
}
pwr, err = app.GenInternalReset(jfUser.ID)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
for range timerWait {
respondBool(204, true, gc)
return
}
return
}
if app.internalPWRs == nil {
app.internalPWRs = map[string]InternalPWR{}
}
app.internalPWRs[pwr.PIN] = pwr
// FIXME: Send to all contact methods
msg, err := app.email.constructReset(
PasswordReset{
Pin: pwr.PIN,
Username: pwr.Username,
Expiry: pwr.Expiry,
Internal: true,
}, app, false,
)
if err != nil {
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(lm.FailedSendPWRMessage, pwr.Username, "?", err)
} else {
app.info.Printf(lm.SentPWRMessage, pwr.Username, "?")
}
for range timerWait {
respondBool(204, true, gc)
return
}
}
// @Summary Change your password, given the old one and the new one.
// @Produce json
// @Param ChangeMyPasswordDTO body ChangeMyPasswordDTO true "User's old & new passwords."
// @Success 204 {object} boolResponse
// @Failure 400 {object} PasswordValidation
// @Failure 401 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Router /my/password [post]
// @Security Bearer
// @Tags User Page
func (app *appContext) ChangeMyPassword(gc *gin.Context) {
var req ChangeMyPasswordDTO
gc.BindJSON(&req)
if req.Old == "" || req.New == "" {
respondBool(400, false, gc)
}
validation := app.validator.validate(req.New)
for _, val := range validation {
if !val {
gc.JSON(400, validation)
return
}
}
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, err = app.authJf.Authenticate(user.Name, req.Old)
if err != nil {
respondBool(401, false, gc)
return
}
err = app.jf.SetPassword(gc.GetString("jfId"), req.Old, req.New)
if err != nil {
respondBool(500, false, gc)
return
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityChangePassword,
UserID: user.ID,
SourceType: ActivityUser,
Source: user.ID,
Time: time.Now(),
}, gc, true)
if app.config.Section("ombi").Key("enabled").MustBool(false) {
func() {
ombiUser, err := app.getOmbiUser(gc.GetString("jfId"))
if err != nil {
app.err.Printf(lm.FailedGetUser, user.Name, lm.Ombi, err)
return
}
ombiUser["password"] = req.New
err = app.ombi.ModifyUser(ombiUser)
if err != nil {
app.err.Printf(lm.FailedChangePassword, lm.Ombi, ombiUser["userName"], err)
return
}
app.debug.Printf(lm.ChangePassword, lm.Ombi, ombiUser["userName"])
}()
}
cookie, err := gc.Cookie("user-refresh")
if err == nil {
app.invalidTokens = append(app.invalidTokens, cookie)
gc.SetCookie("refresh", "invalid", -1, "/my", gc.Request.URL.Hostname(), true, true)
} else {
app.debug.Printf(lm.FailedGetCookies, "user-refresh", err)
}
respondBool(204, true, gc)
}
// @Summary Get or generate a new referral code.
// @Produce json
// @Success 200 {object} GetMyReferralRespDTO
// @Failure 400 {object} boolResponse
// @Failure 401 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Router /my/referral [get]
// @Security Bearer
// @Tags User Page
func (app *appContext) GetMyReferral(gc *gin.Context) {
// 1. Look for existing template bound to this Jellyfin ID
// If one exists, that means its just for us and so we
// can use it directly.
inv := Invite{}
err := app.storage.db.FindOne(&inv, badgerhold.Where("ReferrerJellyfinID").Eq(gc.GetString("jfId")))
if err != nil {
// 2. Look for a template matching the key found in the user storage
// Since this key is shared between users in a profile, we make a copy.
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
err = app.storage.db.Get(user.ReferralTemplateKey, &inv)
if !ok || err != nil || user.ReferralTemplateKey == "" {
app.debug.Printf(lm.FailedGetReferralTemplate, user.ReferralTemplateKey, err)
respondBool(400, false, gc)
return
}
inv.Code = GenerateInviteCode()
expiryDelta := inv.ValidTill.Sub(inv.Created)
inv.Created = time.Now()
if inv.UseReferralExpiry {
inv.ValidTill = inv.Created.Add(expiryDelta)
} else {
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
}
inv.IsReferral = true
inv.ReferrerJellyfinID = gc.GetString("jfId")
app.storage.SetInvitesKey(inv.Code, inv)
} else if time.Now().After(inv.ValidTill) {
// 3. We found an invite for us, but it's expired.
// We delete it from storage, and put it back with a fresh code and expiry.
// If UseReferralExpiry is enabled, we delete it and return nothing.
app.storage.DeleteInvitesKey(inv.Code)
if inv.UseReferralExpiry {
app.debug.Printf(lm.DeleteOldReferral, inv.Code)
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
if ok {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(gc.GetString("jfId"), user)
}
app.debug.Printf("Ignoring referral request, expired.")
respondBool(400, false, gc)
return
}
app.debug.Printf(lm.RenewOldReferral, inv.Code)
inv.Code = GenerateInviteCode()
inv.Created = time.Now()
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
app.storage.SetInvitesKey(inv.Code, inv)
}
gc.JSON(200, GetMyReferralRespDTO{
Code: inv.Code,
RemainingUses: inv.RemainingUses,
NoLimit: inv.NoLimit,
Expiry: inv.ValidTill.Unix(),
UseExpiry: inv.UseReferralExpiry,
})
}

1294
api-users.go Normal file

File diff suppressed because it is too large Load Diff

2627
api.go

File diff suppressed because it is too large Load Diff

17
args.go
View File

@@ -23,6 +23,7 @@ func (app *appContext) loadArgs(firstCall bool) {
HOST = flag.String("host", "", "alternate address to host web ui on.")
PORT = flag.Int("port", 0, "alternate port to host web ui on.")
flag.IntVar(PORT, "p", 0, "SHORTHAND")
_LOADBAK = flag.String("restore", "", "path to database backup to restore.")
DEBUG = flag.Bool("debug", false, "Enables debug logging.")
PPROF = flag.Bool("pprof", false, "Exposes pprof profiler on /debug/pprof.")
SWAGGER = flag.Bool("swagger", false, "Enable swagger at /swagger/index.html")
@@ -41,6 +42,9 @@ func (app *appContext) loadArgs(firstCall bool) {
if *PPROF {
os.Setenv("PPROF", "1")
}
if *_LOADBAK != "" {
LOADBAK = *_LOADBAK
}
}
if os.Getenv("SWAGGER") == "1" {
@@ -75,18 +79,23 @@ func (app *appContext) loadArgs(firstCall bool) {
os.Setenv("JFA_DATAPATH", app.dataPath)
}
/* Adds start/stop/systemd to help message, and
/*
Adds start/stop/systemd to help message, and
also gets rid of usage for shorthand flags, and merge them with the full-length one.
implementation is 🤢, will clean this up eventually.
-h SHORTHAND
-help
prints this message.
becomes:
-help, -h
prints this message.
*/
func helpFunc() {
fmt.Fprint(os.Stderr, `Usage of jfa-go:
fmt.Fprint(stderr, `Usage of jfa-go:
start
start jfa-go as a daemon and run in the background.
stop
@@ -99,7 +108,7 @@ func helpFunc() {
// Write defaults into buffer then remove any shorthands
flag.CommandLine.SetOutput(&b)
flag.PrintDefaults()
flag.CommandLine.SetOutput(os.Stderr)
flag.CommandLine.SetOutput(stderr)
scanner := bufio.NewScanner(&b)
out := ""
line := scanner.Text()
@@ -150,5 +159,5 @@ func helpFunc() {
lastLine = true
}
}
fmt.Fprint(os.Stderr, out)
fmt.Fprint(stderr, out)
}

278
auth.go
View File

@@ -2,6 +2,7 @@ package main
import (
"encoding/base64"
"errors"
"fmt"
"os"
"strings"
@@ -9,21 +10,50 @@ 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"
)
const (
TOKEN_VALIDITY_SEC = 20 * 60
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) (string, string, error) {
func CreateToken(userId, jfId string, admin bool) (string, string, error) {
var token, refresh string
claims := jwt.MapClaims{
"valid": true,
"id": userId,
"exp": time.Now().Add(time.Minute * 20).Unix(),
"exp": time.Now().Add(time.Second * TOKEN_VALIDITY_SEC).Unix(),
"jfid": jfId,
"admin": admin,
"type": "bearer",
}
tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
@@ -31,7 +61,7 @@ func CreateToken(userId, jfId string) (string, string, error) {
if err != nil {
return "", "", err
}
claims["exp"] = time.Now().Add(time.Hour * 24).Unix()
claims["exp"] = time.Now().Add(time.Second * REFRESH_TOKEN_VALIDITY_SEC).Unix()
claims["type"] = "refresh"
tk = jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
refresh, err = tk.SignedString([]byte(os.Getenv("JFA_SECRET")))
@@ -41,50 +71,70 @@ func CreateToken(userId, jfId string) (string, string, error) {
return token, refresh, nil
}
// Check header for token
func (app *appContext) authenticate(gc *gin.Context) {
// Caller should return if this returns false.
func (app *appContext) decodeValidateAuthHeader(gc *gin.Context) (claims jwt.MapClaims, ok bool) {
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.authLog(lm.FailedCastJWT)
respond(401, "Unauthorized", gc)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
expiryUnix := int64(claims["exp"].(float64))
if err != nil {
app.debug.Printf("Auth denied: %s", err)
respond(401, "Unauthorized", gc)
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
return
}
ok = true
return
}
// Check header for token
func (app *appContext) authenticate(gc *gin.Context) {
claims, ok := app.decodeValidateAuthHeader(gc)
if !ok {
return
}
isAdminToken := claims["admin"].(bool)
if !isAdminToken {
app.authLog(lm.NonAdminToken)
respond(401, "Unauthorized", gc)
return
}
userID := claims["id"].(string)
jfID := claims["jfid"].(string)
match := false
for _, user := range app.users {
for _, user := range app.adminUsers {
if user.UserID == userID {
match = true
break
}
}
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)
app.debug.Println("Auth succeeded")
gc.Set("userMode", false)
gc.Next()
}
@@ -99,6 +149,68 @@ type getTokenDTO struct {
Token string `json:"token" example:"kjsdklsfdkljfsjsdfklsdfkldsfjdfskjsdfjklsdf"` // API token for use with everything else.
}
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)
username = creds[0]
password = creds[1]
ok = false
if username == "" || password == "" {
app.logIpDebug(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.EmptyUserOrPass))
respond(401, "Unauthorized", gc)
return
}
ok = true
return
}
func (app *appContext) canAccessAdminPage(user mediabrowser.User, emailStore EmailAddress) bool {
// 1. "Allow all" is enabled, so simply being a user implies access.
if app.config.Section("ui").Key("allow_all").MustBool(false) && user.ID != "" {
return true
}
// 2. You've been made an "accounts admin" from the accounts tab.
if emailStore.Admin {
return true
}
// 3. (Jellyfin) "Admins only" is enabled, and you're one.
if app.config.Section("ui").Key("admin_only").MustBool(true) && user.ID != "" && user.Policy.IsAdministrator {
return true
}
return false
}
func (app *appContext) canAccessAdminPageByID(jfID string) bool {
user, err := app.jf.UserByID(jfID, false)
if err != nil {
return false
}
emailStore, _ := app.storage.GetEmailsKey(jfID)
return app.canAccessAdminPage(user, emailStore)
}
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context, userpage bool) (user mediabrowser.User, ok bool) {
ok = false
user, err := app.authJf.Authenticate(username, password)
if err != nil {
if errors.As(err, &mediabrowser.ErrUnauthorized{}) {
app.logIpInfo(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.InvalidUserOrPass))
respond(401, "Unauthorized", gc)
return
} else if errors.As(err, &mediabrowser.ErrForbidden{}) {
app.logIpInfo(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.UserDisabled))
respond(403, "yourAccountWasDisabled", gc)
return
}
app.authLog(fmt.Sprintf(lm.FailedAuthJellyfin, app.jf.Server, 0, err))
respond(500, "Jellyfin error", gc)
return
}
ok = true
return
}
// @Summary Grabs an API token using username & password.
// @description If viewing docs locally, click the lock icon next to this, login with your normal jfa-go credentials. Click 'try it out', then 'execute' and an API Key will be returned, copy it (not including quotes). On any of the other routes, click the lock icon and set the API key as "Bearer `your api key`".
// @Produce json
@@ -108,68 +220,94 @@ type getTokenDTO struct {
// @tags Auth
// @Security getTokenAuth
func (app *appContext) getTokenLogin(gc *gin.Context) {
app.info.Println("Token requested (login attempt)")
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
auth, _ := base64.StdEncoding.DecodeString(header[1])
creds := strings.SplitN(string(auth), ":", 2)
var userID, jfID string
if creds[0] == "" || creds[1] == "" {
app.debug.Println("Auth denied: blank username/password")
respond(401, "Unauthorized", gc)
app.logIpInfo(gc, false, fmt.Sprintf(lm.RequestingToken, lm.TokenLoginAttempt))
username, password, ok := app.decodeValidateLoginHeader(gc, false)
if !ok {
return
}
var userID, jfID string
match := false
for _, user := range app.users {
if user.Username == creds[0] && user.Password == creds[1] {
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, status, err := app.authJf.Authenticate(creds[0], creds[1])
if status != 200 || err != nil {
if status == 401 || status == 400 {
app.info.Println("Auth denied: Invalid username/password (Jellyfin)")
respond(401, "Unauthorized", gc)
return
}
app.err.Printf("Auth failed: Couldn't authenticate with Jellyfin (%d/%s)", status, err)
respond(500, "Jellyfin error", gc)
user, ok := app.validateJellyfinCredentials(username, password, gc, false)
if !ok {
return
}
jfID = user.ID
if app.config.Section("ui").Key("admin_only").MustBool(true) {
if !user.Policy.IsAdministrator {
app.debug.Printf("Auth denied: Users \"%s\" isn't admin", creds[0])
respond(401, "Unauthorized", gc)
return
}
emailStore, _ := app.storage.GetEmailsKey(jfID)
accountsAdmin := app.canAccessAdminPage(user, emailStore)
if !accountsAdmin {
app.authLog(fmt.Sprintf(lm.NonAdminUser, username))
respond(401, "Unauthorized", gc)
return
}
// New users are only added when using jellyfinLogin.
userID = shortuuid.New()
newUser := User{
UserID: userID,
}
app.debug.Printf("Token generated for user \"%s\"", creds[0])
app.users = append(app.users, newUser)
app.debug.Printf(lm.GenerateToken, username)
app.adminUsers = append(app.adminUsers, newUser)
}
token, refresh, err := CreateToken(userID, jfID)
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})
}
func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName string) (claims jwt.MapClaims, ok bool) {
ok = false
cookie, err := gc.Cookie(cookieName)
if err != nil || cookie == "" {
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.authLog(lm.LocallyInvalidatedJWT)
respond(401, lm.InvalidJWT, gc)
return
}
}
token, err := jwt.Parse(cookie, checkToken)
if err != nil {
app.authLog(fmt.Sprintf(lm.FailedParseJWT, err))
respond(400, lm.InvalidJWT, gc)
return
}
claims, ok = token.Claims.(jwt.MapClaims)
expiryUnix := int64(claims["exp"].(float64))
expiry := time.Unix(expiryUnix, 0)
if !(ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now())) {
app.authLog(lm.InvalidJWT)
respond(401, lm.InvalidJWT, gc)
ok = false
return
}
ok = true
return
}
// @Summary Grabs an API token using a refresh token from cookies.
// @Produce json
// @Success 200 {object} getTokenDTO
@@ -177,47 +315,21 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
// @Router /token/refresh [get]
// @tags Auth
func (app *appContext) getTokenRefresh(gc *gin.Context) {
app.debug.Println("Token requested (refresh token)")
cookie, err := gc.Cookie("refresh")
if err != nil || cookie == "" {
app.debug.Printf("getTokenRefresh denied: Couldn't get token: %s", 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)
return
}
}
token, err := jwt.Parse(cookie, checkToken)
if err != nil {
app.debug.Println("getTokenRefresh: Invalid token")
respond(400, "Invalid token", 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)
return
}
expiry := time.Unix(expiryUnix, 0)
if !(ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now())) {
app.debug.Printf("getTokenRefresh: Invalid token: %s", err)
respond(401, "Invalid token", gc)
app.logIpInfo(gc, false, fmt.Sprintf(lm.RequestingToken, lm.TokenRefresh))
claims, ok := app.decodeValidateRefreshCookie(gc, "refresh")
if !ok {
return
}
userID := claims["id"].(string)
jfID := claims["jfid"].(string)
jwt, refresh, err := CreateToken(userID, jfID)
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, (3600 * 24), "/", 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})
}

302
backups.go Normal file
View File

@@ -0,0 +1,302 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
lm "github.com/hrfee/jfa-go/logmessages"
)
const (
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
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.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.info[i].Date.IsZero() {
return false
}
if bl.info[j].Date.IsZero() {
return true
}
// Sort by oldest first
return bl.info[j].Date.After(bl.info[i].Date)
}
// Get human-readable file size from f.Size() result.
// https://programming.guide/go/formatting-byte-size-to-human-readable-format.html
func fileSize(l int64) string {
const unit = 1000
if l < unit {
return fmt.Sprintf("%dB", l)
}
div, exp := int64(unit), 0
for n := l / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f%c", float64(l)/float64(div), "KMGTPE"[exp])
}
func (app *appContext) getBackups() *BackupList {
path := app.config.Section("backups").Key("path").String()
err := os.MkdirAll(path, 0755)
if err != nil {
app.err.Printf(lm.FailedCreateDir, path, err)
return nil
}
items, err := os.ReadDir(path)
if err != nil {
app.err.Printf(lm.FailedReading, path, err)
return nil
}
backups := &BackupList{}
backups.files = 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
}
b := Backup{}
if err := b.FromString(item.Name()); err != nil {
app.debug.Printf(lm.FailedParseBackup, item.Name(), err)
continue
}
backups.info[i] = b
backups.count++
}
return backups
}
func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) {
toKeep := app.config.Section("backups").Key("keep_n_backups").MustInt(20)
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 {
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())
err := os.Remove(fullpath)
if err != nil {
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(lm.FailedOpen, fullpath, err)
return
}
defer f.Close()
_, err = app.storage.db.Badger().Backup(f, 0)
if err != nil {
app.err.Printf(lm.FailedCreateBackup, err)
return
}
fstat, err := f.Stat()
if err != nil {
app.err.Printf(lm.FailedStat, fullpath, err)
return
}
fileDetails.Size = fileSize(fstat.Size())
fileDetails.Name = fname
fileDetails.Path = fullpath
app.debug.Printf(lm.CreateBackup, fileDetails)
return
}
func (app *appContext) loadPendingBackup() {
if LOADBAK == "" {
return
}
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(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(lm.FailedOpen, LOADBAK, err)
}
err = app.storage.db.Badger().Load(f, 256)
f.Close()
if err != nil {
app.err.Fatalf(lm.FailedRestoreDB, LOADBAK, err)
}
app.info.Printf(lm.RestoreDB, LOADBAK)
LOADBAK = ""
}
func newBackupDaemon(app *appContext) *GenericDaemon {
interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute
d := NewGenericDaemon(interval, app,
func(app *appContext) {
app.makeBackup()
},
)
d.Name("Backup")
return d
}

57
backups_test.go Normal file
View File

@@ -0,0 +1,57 @@
package main
import (
"testing"
"time"
)
func testBackupParse(f string, a Backup, t *testing.T) {
b := Backup{}
err := b.FromString(f)
if err != nil {
t.Fatalf("error: %+v", err)
}
if !b.Equals(a) {
t.Fatalf("not equal: %+v != %+v", b, a)
}
}
func TestBackupParserOld(t *testing.T) {
Q1 := BACKUP_PREFIX + "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

258
config.go
View File

@@ -3,11 +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"
)
@@ -17,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:") {
@@ -30,29 +38,88 @@ 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 FixFullURL(v string) string {
if !strings.HasPrefix(v, "http://") && !strings.HasPrefix(v, "https://") {
v = "http://" + v
}
return v
}
func FormatSubpath(path string) string {
if path == "/" {
return ""
}
return strings.TrimSuffix(path, "/")
}
func (app *appContext) MustCorrectURL(section, key, value string) {
v := app.config.Section(section).Key(key).String()
if v == "" {
v = value
}
v = FixFullURL(v)
app.config.Section(section).Key(key).SetValue(v)
}
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
}
app.MustSetValue("jellyfin", "public_server", app.config.Section("jellyfin").Key("server").String())
// 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("ui", "redirect_url", app.config.Section("jellyfin").Key("public_server").String())
app.MustCorrectURL("jellyfin", "server", "")
app.MustCorrectURL("jellyfin", "public_server", app.config.Section("jellyfin").Key("server").String())
app.MustCorrectURL("ui", "redirect_url", app.config.Section("jellyfin").Key("public_server").String())
for _, key := range app.config.Section("files").Keys() {
if name := key.Name(); name != "html_templates" && name != "lang_files" {
key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json"))))
}
}
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users", "announcements"} {
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users", "announcements", "custom_user_page_content"} {
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json"))))
}
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")
@@ -75,11 +142,14 @@ 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")
jfUrl := app.config.Section("jellyfin").Key("server").String()
if !(strings.HasPrefix(jfUrl, "http://") || strings.HasPrefix(jfUrl, "https://")) {
app.config.Section("jellyfin").Key("server").SetValue("http://" + jfUrl)
}
app.MustSetValue("activity_log", "keep_n_records", "1000")
app.MustSetValue("activity_log", "delete_after_days", "90")
sc := app.config.Section("discord").Key("start_command").MustString("start")
app.config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
// Deletion template is good enough for these as well.
app.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html")
@@ -97,6 +167,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")
@@ -104,15 +179,43 @@ func (app *appContext) loadConfig() error {
app.MustSetValue("telegram", "show_on_reg", "true")
app.MustSetValue("backups", "every_n_minutes", "1440")
app.MustSetValue("backups", "path", filepath.Join(app.dataPath, "backups"))
app.MustSetValue("backups", "keep_n_backups", "20")
app.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)
app.MustSetValue("jellyfin", "cache_timeout", "30")
app.MustSetValue("jellyfin", "web_cache_async_timeout", "1")
app.MustSetValue("jellyfin", "web_cache_sync_timeout", "10")
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
for _, v := range pwrMethods {
if app.config.Section("user_page").Key(v).MustBool(true) {
allDisabled = false
}
}
if allDisabled {
app.info.Println(lm.EnableAllPWRMethods)
for _, v := range pwrMethods {
app.config.Section("user_page").Key(v).SetValue("true")
}
}
messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false)
telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false)
@@ -132,6 +235,28 @@ func (app *appContext) loadConfig() error {
messagesEnabled = false
}
if app.proxyEnabled = app.config.Section("advanced").Key("proxy").MustBool(false); app.proxyEnabled {
app.proxyConfig = easyproxy.ProxyConfig{}
app.proxyConfig.Protocol = easyproxy.HTTP
if strings.Contains(app.config.Section("advanced").Key("proxy_protocol").MustString("http"), "socks") {
app.proxyConfig.Protocol = easyproxy.SOCKS5
}
app.proxyConfig.Addr = app.config.Section("advanced").Key("proxy_address").MustString("")
app.proxyConfig.User = app.config.Section("advanced").Key("proxy_user").MustString("")
app.proxyConfig.Password = app.config.Section("advanced").Key("proxy_password").MustString("")
app.proxyTransport, err = easyproxy.NewTransport(app.proxyConfig)
if err != nil {
app.err.Printf(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.MustSetValue("updates", "enabled", "true")
releaseChannel := app.config.Section("updates").Key("channel").String()
if app.config.Section("updates").Key("enabled").MustBool(false) {
@@ -144,6 +269,9 @@ func (app *appContext) loadConfig() error {
v = "git"
}
app.updater = newUpdater(baseURL, namespace, repo, v, commit, updater)
if app.proxyEnabled {
app.updater.SetTransport(app.proxyTransport)
}
}
if releaseChannel == "" {
if version == "git" {
@@ -154,9 +282,6 @@ func (app *appContext) loadConfig() error {
app.MustSetValue("updates", "channel", releaseChannel)
}
app.storage.customEmails_path = app.config.Section("files").Key("custom_emails").String()
app.storage.loadCustomEmails()
substituteStrings = app.config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("")
if substituteStrings != "" {
@@ -166,11 +291,11 @@ func (app *appContext) loadConfig() error {
oldFormLang := app.config.Section("ui").Key("language").MustString("")
if oldFormLang != "" {
app.storage.lang.chosenFormLang = oldFormLang
app.storage.lang.chosenUserLang = oldFormLang
}
newFormLang := app.config.Section("ui").Key("language-form").MustString("")
if newFormLang != "" {
app.storage.lang.chosenFormLang = newFormLang
app.storage.lang.chosenUserLang = newFormLang
}
app.storage.lang.chosenAdminLang = app.config.Section("ui").Key("language-admin").MustString("en-us")
app.storage.lang.chosenEmailLang = app.config.Section("email").Key("language").MustString("en-us")
@@ -181,3 +306,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

1652
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

@@ -1,18 +1,25 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "remixicon.css";
@import "./modal.css";
@import "./dark.css";
@import "./tooltip.css";
@import "./loader.css";
@import "./fonts.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--border-width-default: 2px;
--border-width-2: 3px;
--border-width-4: 5px;
--border-width-8: 8px;
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
--bg-light: #fff;
--bg-dark: #101010;
color-scheme: light;
}
.light {
@@ -21,23 +28,33 @@
.dark {
--settings-section-button-filter: 80%;
color-scheme: dark !important;
}
.dark body {
background-color: #101010;
background-color: var(--bg-dark);
}
html:not(.dark) body {
background-color: var(--bg-light);
}
.dark select, .dark option, .dark input {
background: #202020;
}
html:not(.dark) .card.\@low:not(.\~neutral):not(.\~positive):not(.\~urge):not(.\~warning):not(.\~info):not(.\~critical) {
--color-fill: va(--color-fill);
--color-content: var(--color-content);
--color-accent: var(--color-accent);
--color-muted: var(--color-muted);
html:not(.dark) .card.\@low:not(.\~neutral):not(.\~positive):not(.\~urge):not(.\~warning):not(.\~info):not(.\~critical),
html:not(.dark) .card.\@low:not(.\~neutral):not(.\~positive):not(.\~urge):not(.\~warning):not(.\~info):not(.\~critical) > * {
/* Colors from ~neutral */
--color-fill-high: #64748b;
--color-fill-low: #e2e8f0;
--color-content-high: #f8fafc;
--color-content-low: #1e293b;
--color-accent-high: #475569;
--color-accent-low: #cbd5e1;
--color-muted-high: #475569;
--color-muted-low: #f1f5f9;
background-color: #fff;
color: inherit;
}
.light-only {
@@ -48,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;
}
@@ -88,66 +94,10 @@ html:not(.dark) .card.\@low:not(.\~neutral):not(.\~positive):not(.\~urge):not(.\
padding: var(--spacing-4,1rem);
}
.modal-content .banner {
margin-left: calc(-1 * var(--spacing-4,1rem) - 0.5%); /* Not sure why this is necessary */
margin-right: calc(-1 * var(--spacing-4,1rem) - 0.5%);
}
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 {
display: flex;
}
.flex-expand {
display: flex;
justify-content: space-between;
}
.flex-row {
display: flex;
flex-direction: row;
}
.flex-row-group {
display: block;
flex-grow: 1;
}
.row {
display: flex;
flex-wrap: wrap;
@@ -172,11 +122,7 @@ span.sm:not(.heading) {
margin: .25rem;
}
.flex-col {
display: flex;
flex-direction: column;
}
/* Who knows for half of these to be honest */
@media screen and (max-width: 400px) {
.row {
flex-direction: column;
@@ -207,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 */
}
@@ -288,10 +171,6 @@ sup.\~critical, .text-critical {
width: 100%;
}
.flex-auto {
flex: auto;
}
.center {
justify-content: center;
}
@@ -300,14 +179,6 @@ sup.\~critical, .text-critical {
align-items: center;
}
.no-lp {
padding-left: 0px;
}
.block {
display: block;
}
.focused {
display: block;
}
@@ -404,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;
@@ -427,6 +307,10 @@ p.top {
margin-bottom: -0.5rem;
}
.dropdown.over-top {
position: absolute;
}
.dropdown-display.lg {
white-space: nowrap;
}
@@ -474,15 +358,24 @@ a:hover:not(.lang-link):not(.\~urge), a:active:not(.lang-link):not(.\~urge) {
color: var(--color-urge-200);
}
a.button,
a.button:link,
a.button:visited,
a.button:focus,
a.buton:hover {
color: var(--color-content) !important;
}
.link-center {
display: block;
text-align: center;
}
.search {
/* .search {
max-width: 15rem;
min-width: 10rem;
}
} */
td.img-circle {
width: 32px;
@@ -537,6 +430,59 @@ div.card:contains(section.banner.footer) {
padding-bottom: 0px !important
}
.mx-0i {
margin-left: 0px !important;
margin-right: 0px !important
}
.text-center-i {
text-align: center !important;
}
input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:before, .modal-close {
cursor: pointer;
}
.g-recaptcha {
overflow: hidden;
width: 296px;
height: 72px;
transform: scale(1.1);
transform-origin: top left;
}
.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;
}
}
:root {
/* seems to be the sweet spot */
--inside-input-base: -2.6rem;
/* thought --spacing would do the trick but apparently not */
--tailwind-spacing: 0.25rem;
}
/* places buttons inside a sibling input element (hopefully), based on the flex gap of the parent. */
.gap-1 > .button.inside-input {
margin-left: calc(var(--inside-input-base) - 1.0*var(--tailwind-spacing));
}
.gap-2 > .button.inside-input {
margin-left: calc(var(--inside-input-base) - 2.0*var(--tailwind-spacing));
}

44
css/fonts.css Normal file
View File

@@ -0,0 +1,44 @@
/* hanken-grotesk-regular - cyrillic-ext_latin_vietnamese */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Hanken Grotesk';
font-style: normal;
font-weight: 400;
src: url('../fonts/hanken-grotesk-v8-cyrillic-ext_latin_vietnamese-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* hanken-grotesk-500 - cyrillic-ext_latin_vietnamese */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Hanken Grotesk';
font-style: normal;
font-weight: 500;
src: url('../fonts/hanken-grotesk-v8-cyrillic-ext_latin_vietnamese-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* hanken-grotesk-500italic - cyrillic-ext_latin_vietnamese */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Hanken Grotesk';
font-style: italic;
font-weight: 500;
src: url('../fonts/hanken-grotesk-v8-cyrillic-ext_latin_vietnamese-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* hanken-grotesk-700 - cyrillic-ext_latin_vietnamese */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Hanken Grotesk';
font-style: normal;
font-weight: 700;
src: url('../fonts/hanken-grotesk-v8-cyrillic-ext_latin_vietnamese-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* hanken-grotesk-700italic - cyrillic-ext_latin_vietnamese */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Hanken Grotesk';
font-style: italic;
font-weight: 700;
src: url('../fonts/hanken-grotesk-v8-cyrillic-ext_latin_vietnamese-700italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}

View File

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

View File

@@ -10,54 +10,22 @@
background-color: rgba(0,0,0,40%);
}
.modal-shown {
display: block;
.wall {
position: fixed;
z-index: 11;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: auto;
}
@keyframes modal-hide {
from { opacity: 1; }
to { opacity: 0; }
html.dark .wall {
background-color: var(--bg-dark);
}
.modal-hiding {
animation: modal-hide 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
@keyframes modal-content-show {
from {
opacity: 0;
top: -6rem;
}
to {
opacity: 1;
top: 0;
}
}
.modal-content {
position: relative;
margin: 10% auto;
width: 30%;
}
.modal-content.wide {
width: 60%;
}
.modal-shown .modal-content {
animation: modal-content-show 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
@media screen and (max-width: 1000px) {
.modal-content.wide {
width: 75%;
}
}
@media screen and (max-width: 400px) {
.modal-content, .modal-content.wide {
width: 90%;
}
html:not(.dark) .wall {
background-color: var(--bg-light);
}
.modal-close {

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,29 @@
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.above .content {
bottom: 2.5rem;
left: 0;
right: 0;
}
.tooltip.darker .content {
background-color: rgba(0, 0, 0, 0.8);
}
.tooltip.right .content {
left: 120%;
}
@@ -31,6 +49,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,23 +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 []string
verifiedTokens map[string]DiscordUser // Map of tokens 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.
app *appContext
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) {
@@ -34,22 +42,42 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
Stopped: false,
ShutdownChannel: make(chan string),
bot: bot,
tokens: []string{},
tokens: map[string]VerifToken{},
verifiedTokens: map[string]DiscordUser{},
users: map[string]DiscordUser{},
app: app,
roleID: app.config.Section("discord").Key("apply_role").String(),
commandHandlers: map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string){},
commandIDs: []string{},
}
for _, user := range app.storage.discord {
dd.commandHandlers[app.config.Section("discord").Key("start_command").MustString("start")] = dd.cmdStart
dd.commandHandlers["lang"] = dd.cmdLang
dd.commandHandlers["pin"] = dd.cmdPIN
dd.commandHandlers["inv"] = dd.cmdInvite
for _, user := range app.storage.GetDiscord() {
dd.users[user.ID] = user
}
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()
d.tokens = append(d.tokens, pin)
d.tokens[pin] = VerifToken{Expiry: time.Now().Add(VERIF_TOKEN_EXPIRY_SEC * time.Second), JellyfinID: ""}
return pin
}
// NewAssignedAuthToken generates an 8-character pin in the form "A1-2B-CD",
// and assigns it for access only with the given Jellyfin ID.
func (d *DiscordDaemon) NewAssignedAuthToken(id string) string {
pin := genAuthToken()
d.tokens[pin] = VerifToken{Expiry: time.Now().Add(VERIF_TOKEN_EXPIRY_SEC * time.Second), JellyfinID: id}
return pin
}
@@ -71,10 +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.
@@ -92,63 +121,114 @@ 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"))
defer d.deregisterCommands()
defer d.bot.Close()
go d.registerCommands()
<-d.ShutdownChannel
d.ShutdownChannel <- "Down"
return
}
// ListRoles returns a list of available (excluding bot and @everyone) roles in a guild as a list of containing an array of the guild ID and its name.
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(lm.FailedGetDiscordRoles, err)
return
}
for _, role := range r {
if role.Name != d.username && role.Name != "@everyone" {
roles = append(roles, [2]string{role.ID, role.Name})
}
}
// roles = make([][2]string, len(r))
// for i, role := range r {
// roles[i] = [2]string{role.ID, role.Name}
// }
return
}
// ApplyRole applies the member role to the given user if set.
func (d *DiscordDaemon) ApplyRole(userID string) error {
if d.roleID == "" {
return nil
}
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,
@@ -157,19 +237,38 @@ 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()
iconURL = guild.IconURL("256")
return
}
// RenderDiscordUsername returns String of discord username, with support for new discriminator-less versions.
func RenderDiscordUsername[DcUser *dg.User | DiscordUser](user DcUser) string {
u, ok := interface{}(user).(*dg.User)
var discriminator, username string
if ok {
discriminator = u.Discriminator
username = u.Username
} else {
u2 := interface{}(user).(DiscordUser)
discriminator = u2.Discriminator
username = u2.Username
}
if discriminator == "0" {
return "@" + username
}
return username + "#" + discriminator
}
// Returns the user(s) roughly corresponding to the username (if they are in the guild).
// if no discriminator (#xxxx) is given in the username and there are multiple corresponding users, a list of all matching users is returned.
func (d *DiscordDaemon) GetUsers(username string) []*dg.Member {
@@ -179,10 +278,14 @@ 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, "#")
hasAt := strings.HasPrefix(username, "@")
if hasAt {
username = username[1:]
}
var users []*dg.Member
for _, member := range members {
if hasDiscriminator {
@@ -190,6 +293,11 @@ func (d *DiscordDaemon) GetUsers(username string) []*dg.Member {
return []*dg.Member{member}
}
}
if hasAt {
if member.User.Username == username && member.User.Discriminator == "0" {
return []*dg.Member{member}
}
}
if strings.Contains(member.User.Username, username) {
users = append(users, member)
}
@@ -200,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
@@ -209,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
@@ -224,137 +332,387 @@ func (d *DiscordDaemon) Shutdown() {
close(d.ShutdownChannel)
}
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 = ""
func (d *DiscordDaemon) registerCommands() {
d.commandDescriptions = []*dg.ApplicationCommand{
{
Name: d.app.config.Section("discord").Key("start_command").MustString("start"),
Description: "Start the Discord linking process. The bot will send further instructions.",
},
{
Name: "lang",
Description: "Set the language for the bot.",
Options: []*dg.ApplicationCommandOption{
{
Type: dg.ApplicationCommandOptionString,
Name: "language",
Description: "Language Name",
Required: true,
Choices: []*dg.ApplicationCommandOptionChoice{},
},
},
},
{
Name: "pin",
Description: "Send PIN for Discord verification.",
Options: []*dg.ApplicationCommandOption{
{
Type: dg.ApplicationCommandOptionString,
Name: "pin",
Description: "Verification PIN (e.g AB-CD-EF)",
Required: true,
},
},
},
{
Name: "inv",
Description: "Send an invite to a discord user (admin only).",
Options: []*dg.ApplicationCommandOption{
{
Type: dg.ApplicationCommandOptionUser,
Name: "user",
Description: "User to Invite.",
Required: true,
},
{
Type: dg.ApplicationCommandOptionInteger,
Name: "expiry",
Description: "Time in minutes before expiration.",
Required: false,
},
/* Label should be automatically set to something like "Discord invite for @username"
{
Type: dg.ApplicationCommandOptionString,
Name: "label",
Description: "Label given to this invite (shown on the Admin page)",
Required: false,
}, */
{
Type: dg.ApplicationCommandOptionString,
Name: "user_label",
Description: "Label given to users created with this invite.",
Required: false,
},
{
Type: dg.ApplicationCommandOptionString,
Name: "profile",
Description: "Profile to apply to the created user.",
Required: false,
},
},
},
}
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(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,
}
i++
}
profiles := d.app.storage.GetProfiles()
d.commandDescriptions[3].Options[3].Choices = make([]*dg.ApplicationCommandOptionChoice, len(profiles))
for i, profile := range profiles {
d.app.debug.Printf(lm.RegisterDiscordChoice, lm.Profile, profile.Name)
d.commandDescriptions[3].Options[3].Choices[i] = &dg.ApplicationCommandOptionChoice{
Name: profile.Name,
Value: profile.Name,
}
}
// d.deregisterCommands()
d.commandIDs = make([]string, len(d.commandDescriptions))
// cCommands, err := d.bot.ApplicationCommandBulkOverwrite(d.bot.State.User.ID, d.guildID, commands)
// if err != nil {
// d.app.err.Printf("Discord: Cannot create commands: %v", err)
// }
for i, cmd := range d.commandDescriptions {
command, err := d.bot.ApplicationCommandCreate(d.bot.State.User.ID, d.guildID, cmd)
if err != nil {
d.app.err.Printf(lm.FailedRegisterDiscordCommand, cmd.Name, err)
} else {
d.app.debug.Printf(lm.RegisterDiscordCommand, cmd.Name)
d.commandIDs[i] = command.ID
}
}
}
func (d *DiscordDaemon) deregisterCommands() {
existingCommands, err := d.bot.ApplicationCommands(d.bot.State.User.ID, d.guildID)
if err != nil {
d.app.err.Printf(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(lm.FailedDeregDiscordCommand, cmd.Name, err)
}
}
}
// UpdateCommands updates commands which have defined lists of options, to be used when changes occur.
func (d *DiscordDaemon) UpdateCommands() {
// Reload Profile List
profiles := d.app.storage.GetProfiles()
d.commandDescriptions[3].Options[3].Choices = make([]*dg.ApplicationCommandOptionChoice, len(profiles))
for i, profile := range profiles {
d.app.debug.Printf(lm.RegisterDiscordChoice, lm.Profile, profile.Name)
d.commandDescriptions[3].Options[3].Choices[i] = &dg.ApplicationCommandOptionChoice{
Name: profile.Name,
Value: profile.Name,
}
}
cmd, err := d.bot.ApplicationCommandEdit(d.bot.State.User.ID, d.guildID, d.commandIDs[3], d.commandDescriptions[3])
if err != nil {
d.app.err.Printf(lm.FailedRegisterDiscordChoices, lm.Profile, err)
} else {
d.commandIDs[3] = cmd.ID
}
}
func (d *DiscordDaemon) commandHandler(s *dg.Session, i *dg.InteractionCreate) {
if h, ok := d.commandHandlers[i.ApplicationCommandData().Name]; ok {
if i.GuildID != "" && d.Channel.Name != "" {
if d.Channel.ID == "" {
channel, err := s.Channel(i.ChannelID)
if err != nil {
d.app.err.Printf(lm.FailedGetDiscordChannel, i.ChannelID, err)
d.app.err.Println(lm.MonitorAllDiscordChannels)
d.Channel.Name = ""
}
if channel.Name == d.Channel.Name {
d.Channel.ID = channel.ID
}
}
if channel.Name == d.channelName {
d.channelID = channel.ID
if d.Channel.ID != i.ChannelID {
d.app.debug.Printf(lm.IgnoreOutOfChannelMessage, lm.Discord)
return
}
}
if d.channelID != m.ChannelID {
d.app.debug.Printf("Discord: Ignoring message as not in specified channel")
if i.Interaction.Member.User.ID == s.State.User.ID {
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
lang := d.app.storage.lang.chosenTelegramLang
if user, ok := d.users[i.Interaction.Member.User.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.commandStart(s, m, lang)
case "!lang":
d.commandLang(s, m, sects, lang)
default:
d.commandPIN(s, m, sects, lang)
h(s, i, lang)
}
}
func (d *DiscordDaemon) commandStart(s *dg.Session, m *dg.MessageCreate, lang string) {
channel, err := s.UserChannelCreate(m.Author.ID)
// cmd* methods handle slash-commands, msg* methods handle ! commands.
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", m.Author.Username, err)
d.app.err.Printf(lm.FailedCreateDiscordDMChannel, i.Interaction.Member.User.ID, err)
return
}
user := d.MustGetUser(channel.ID, m.Author.ID, m.Author.Discriminator, m.Author.Username)
d.users[m.Author.ID] = user
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)
user := d.MustGetUser(channel.ID, i.Interaction.Member.User.ID, i.Interaction.Member.User.Discriminator, i.Interaction.Member.User.Username)
d.users[i.Interaction.Member.User.ID] = user
content := d.app.storage.lang.Telegram[lang].Strings.get("discordStartMessage") + "\n"
content += d.app.storage.lang.Telegram[lang].Strings.template("languageMessageDiscord", tmpl{"command": "/lang"})
err = s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
// Type: dg.InteractionResponseChannelMessageWithSource,
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
Content: content,
Flags: 64, // Ephemeral
},
})
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
return
}
}
func (d *DiscordDaemon) commandLang(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(),
)
func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang string) {
pin := i.ApplicationCommandData().Options[0].StringValue()
user, ok := d.tokens[pin]
if !ok || time.Now().After(user.Expiry) {
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
// Type: dg.InteractionResponseChannelMessageWithSource,
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
Content: d.app.storage.lang.Telegram[lang].Strings.get("invalidPIN"),
Flags: 64, // Ephemeral
},
})
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
}
delete(d.tokens, pin)
return
}
if _, ok := d.app.storage.lang.Telegram[sects[1]]; ok {
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
// Type: dg.InteractionResponseChannelMessageWithSource,
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
Content: d.app.storage.lang.Telegram[lang].Strings.get("pinSuccess"),
Flags: 64, // Ephemeral
},
})
if err != nil {
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
d.verifiedTokens[pin] = dcUser
delete(d.tokens, pin)
}
func (d *DiscordDaemon) cmdLang(s *dg.Session, i *dg.InteractionCreate, lang string) {
code := i.ApplicationCommandData().Options[0].StringValue()
if _, ok := d.app.storage.lang.Telegram[code]; ok {
var user DiscordUser
for jfID, user := range d.app.storage.discord {
if user.ID == m.Author.ID {
user.Lang = sects[1]
d.app.storage.discord[jfID] = user
if err := d.app.storage.storeDiscordUsers(); err != nil {
d.app.err.Printf("Failed to store Discord users: %v", err)
}
for _, u := range d.app.storage.GetDiscord() {
if u.ID == i.Interaction.Member.User.ID {
u.Lang = code
lang = code
d.app.storage.SetDiscordKey(u.JellyfinID, u)
user = u
break
}
}
d.users[m.Author.ID] = user
d.users[i.Interaction.Member.User.ID] = user
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
// Type: dg.InteractionResponseChannelMessageWithSource,
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
Content: d.app.storage.lang.Telegram[lang].Strings.template("languageSet", tmpl{"language": d.app.storage.lang.Telegram[lang].Meta.Name}),
Flags: 64, // Ephemeral
},
})
if err != nil {
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
return
}
}
}
func (d *DiscordDaemon) commandPIN(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
}
tokenIndex := -1
for i, token := range d.tokens {
if sects[0] == token {
tokenIndex = i
break
}
}
if tokenIndex == -1 {
_, 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)
}
return
}
_, err := s.ChannelMessageSend(
m.ChannelID,
d.app.storage.lang.Telegram[lang].Strings.get("pinSuccess"),
)
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 send message to \"%s\": %v", m.Author.Username, err)
d.app.err.Printf(lm.FailedCreateDiscordDMChannel, i.Interaction.Member.User.ID, err)
return
}
d.verifiedTokens[sects[0]] = d.users[m.Author.ID]
d.tokens[len(d.tokens)-1], d.tokens[tokenIndex] = d.tokens[tokenIndex], d.tokens[len(d.tokens)-1]
d.tokens = d.tokens[:len(d.tokens)-1]
requester := d.MustGetUser(channel.ID, i.Interaction.Member.User.ID, i.Interaction.Member.User.Discriminator, i.Interaction.Member.User.Username)
d.users[i.Interaction.Member.User.ID] = requester
recipient := i.ApplicationCommandData().Options[0].UserValue(s)
// d.app.debug.Println(invuser)
//label := i.ApplicationCommandData().Options[2].StringValue()
//profile := i.ApplicationCommandData().Options[3].StringValue()
//mins, err := strconv.Atoi(i.ApplicationCommandData().Options[1].StringValue())
//if mins > 0 {
// expmin = mins
//}
// We want the same criteria for running this command as accessing the admin page (i.e. an "admin" of some sort)
if !(d.app.canAccessAdminPageByID(requester.JellyfinID)) {
d.app.err.Printf(lm.FailedGenerateInvite, fmt.Sprintf(lm.NonAdminUser, requester.JellyfinID))
s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
Content: d.app.storage.lang.Telegram[lang].Strings.get("noPermission"),
Flags: 64, // Ephemeral
},
})
return
}
var expiryMinutes int64 = 30
userLabel := ""
profileName := ""
for i, opt := range i.ApplicationCommandData().Options {
if i == 0 {
continue
}
switch opt.Name {
case "expiry":
expiryMinutes = opt.IntValue()
case "user_label":
userLabel = opt.StringValue()
case "profile":
profileName = opt.StringValue()
}
}
currentTime := time.Now()
validTill := currentTime.Add(time.Minute * time.Duration(expiryMinutes))
invite := Invite{
Code: GenerateInviteCode(),
Created: currentTime,
RemainingUses: 1,
UserExpiry: false,
ValidTill: validTill,
UserLabel: userLabel,
Profile: "Default",
Label: fmt.Sprintf("%s: %s", lm.Discord, RenderDiscordUsername(recipient)),
}
if profileName != "" {
if _, ok := d.app.storage.GetProfileKey(profileName); ok {
invite.Profile = profileName
}
}
if recipient != nil && d.app.config.Section("invite_emails").Key("enabled").MustBool(false) {
invname, err := d.bot.GuildMember(d.guildID, recipient.ID)
invite.SendTo = invname.User.Username
msg, err := d.app.email.constructInvite(invite.Code, invite, d.app, false)
if err != nil {
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, invite.Code, err)
d.app.err.Println(invite.SendTo)
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInviteFailure"),
Flags: 64, // Ephemeral
},
})
if err != nil {
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
}
} else {
var err error
err = d.app.discord.SendDM(msg, recipient.ID)
if err != nil {
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, RenderDiscordUsername(recipient), err)
d.app.err.Println(invite.SendTo)
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInviteFailure"),
Flags: 64, // Ephemeral
},
})
if err != nil {
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
}
} else {
d.app.info.Printf(lm.SentInviteMessage, invite.Code, RenderDiscordUsername(recipient))
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInvite"),
Flags: 64, // Ephemeral
},
})
if err != nil {
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
}
}
}
}
//if profile != "" {
d.app.storage.SetInvitesKey(invite.Code, invite)
}
func (d *DiscordDaemon) SendDM(message *Message, userID ...string) error {
@@ -408,3 +766,69 @@ func (d *DiscordDaemon) Send(message *Message, channelID ...string) error {
}
return nil
}
// UserVerified returns whether or not a token with the given PIN has been verified, and the user itself.
func (d *DiscordDaemon) UserVerified(pin string) (ContactMethodUser, bool) {
u, ok := d.verifiedTokens[pin]
// delete(d.verifiedTokens, pin)
return &u, ok
}
// AssignedUserVerified returns whether or not a user with the given PIN has been verified, and the token itself.
// Returns false if the given Jellyfin ID does not match the one in the user.
func (d *DiscordDaemon) AssignedUserVerified(pin string, jfID string) (user DiscordUser, ok bool) {
user, ok = d.verifiedTokens[pin]
if ok && user.JellyfinID != jfID {
ok = false
}
// delete(d.verifiedUsers, pin)
return
}
// UserExists returns whether or not a user with the given ID exists.
func (d *DiscordDaemon) UserExists(id string) bool {
c, err := d.app.storage.db.Count(&DiscordUser{}, badgerhold.Where("ID").Eq(id))
return err != nil || c > 0
}
// 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)
}

83
easyproxy/easyproxy.go Normal file
View File

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

7
easyproxy/go.mod Normal file
View File

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

4
easyproxy/go.sum Normal file
View File

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

353
email.go
View File

@@ -10,6 +10,8 @@ import (
"html/template"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"strconv"
"strings"
@@ -18,12 +20,16 @@ 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"
"github.com/timshannon/badgerhold/v4"
sMail "github.com/xhit/go-simple-mail/v2"
)
var renderer = html.NewRenderer(html.RendererOptions{Flags: html.Smartypants})
var markdownRenderer = html.NewRenderer(html.RendererOptions{Flags: html.Smartypants})
// EmailClient implements email sending, right now via smtp, mailgun or a dummy client.
type EmailClient interface {
@@ -75,21 +81,31 @@ func NewEmailer(app *appContext) *Emailer {
}
method := app.config.Section("email").Key("method").String()
if method == "smtp" {
sslTLS := false
if app.config.Section("smtp").Key("encryption").String() == "ssl_tls" {
sslTLS = true
enc := sMail.EncryptionSTARTTLS
switch app.config.Section("smtp").Key("encryption").String() {
case "ssl_tls":
enc = sMail.EncryptionSSLTLS
case "starttls":
enc = sMail.EncryptionSTARTTLS
case "none":
enc = sMail.EncryptionNone
}
username := app.config.Section("smtp").Key("username").MustString("")
password := app.config.Section("smtp").Key("password").String()
if username == "" && password != "" {
username = emailer.fromAddr
}
err := emailer.NewSMTP(app.config.Section("smtp").Key("server").String(), app.config.Section("smtp").Key("port").MustInt(465), username, password, sslTLS, app.config.Section("smtp").Key("ssl_cert").MustString(""), app.config.Section("smtp").Key("hello_hostname").String(), app.config.Section("smtp").Key("cert_validation").MustBool(true))
var proxyConf *easyproxy.ProxyConfig = nil
if app.proxyEnabled {
proxyConf = &app.proxyConfig
}
authType := sMail.AuthType(app.config.Section("smtp").Key("auth_type").MustInt(4))
err := emailer.NewSMTP(app.config.Section("smtp").Key("server").String(), app.config.Section("smtp").Key("port").MustInt(465), username, password, enc, 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{}
}
@@ -110,16 +126,12 @@ type SMTP struct {
}
// NewSMTP returns an SMTP emailClient.
func (emailer *Emailer) NewSMTP(server string, port int, username, password string, sslTLS bool, certPath string, helloHostname string, validateCertificate bool) (err error) {
func (emailer *Emailer) NewSMTP(server string, port int, username, password string, encryption sMail.Encryption, certPath string, helloHostname string, validateCertificate bool, authType sMail.AuthType, proxy *easyproxy.ProxyConfig) (err error) {
sender := &SMTP{}
sender.Client = sMail.NewSMTPClient()
if sslTLS {
sender.Client.Encryption = sMail.EncryptionSSLTLS
} else {
sender.Client.Encryption = sMail.EncryptionSTARTTLS
}
sender.Client.Encryption = encryption
if username != "" || password != "" {
sender.Client.Authentication = sMail.AuthLogin
sender.Client.Authentication = authType
sender.Client.Username = username
sender.Client.Password = password
}
@@ -128,12 +140,16 @@ func (emailer *Emailer) NewSMTP(server string, port int, username, password stri
sender.Client.Host = server
sender.Client.Port = port
sender.Client.KeepAlive = false
// x509.SystemCertPool is unavailable on windows
if PLATFORM == "windows" {
sender.Client.TLSConfig = &tls.Config{
InsecureSkipVerify: !validateCertificate,
ServerName: server,
}
if proxy != nil {
sender.Client.CustomConn, err = easyproxy.NewConn(*proxy, fmt.Sprintf("%s:%d", server, port), sender.Client.TLSConfig)
}
emailer.sender = sender
return
}
@@ -153,6 +169,9 @@ func (emailer *Emailer) NewSMTP(server string, port int, username, password stri
ServerName: server,
RootCAs: rootCAs,
}
if proxy != nil {
sender.Client.CustomConn, err = easyproxy.NewConn(*proxy, fmt.Sprintf("%s:%d", server, port), sender.Client.TLSConfig)
}
emailer.sender = sender
return
}
@@ -184,13 +203,19 @@ 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, "/")]
}
if strings.Contains(url, "v3") {
url = url[0:strings.LastIndex(url, "/")]
}
sender.client.SetAPIBase(url)
@@ -301,11 +326,12 @@ 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()
if !strings.HasSuffix(inviteLink, "/invite") {
inviteLink += "/invite"
inviteLink := app.ExternalURI
if code == "" { // Personal email change
inviteLink = fmt.Sprintf("%s/my/confirm/%s", inviteLink, url.PathEscape(key))
} else { // Invite email confirmation
inviteLink = fmt.Sprintf("%s%s/%s?key=%s", inviteLink, PAGES.Form, code, url.PathEscape(key))
}
inviteLink = fmt.Sprintf("%s/%s?key=%s", inviteLink, code, key)
template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username})
template["confirmationURL"] = inviteLink
template["message"] = message
@@ -319,10 +345,11 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, app *a
}
var err error
template := emailer.confirmationValues(code, username, key, app, noSub)
if app.storage.customEmails.EmailConfirmation.Enabled {
message := app.storage.MustGetCustomContentKey("EmailConfirmation")
if message.Enabled {
content := templateEmail(
app.storage.customEmails.EmailConfirmation.Content,
app.storage.customEmails.EmailConfirmation.Variables,
message.Content,
message.Variables,
nil,
template,
)
@@ -343,7 +370,7 @@ func (emailer *Emailer) constructTemplate(subject, md string, app *appContext, u
subject = templateEmail(subject, []string{"{username}"}, nil, map[string]interface{}{"username": username[0]})
}
email := &Message{Subject: subject}
html := markdown.ToHTML([]byte(md), nil, renderer)
html := markdown.ToHTML([]byte(md), nil, markdownRenderer)
text := stripMarkdown(md)
message := app.config.Section("messages").Key("message").String()
var err error
@@ -367,11 +394,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"),
@@ -402,10 +425,11 @@ func (emailer *Emailer) constructInvite(code string, invite Invite, app *appCont
}
template := emailer.inviteValues(code, invite, app, noSub)
var err error
if app.storage.customEmails.InviteEmail.Enabled {
message := app.storage.MustGetCustomContentKey("InviteEmail")
if message.Enabled {
content := templateEmail(
app.storage.customEmails.InviteEmail.Content,
app.storage.customEmails.InviteEmail.Variables,
message.Content,
message.Variables,
nil,
template,
)
@@ -441,10 +465,11 @@ func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appCont
}
var err error
template := emailer.expiryValues(code, invite, app, noSub)
if app.storage.customEmails.InviteExpiry.Enabled {
message := app.storage.MustGetCustomContentKey("InviteExpiry")
if message.Enabled {
content := templateEmail(
app.storage.customEmails.InviteExpiry.Content,
app.storage.customEmails.InviteExpiry.Variables,
message.Content,
message.Variables,
nil,
template,
)
@@ -495,10 +520,11 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite
}
template := emailer.createdValues(code, username, address, invite, app, noSub)
var err error
if app.storage.customEmails.UserCreated.Enabled {
message := app.storage.MustGetCustomContentKey("UserCreated")
if message.Enabled {
content := templateEmail(
app.storage.customEmails.UserCreated.Content,
app.storage.customEmails.UserCreated.Variables,
message.Content,
message.Variables,
nil,
template,
)
@@ -512,18 +538,6 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite
return email, nil
}
// GenResetLink generates and returns a password reset link.
func (app *appContext) GenResetLink(pin string) (string, error) {
url := app.config.Section("password_resets").Key("url_base").String()
var pinLink string
if url == "" {
return pinLink, fmt.Errorf("disabled as no URL Base provided. Set in Settings > Password Resets.")
}
// Strip /invite from end of this URL, ik it's ugly.
pinLink = fmt.Sprintf("%s/reset?pin=%s", url, pin)
return pinLink, nil
}
func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bool) map[string]interface{} {
d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern)
message := app.config.Section("messages").Key("message").String()
@@ -563,7 +577,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 {
@@ -580,10 +594,11 @@ func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub
}
template := emailer.resetValues(pwr, app, noSub)
var err error
if app.storage.customEmails.PasswordReset.Enabled {
message := app.storage.MustGetCustomContentKey("PasswordReset")
if message.Enabled {
content := templateEmail(
app.storage.customEmails.PasswordReset.Content,
app.storage.customEmails.PasswordReset.Variables,
message.Content,
message.Variables,
nil,
template,
)
@@ -612,7 +627,6 @@ func (emailer *Emailer) deletedValues(reason string, app *appContext, noSub bool
template["reason"] = reason
template["message"] = app.config.Section("messages").Key("message").String()
}
fmt.Println("TTTT", template)
return template
}
@@ -622,10 +636,11 @@ func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub b
}
var err error
template := emailer.deletedValues(reason, app, noSub)
if app.storage.customEmails.UserDeleted.Enabled {
message := app.storage.MustGetCustomContentKey("UserDeleted")
if message.Enabled {
content := templateEmail(
app.storage.customEmails.UserDeleted.Content,
app.storage.customEmails.UserDeleted.Variables,
message.Content,
message.Variables,
nil,
template,
)
@@ -663,10 +678,11 @@ func (emailer *Emailer) constructDisabled(reason string, app *appContext, noSub
}
var err error
template := emailer.disabledValues(reason, app, noSub)
if app.storage.customEmails.UserDisabled.Enabled {
message := app.storage.MustGetCustomContentKey("UserDisabled")
if message.Enabled {
content := templateEmail(
app.storage.customEmails.UserDisabled.Content,
app.storage.customEmails.UserDisabled.Variables,
message.Content,
message.Variables,
nil,
template,
)
@@ -704,10 +720,11 @@ func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub b
}
var err error
template := emailer.enabledValues(reason, app, noSub)
if app.storage.customEmails.UserEnabled.Enabled {
message := app.storage.MustGetCustomContentKey("UserEnabled")
if message.Enabled {
content := templateEmail(
app.storage.customEmails.UserEnabled.Content,
app.storage.customEmails.UserEnabled.Variables,
message.Content,
message.Variables,
nil,
template,
)
@@ -721,6 +738,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"),
@@ -759,7 +842,8 @@ func (emailer *Emailer) constructWelcome(username string, expiry time.Time, app
}
var err error
var template map[string]interface{}
if app.storage.customEmails.WelcomeEmail.Enabled {
message := app.storage.MustGetCustomContentKey("WelcomeEmail")
if message.Enabled {
template = emailer.welcomeValues(username, expiry, app, noSub, true)
} else {
template = emailer.welcomeValues(username, expiry, app, noSub, false)
@@ -769,11 +853,11 @@ func (emailer *Emailer) constructWelcome(username string, expiry time.Time, app
"date": "{yourAccountWillExpire}",
})
}
if app.storage.customEmails.WelcomeEmail.Enabled {
if message.Enabled {
content := templateEmail(
app.storage.customEmails.WelcomeEmail.Content,
app.storage.customEmails.WelcomeEmail.Variables,
app.storage.customEmails.WelcomeEmail.Conditionals,
message.Content,
message.Variables,
message.Conditionals,
template,
)
email, err = emailer.constructTemplate(email.Subject, content, app)
@@ -804,10 +888,11 @@ func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Mess
}
var err error
template := emailer.userExpiredValues(app, noSub)
if app.storage.customEmails.UserExpired.Enabled {
message := app.storage.MustGetCustomContentKey("UserExpired")
if message.Enabled {
content := templateEmail(
app.storage.customEmails.UserExpired.Content,
app.storage.customEmails.UserExpired.Variables,
message.Content,
message.Variables,
nil,
template,
)
@@ -826,49 +911,123 @@ func (emailer *Emailer) send(email *Message, address ...string) error {
return emailer.sender.Send(emailer.fromName, emailer.fromAddr, email, address...)
}
func (app *appContext) sendByID(email *Message, ID ...string) error {
func (app *appContext) sendByID(email *Message, ID ...string) (err error) {
for _, id := range ID {
var err error
if tgChat, ok := app.storage.telegram[id]; ok && tgChat.Contact && telegramEnabled {
if tgChat, ok := app.storage.GetTelegramKey(id); ok && tgChat.Contact && telegramEnabled {
err = app.telegram.Send(email, tgChat.ChatID)
if err != nil {
return err
}
// if err != nil {
// return err
// }
}
if dcChat, ok := app.storage.discord[id]; ok && dcChat.Contact && discordEnabled {
if dcChat, ok := app.storage.GetDiscordKey(id); ok && dcChat.Contact && discordEnabled {
err = app.discord.Send(email, dcChat.ChannelID)
if err != nil {
return err
}
// if err != nil {
// return err
// }
}
if mxChat, ok := app.storage.matrix[id]; ok && mxChat.Contact && matrixEnabled {
if mxChat, ok := app.storage.GetMatrixKey(id); ok && mxChat.Contact && matrixEnabled {
err = app.matrix.Send(email, mxChat)
if err != nil {
return err
}
// if err != nil {
// return err
// }
}
if address, ok := app.storage.emails[id]; ok && address.Contact && emailEnabled {
if address, ok := app.storage.GetEmailsKey(id); ok && address.Contact && emailEnabled {
err = app.email.send(email, address.Addr)
if err != nil {
return err
}
}
if err != nil {
return err
// if err != nil {
// return err
// }
}
// if err != nil {
// return err
// }
}
return nil
return
}
func (app *appContext) getAddressOrName(jfID string) string {
if dcChat, ok := app.storage.discord[jfID]; ok && dcChat.Contact && discordEnabled {
return dcChat.Username + "#" + dcChat.Discriminator
if dcChat, ok := app.storage.GetDiscordKey(jfID); ok && dcChat.Contact && discordEnabled {
return RenderDiscordUsername(dcChat)
}
if tgChat, ok := app.storage.telegram[jfID]; ok && tgChat.Contact && telegramEnabled {
if tgChat, ok := app.storage.GetTelegramKey(jfID); ok && tgChat.Contact && telegramEnabled {
return "@" + tgChat.Username
}
if addr, ok := app.storage.emails[jfID]; ok {
if addr, ok := app.storage.GetEmailsKey(jfID); ok {
return addr.Addr
}
if mxChat, ok := app.storage.GetMatrixKey(jfID); ok && mxChat.Contact && matrixEnabled {
return mxChat.UserID
}
return ""
}
// ReverseUserSearch returns the jellyfin ID of the user with the given username, email, or contact method username.
// returns "" if none found. returns only the first match, might be an issue if there are users with the same contact method usernames.
func (app *appContext) ReverseUserSearch(address string, matchUsername, matchEmail, matchContactMethod bool) (user mediabrowser.User, ok bool) {
ok = false
var err error = nil
if matchUsername {
user, err = app.jf.UserByName(address, false)
if err == nil {
ok = true
return
}
}
if matchEmail {
emailAddresses := []EmailAddress{}
err = app.storage.db.Find(&emailAddresses, badgerhold.Where("Addr").Eq(address))
if err == nil && len(emailAddresses) > 0 {
for _, emailUser := range emailAddresses {
user, err = app.jf.UserByID(emailUser.JellyfinID, false)
if err == nil {
ok = true
return
}
}
}
}
// Dont know how we'd use badgerhold when we need to render each username,
// Apart from storing the rendered name in the db.
if matchContactMethod {
for _, dcUser := range app.storage.GetDiscord() {
if RenderDiscordUsername(dcUser) == strings.ToLower(address) {
user, err = app.jf.UserByID(dcUser.JellyfinID, false)
if err == nil {
ok = true
return
}
}
}
tgUsername := strings.TrimPrefix(address, "@")
telegramUsers := []TelegramUser{}
err = app.storage.db.Find(&telegramUsers, badgerhold.Where("Username").Eq(tgUsername))
if err == nil && len(telegramUsers) > 0 {
for _, telegramUser := range telegramUsers {
user, err = app.jf.UserByID(telegramUser.JellyfinID, false)
if err == nil {
ok = true
return
}
}
}
matrixUsers := []MatrixUser{}
err = app.storage.db.Find(&matrixUsers, badgerhold.Where("UserID").Eq(address))
if err == nil && len(matrixUsers) > 0 {
for _, matrixUser := range matrixUsers {
user, err = app.jf.UserByID(matrixUser.JellyfinID, false)
if err == nil {
ok = true
return
}
}
}
}
return
}
// EmailAddressExists returns whether or not a user with the given email address exists.
func (app *appContext) EmailAddressExists(address string) bool {
c, err := app.storage.db.Count(&EmailAddress{}, badgerhold.Where("Addr").Eq(address))
return err != nil || c > 0
}

32
exit.go
View File

@@ -5,13 +5,14 @@ import (
"html/template"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"runtime/debug"
"strings"
"time"
"github.com/pkg/browser"
"github.com/robert-nix/ansihtml"
)
// https://gist.github.com/swdunlop/9629168
@@ -43,6 +44,21 @@ func identifyPanic() string {
return fmt.Sprintf("pc:%x", pc)
}
// OpenFile attempts to open a given file in the appropriate GUI application.
func OpenFile(fpath string) (err error) {
switch PLATFORM {
case "linux":
err = exec.Command("xdg-open", fpath).Start()
case "windows":
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", fpath).Start()
case "darwin":
err = exec.Command("open", fpath).Start()
default:
err = fmt.Errorf("unknown os")
}
return
}
// Exit dumps the last 100 lines of output to a crash file in /tmp (or equivalent), and generates a prettier HTML file containing it that is opened in the browser if possible.
func Exit(err interface{}) {
tmpl, err2 := template.ParseFS(localFS, "html/crash.html", "html/header.html")
@@ -63,12 +79,19 @@ func Exit(err interface{}) {
if err != nil {
data["Err"] = fmt.Sprintf("%s %v", identifyPanic(), err)
}
fpath := filepath.Join(temp, "jfa-go-crash-"+time.Now().Local().Format("2006-01-02T15:04:05"))
// Use dashes for time rather than colons for Windows
fpath := filepath.Join(temp, "jfa-go-crash-"+time.Now().Local().Format("2006-01-02T15-04-05"))
err2 = os.WriteFile(fpath+".txt", []byte(logCache), 0666)
if err2 != nil {
log.Fatalf("Failed to write crash dump file: %v", err2)
}
log.Printf("\n------\nA crash report has been saved to \"%s\".\n------", fpath+".txt")
// Render ANSI colors to HTML
data["Log"] = template.HTML(string(ansihtml.ConvertToHTML([]byte(data["Log"].(string)))))
data["SanitizedLog"] = template.HTML(string(ansihtml.ConvertToHTML([]byte(data["SanitizedLog"].(string)))))
data["Err"] = template.HTML(string(ansihtml.ConvertToHTML([]byte(data["Err"].(string)))))
f, err2 := os.OpenFile(fpath+".html", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
if err2 != nil {
log.Fatalf("Failed to open crash dump file: %v", err2)
@@ -78,7 +101,10 @@ func Exit(err interface{}) {
if err2 != nil {
log.Fatalf("Failed to execute template: %v", err2)
}
browser.OpenFile(fpath + ".html")
if err := OpenFile(fpath + ".html"); err != nil {
log.Printf("Failed to open browser, trying text file...")
OpenFile(fpath + ".txt")
}
if TRAY {
QuitTray()
} else {

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

169
go.mod
View File

@@ -1,6 +1,8 @@
module github.com/hrfee/jfa-go
go 1.16
go 1.23.0
toolchain go1.24.0
replace github.com/hrfee/jfa-go/docs => ./docs
@@ -10,56 +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.23.2
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.13.0
github.com/fsnotify/fsnotify v1.5.1
github.com/getlantern/golog v0.0.0-20210606115803-bce9f9fe5a5f // indirect
github.com/getlantern/hidden v0.0.0-20201229170000-e66e7f878730 // indirect
github.com/getlantern/ops v0.0.0-20200403153110-8476b16edcd6 // indirect
github.com/getlantern/systray v1.1.0
github.com/gin-contrib/pprof v1.3.0
github.com/gin-contrib/static v0.0.1
github.com/gin-gonic/gin v1.7.7
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-playground/validator/v10 v10.9.0 // indirect
github.com/go-stack/stack v1.8.1 // indirect
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.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/golang/protobuf v1.5.2 // indirect
github.com/gomarkdown/markdown v0.0.0-20211212230626-5af6ad2f47df
github.com/google/uuid v1.3.0 // indirect
github.com/hrfee/jfa-go/common v0.0.0-20211222231100-d47afe05f49c
github.com/hrfee/jfa-go/docs v0.0.0-20211222231100-d47afe05f49c
github.com/hrfee/jfa-go/linecache v0.0.0-20211222231100-d47afe05f49c
github.com/hrfee/jfa-go/logger v0.0.0-20211222231100-d47afe05f49c
github.com/hrfee/jfa-go/ombi v0.0.0-20211222231100-d47afe05f49c
github.com/hrfee/mediabrowser v0.3.8
github.com/itchyny/timefmt-go v0.1.3
github.com/json-iterator/go v1.1.12 // indirect
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/itchyny/timefmt-go v0.1.6
github.com/lithammer/shortuuid/v3 v3.0.7
github.com/mailgun/mailgun-go/v4 v4.6.0
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
github.com/pkg/errors v0.9.1 // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/swaggo/files v0.0.0-20210815190702-a29dd2bc99b2
github.com/swaggo/gin-swagger v1.3.3
github.com/swaggo/swag v1.7.6 // indirect
github.com/technoweenie/multipartstreamer v1.0.1 // indirect
github.com/tidwall/sjson v1.2.4 // indirect
github.com/ugorji/go v1.2.6 // indirect
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.3
github.com/writeas/go-strip-markdown v2.0.1+incompatible
github.com/xhit/go-simple-mail/v2 v2.10.0
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect
golang.org/x/net v0.0.0-20211216030914-fe4d6282115f // indirect
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
golang.org/x/tools v0.1.8 // indirect
google.golang.org/protobuf v1.27.1 // indirect
gopkg.in/ini.v1 v1.66.2
maunium.net/go/mautrix v0.10.7
github.com/xhit/go-simple-mail/v2 v2.16.0
gopkg.in/ini.v1 v1.67.0
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.21.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/bytedance/sonic v1.12.4 // indirect
github.com/bytedance/sonic/loader v0.2.1 // indirect
github.com/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.6 // indirect
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect
github.com/getlantern/errors v1.0.4 // indirect
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 // indirect
github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc // indirect
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 // indirect
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-chi/chi/v5 v5.1.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.22.1 // indirect
github.com/go-stack/stack v1.8.1 // indirect
github.com/go-test/deep v1.1.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/flatbuffers v24.3.25+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hrfee/mediabrowser v0.3.27 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/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.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rs/zerolog v1.33.0 // indirect
github.com/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.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-20240103092955-90b7d1423f92 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.mau.fi/util v0.8.1 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/otel v1.31.0 // indirect
go.opentelemetry.io/otel/metric v1.31.0 // indirect
go.opentelemetry.io/otel/trace v1.31.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/arch v0.11.0 // indirect
golang.org/x/crypto v0.35.0 // indirect
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
golang.org/x/image v0.21.0 // indirect
golang.org/x/net v0.36.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/tools v0.26.0 // indirect
google.golang.org/protobuf v1.35.1 // indirect
)

607
go.sum
View File

@@ -1,311 +1,430 @@
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.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/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/agiledragon/gomonkey/v2 v2.3.1 h1:k+UnUY0EMNYUFUAQVETGY9uUTxjMdnUkP0ARyJS1zzs=
github.com/agiledragon/gomonkey/v2 v2.3.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts=
github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts=
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/bwmarrin/discordgo v0.23.2 h1:BzrtTktixGHIu9Tt7dEE6diysEF9HWnXeHuoJEt2fH4=
github.com/bwmarrin/discordgo v0.23.2/go.mod h1:c1WtWUGN6nREDmzIpyTp/iD3VYt4Fpx+bVyfBG7JE+M=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4=
github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/bytedance/sonic v1.12.4 h1:9Csb3c9ZJhfUWeMtpCDCq6BUoH5ogfDFLUgQ/jG+R0k=
github.com/bytedance/sonic v1.12.4/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=
github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
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 v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/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/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=
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a h1:M88ob4TyDnEqNuL3PgsE/p3bDujfspnulR+0dQWNYZs=
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a/go.mod h1:buzQsO8HHkZX2Q45fdfGH1xejPjuDQaXH8btcYMFzPM=
github.com/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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
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.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI=
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
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 h1:XukU2whlh7OdpxnkXhNH9VTLVz0EVPGKDV5K0oWhvzw=
github.com/getlantern/errors v1.0.1/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
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-20210606115803-bce9f9fe5a5f h1:wsVt3P/boVKkPFEZkWxgNgRq/+mD7sWHc17+Vw2PgH8=
github.com/getlantern/golog v0.0.0-20210606115803-bce9f9fe5a5f/go.mod h1:ZyIjgH/1wTCl+B+7yH1DqrWp6MPJqESmwmEQ89ZfhvA=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
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=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc h1:sue+aeVx7JF5v36H1HfvcGFImLpSD5goj8d+MitovDU=
github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc/go.mod h1:D9RWpXy/EFPYxiKUURo2TB8UBosbqkiLhttRrZYtvqM=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
github.com/getlantern/hidden v0.0.0-20201229170000-e66e7f878730 h1:oKJVQbWZ2CAJ71jYnm6A3+e6h5bkPJ0okIMwkaYB5HI=
github.com/getlantern/hidden v0.0.0-20201229170000-e66e7f878730/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 h1:cSrD9ryDfTV2yaur9Qk3rHYD414j3Q1rl7+L0AylxrE=
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-20200403153110-8476b16edcd6 h1:QthAQCekS1YOeYWSvoHI6ZatlG4B+GBDLxV/2ZkBsTA=
github.com/getlantern/ops v0.0.0-20200403153110-8476b16edcd6/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/systray v1.1.0 h1:U0wCEqseLi2ok1fE6b88gJklzriavPJixZysZPkZd/Y=
github.com/getlantern/systray v1.1.0/go.mod h1:AecygODWIsBquJCJFop8MEQcJbWFfw/1yWbVabNgpCM=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/getlantern/ops v0.0.0-20220713155959-1315d978fff7/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
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.3 h1:etUaeesHhEORpZMp18zoOhepboiWnFtXrBZxszWUn4k=
github.com/gin-contrib/gzip v0.0.3/go.mod h1:YxxswVZIqOvcHEQpsSn+QF5guQtO1dCfy0shBPy4jFc=
github.com/gin-contrib/pprof v1.3.0 h1:G9eK6HnbkSqDZBYbzG4wrjCsA4e+cvYAHUZw6W+W9K0=
github.com/gin-contrib/pprof v1.3.0/go.mod h1:waMjT1H9b179t3CxuG1cV3DHpga6ybizwfBaM5OXaB0=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/pprof v1.5.0 h1:E/Oy7g+kNw94KfdCy3bZxQFtyDnAX2V7axRS7sNYVrU=
github.com/gin-contrib/pprof v1.5.0/go.mod h1:GqFL6LerKoCQ/RSWnkYczkTJ+tOAUVN/8sbnEtaqOKs=
github.com/gin-contrib/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.2/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/gin-gonic/gin v1.7.4/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY=
github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs=
github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U=
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.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 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/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.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
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.3/go.mod h1:gG4F8wdEDN+YPBMVnzE85Rbhf+Th2DTvA9nFPQ5AYEg=
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
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.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/go-playground/validator/v10 v10.9.0 h1:NgTtmN58D0m8+UuxtYmGztBJB7VnPgjj221I1QHci2A=
github.com/go-playground/validator/v10 v10.9.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
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/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-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=
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU=
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
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 v1.1.1/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/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.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
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=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/gomarkdown/markdown v0.0.0-20211212230626-5af6ad2f47df h1:M7mdNDTRraBcrHZg2aOYiFP9yTDajb6fquRZRpXnbVA=
github.com/gomarkdown/markdown v0.0.0-20211212230626-5af6ad2f47df/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81 h1:5lyLWsV+qCkoYqsKUDuycESh9DEIPVKN6iCFeL7ag50=
github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v23.5.9+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/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=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/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/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
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/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/hrfee/mediabrowser v0.3.7 h1:F57Cmwst4fOfhPuOlanKiOuek9zCVcXm78/zP/1WB2s=
github.com/hrfee/mediabrowser v0.3.7/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/hrfee/mediabrowser v0.3.8 h1:y0iBCb6jE3QKcsiCJSYva2fFPHRn4UA+sGRzoPuJ/Dk=
github.com/hrfee/mediabrowser v0.3.8/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/itchyny/timefmt-go v0.1.3 h1:7M3LGVDsqcd0VZH2U+x393obrzZisp7C0uEe921iRkU=
github.com/itchyny/timefmt-go v0.1.3/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
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.27 h1:8bxPamBFLD1Xqy6pf6M3Oc5GUQ0iU/flO0S64G1AsIM=
github.com/hrfee/mediabrowser v0.3.27/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
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/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
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.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/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 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
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/mailgun/mailgun-go/v4 v4.6.0 h1:qSrgT3wP5fU7wF/tNUp4xeYe8wSUy+8V5NJPYnB6Hxo=
github.com/mailgun/mailgun-go/v4 v4.6.0/go.mod h1:FJlF9rI5cQT+mrwujtJjPMbIVy3Ebor9bKTVsJ0QU40=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/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/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.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
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 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA=
github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/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/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/otiai10/copy v1.7.0 h1:hVoPiN+t+7d2nzzwMiDHPSOogsWAStewq3TwU05+clE=
github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U=
github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
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/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274 h1:qli3BGQK0tYDkSEvZ/FzZTi9ZrOX86Q6CIhKLGc489A=
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/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/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.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/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
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/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
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=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/steambap/captcha v1.4.1 h1:OmMdxLCWCqJvsFaFYwRpvMckIuvI6s8s1LsBrBw97P0=
github.com/steambap/captcha v1.4.1/go.mod h1:oC9T7IfEgnrhzjDz5Djf1H7GPffCzRMbsQfFkJmhlnk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E=
github.com/swaggo/files v0.0.0-20210815190702-a29dd2bc99b2 h1:+iNTcqQJy0OZ5jk6a5NLib47eqXK8uYcPX+O4+cBpEM=
github.com/swaggo/files v0.0.0-20210815190702-a29dd2bc99b2/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.2.0/go.mod h1:qlH2+W7zXGZkczuL+r2nEBR2JTT+/lX05Nn6vPhc7OI=
github.com/swaggo/gin-swagger v1.3.3 h1:XHyYmeNVFG5PbyWHG4jXtxOm2P4kiZapDCWsyDDiQ/I=
github.com/swaggo/gin-swagger v1.3.3/go.mod h1:ymsZuGpbbu+S7ZoQ49QPpZoDBj6uqhb8WizgQPVgWl0=
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
github.com/swaggo/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.7.4/go.mod h1:zD8h6h4SPv7t3l+4BKdRquqW1ASWjKZgT6Qv9z3kNqI=
github.com/swaggo/swag v1.7.6 h1:UbAqHyXkW2J+cDjs5S43MkuYR7a6stB7Am7SK8NBmRg=
github.com/swaggo/swag v1.7.6/go.mod h1:7vLqNYEtYoIsD14wXgy9oDS65MNiDANrPtbk9rnLuj0=
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.10.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.12.1 h1:ikuZsLdhr8Ws0IdROXUS1Gi4v9Z4pGqpX/CvJkxvfpo=
github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.3/go.mod h1:5WdjKx3AQMvCJ4RG6/2UYT7dLrGvJUV1x4jdTAyGvZs=
github.com/tidwall/sjson v1.2.4 h1:cuiLzLnaMeBhRmEv00Lpk3tkYrcxpmbU81tAY4Dw0tc=
github.com/tidwall/sjson v1.2.4/go.mod h1:098SZ494YoMWPmMO6ct4dcFnqxwj9r/gF0Etp19pSNM=
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/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.6 h1:tGiWC9HENWE2tqYycIqFTNorMmFRVhNwCpDOpWqnk8E=
github.com/ugorji/go v1.2.6/go.mod h1:anCg0y61KIhDlPZmnH+so+RQbysYVyDko0IMgJv0Nn0=
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.6 h1:7kbGefxLoDBuYXOms4yD7223OpNMMPNPZxXk5TvFcyQ=
github.com/ugorji/go/codec v1.2.6/go.mod h1:V6TCNZ4PHqoHGFZuSG1W8nrCzzdgA2DozYxWFFpvxTw=
github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw=
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/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
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.10.0 h1:nib6RaJ4qVh5HD9UE9QJqnUZyWp3upv+Z6CFxaMj0V8=
github.com/xhit/go-simple-mail/v2 v2.10.0/go.mod h1:kA1XbQfCI4JxQ9ccSN6VFyIEkkugOm7YiPkA5hKiQn4=
github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA=
github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
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.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.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.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/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
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-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
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-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.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=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/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=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -313,93 +432,137 @@ golang.org/x/net v0.0.0-20190611141213-3f473d35a33a/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
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-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211020060615-d418f374d309/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211216030914-fe4d6282115f h1:hEYJvxw1lSnWIl8X9ofsYMklzaDs90JI2az5YMd4fPM=
golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/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=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190610200419-93c9922d18ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/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-20210616045830-e2b7044e8c71/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-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/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-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/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.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
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=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606050223-4d9ae51c2468/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190611222205-d73e1c7e250b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.8 h1:P1HhGGuLW4aAclzjtmJdf0mJOjVUZUzOTqkAkWL+l6w=
golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
golang.org/x/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=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
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 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
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-20200227125254-8fa46927fb4f/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-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/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
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.66.2 h1:XfR1dOYubytKy4Shzc2LHrrGhU0lDCfDGG1yLPmpgsI=
gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
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.3/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 h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/maulogger/v2 v2.3.1 h1:fwBYJne0pHvJrrIPHK+TAPfyxxbBEz46oVGez2x0ODE=
maunium.net/go/maulogger/v2 v2.3.1/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
maunium.net/go/mautrix v0.10.7 h1:QV5vbCY4g50N7r1ihdG6zEPfaPn/EVYjM5H+qfLy4RM=
maunium.net/go/mautrix v0.10.7/go.mod h1:k4Ng5oci83MEbqPL6KOjPdbU7f8v01KlMjR/zTQ+7mA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
maunium.net/go/mautrix v0.21.1 h1:Z+e448jtlY977iC1kokNJTH5kg2WmDpcQCqn+v9oZOA=
maunium.net/go/mautrix v0.21.1/go.mod h1:7F/S6XAdyc/6DW+Q7xyFXRSPb6IjfqMb1OMepQ8C8OE=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

163
housekeeping-d.go Normal file
View File

@@ -0,0 +1,163 @@
package main
import (
"time"
"github.com/dgraph-io/badger/v4"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser"
"github.com/timshannon/badgerhold/v4"
)
// clearEmails removes stored emails for users which no longer exist.
// meant to be called with other such housekeeping functions, so assumes
// the user cache is fresh.
func (app *appContext) clearEmails() {
app.debug.Println(lm.HousekeepingEmail)
emails := app.storage.GetEmails()
for _, email := range emails {
_, err := app.jf.UserByID(email.JellyfinID, false)
// Make sure the user doesn't exist, and no other error has occured
switch err.(type) {
case mediabrowser.ErrUserNotFound:
app.storage.DeleteEmailsKey(email.JellyfinID)
default:
continue
}
}
}
// clearDiscord does the same as clearEmails, but for Discord Users.
func (app *appContext) clearDiscord() {
app.debug.Println(lm.HousekeepingDiscord)
discordUsers := app.storage.GetDiscord()
for _, discordUser := range discordUsers {
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
}
}
}
// clearMatrix does the same as clearEmails, but for Matrix Users.
func (app *appContext) clearMatrix() {
app.debug.Println(lm.HousekeepingMatrix)
matrixUsers := app.storage.GetMatrix()
for _, matrixUser := range matrixUsers {
_, err := app.jf.UserByID(matrixUser.JellyfinID, false)
// Make sure the user doesn't exist, and no other error has occured
switch err.(type) {
case mediabrowser.ErrUserNotFound:
app.storage.DeleteMatrixKey(matrixUser.JellyfinID)
default:
continue
}
}
}
// clearTelegram does the same as clearEmails, but for Telegram Users.
func (app *appContext) clearTelegram() {
app.debug.Println(lm.HousekeepingTelegram)
telegramUsers := app.storage.GetTelegram()
for _, telegramUser := range telegramUsers {
_, err := app.jf.UserByID(telegramUser.JellyfinID, false)
// Make sure the user doesn't exist, and no other error has occured
switch err.(type) {
case mediabrowser.ErrUserNotFound:
app.storage.DeleteTelegramKey(telegramUser.JellyfinID)
default:
continue
}
}
}
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(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)
err := error(nil)
errorSource := 0
if maxAgeDays != 0 {
err = app.storage.db.DeleteMatching(&Activity{}, badgerhold.Where("Time").Lt(minAge))
}
if err == nil && keepCount != 0 {
// app.debug.Printf("Keeping %d records", keepCount)
err = app.storage.db.DeleteMatching(&Activity{}, (&badgerhold.Query{}).Reverse().SortBy("Time").Skip(keepCount))
if err != nil {
errorSource = 1
}
}
if err == badger.ErrTxnTooBig {
app.debug.Printf(lm.ActivityLogTxnTooBig)
list := []Activity{}
if errorSource == 0 {
app.storage.db.Find(&list, badgerhold.Where("Time").Lt(minAge))
} else {
app.storage.db.Find(&list, (&badgerhold.Query{}).Reverse().SortBy("Time").Skip(keepCount))
}
for _, record := range list {
app.storage.DeleteActivityKey(record.ID)
}
}
}
func newHousekeepingDaemon(interval time.Duration, app *appContext) *GenericDaemon {
d := NewGenericDaemon(interval, app,
func(app *appContext) {
app.debug.Println(lm.HousekeepingInvites)
app.checkInvites()
},
func(app *appContext) { app.clearActivities() },
)
d.Name("Housekeeping")
clearEmail := app.config.Section("email").Key("require_unique").MustBool(false)
clearDiscord := discordEnabled && (app.config.Section("discord").Key("require_unique").MustBool(false) || app.config.Section("discord").Key("disable_enable_role").MustBool(false))
clearTelegram := telegramEnabled && (app.config.Section("telegram").Key("require_unique").MustBool(false))
clearMatrix := matrixEnabled && (app.config.Section("matrix").Key("require_unique").MustBool(false))
clearPWR := app.config.Section("captcha").Key("enabled").MustBool(false) && !app.config.Section("captcha").Key("recaptcha").MustBool(false)
if clearEmail || clearDiscord || clearTelegram || clearMatrix {
d.appendJobs(func(app *appContext) { app.jf.CacheExpiry = time.Now() })
}
if clearEmail {
d.appendJobs(func(app *appContext) { app.clearEmails() })
}
if clearDiscord {
d.appendJobs(func(app *appContext) { app.clearDiscord() })
}
if clearTelegram {
d.appendJobs(func(app *appContext) { app.clearTelegram() })
}
if clearMatrix {
d.appendJobs(func(app *appContext) { app.clearMatrix() })
}
if clearPWR {
d.appendJobs(func(app *appContext) { app.clearPWRCaptchas() })
}
return d
}

View File

@@ -1,12 +1,11 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/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">

52
html/account-linking.html Normal file
View File

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

File diff suppressed because it is too large Load Diff

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="css/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,22 +13,44 @@
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 }};
window.reCAPTCHA = {{ .reCAPTCHA }};
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 = () => {
const el = document.getElementsByClassName("g-recaptcha")[0];
grecaptcha.render(el, {
"sitekey": window.reCAPTCHASiteKey,
"theme": document.documentElement.classList.contains("dark") ? "dark" : "light"
});
}
</script>
<script src="https://www.google.com/recaptcha/api.js?onload=reCAPTCHACallback&render=explicit" async defer></script>
{{ end }}
{{ end }}

View File

@@ -1,110 +1,63 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/bundle.css">
{{ template "header.html" . }}
<title>
{{ if .passwordReset }}
{{ .strings.passwordReset }}
{{ else }}
{{ .strings.pageTitle }}
{{ end }}
</title>
{{ if .passwordReset }}
<title>{{ .strings.passwordReset }}</title>
{{ else }}
<title>{{ .strings.pageTitle }}</title>
{{ end }}
<script>
window.redirectToJellyfin = {{ .redirectToJellyfin }};
</script>
</head>
<body class="max-w-full overflow-x-hidden section">
<div id="modal-success" class="modal">
<div class="modal-content card">
<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>
<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="modal-content card">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.confirmationRequired }}</span>
<p class="content mb-4">{{ .strings.confirmationRequiredMessage }}</p>
</div>
</div>
{{ if .telegramEnabled }}
<div id="modal-telegram" class="modal">
<div class="modal-content card">
<span class="heading mb-4">{{ .strings.linkTelegram }}</span>
<p class="content mb-4">{{ .strings.sendPIN }}</p>
<p class="text-center text-2xl mb-2">{{ .telegramPIN }}</p>
<a class="subheading link-center" href="{{ .telegramURL }}" target="_blank">
<span class="shield ~info mr-4">
<span class="icon">
<i class="ri-telegram-line"></i>
</span>
</span>
&#64;{{ .telegramUsername }}
</a>
<span class="button ~info @low full-width center mt-4" id="telegram-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}
{{ if .discordEnabled }}
<div id="modal-discord" class="modal">
<div class="modal-content card">
<span class="heading mb-4">{{ .strings.linkDiscord }}</span>
<p class="content mb-4"> {{ .discordSendPINMessage }}</p>
<h1 class="text-center text-2xl mb-2">{{ .discordPIN }}</h1>
<a id="discord-invite"></a>
<span class="button ~info @low full-width center mt-4" id="discord-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}
{{ if .matrixEnabled }}
<div id="modal-matrix" class="modal">
<div class="modal-content card">
<span class="heading mb-4">{{ .strings.linkMatrix }}</span>
<p class="content mb-4"> {{ .strings.matrixEnterUser }}</p>
<input type="text" class="input ~neutral @high" placeholder="@user:riot.im" id="matrix-userid">
<div class="subheading link-center mt-4">
<span class="shield ~info mr-4">
<span class="icon">
<i class="ri-chat-3-line"></i>
</span>
</span>
{{ .matrixUser }}
</div>
<span class="button ~info @low full-width center mt-4" id="matrix-send">{{ .strings.submit }}</span>
</div>
</div>
{{ end }}
<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>
{{ template "account-linking.html" . }}
<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="row baseline">
<span class="col heading">
<div class="flex flex-col md:flex-row gap-3 items-baseline mb-2">
<span class="heading mr-5">
{{ if .passwordReset }}
{{ .strings.passwordReset }}
{{ else }}
{{ .strings.createAccountHeader }}
{{ end }}
</span>
<span class="col subheading">
<span class="subheading">
{{ if .passwordReset }}
{{ .strings.enterYourPassword }}
{{ .strings.enterYourPassword }}
{{ else }}
{{ .helpMessage }}
{{ .helpMessage }}
{{ end }}
</span>
</div>
<div class="row">
<div class="col">
<div class="flex flex-col md:flex-row gap-3">
<div class="flex-1">
{{ if .userExpiry }}
<aside class="col aside sm ~warning" id="user-expiry-message"></aside>
{{ end }}
@@ -114,36 +67,36 @@
{{ .strings.username }}
<input type="text" class="input ~neutral @high mt-2 mb-4" placeholder="{{ .strings.username }}" id="create-username" aria-label="{{ .strings.username }}">
</label>
<label class="label supra" for="create-email">{{ .strings.emailAddress }}</label>
<input type="email" class="input ~neutral @high mt-2 mb-4" placeholder="{{ .strings.emailAddress }}" id="create-email" aria-label="{{ .strings.emailAddress }}" value="{{ .email }}">
{{ if .telegramEnabled }}
<span class="button ~info @low full-width center mb-4" id="link-telegram">{{ .strings.linkTelegram }}</span>
<span class="button ~info @low full-width center mb-4" id="link-telegram">{{ .strings.linkTelegram }} {{ if .telegramRequired }}({{ .strings.required }}){{ end }}</span>
{{ end }}
{{ if .discordEnabled }}
<span class="button ~info @low full-width center mb-4" id="link-discord">{{ .strings.linkDiscord }}</span>
<span class="button ~info @low full-width center mb-4" id="link-discord">{{ .strings.linkDiscord }} {{ if .discordRequired }}({{ .strings.required }}){{ end }}</span>
{{ end }}
{{ if .matrixEnabled }}
<span class="button ~info @low full-width center mb-4" id="link-matrix">{{ .strings.linkMatrix }}</span>
<span class="button ~info @low full-width center mb-4" id="link-matrix">{{ .strings.linkMatrix }} {{ if .matrixRequired }}({{ .strings.required }}){{ end }}</span>
{{ end }}
{{ if or (.telegramEnabled) (or .discordEnabled .matrixEnabled) }}
<div id="contact-via" class="unfocused">
<label class="row switch pb-4">
<input type="radio" name="contact-via" value="email"><span>Contact through Email</span>
<label class="row switch pb-4 unfocused">
<input type="checkbox" name="contact-via" value="email" id="contact-via-email" class="mr-2"><span>Contact through Email</span>
</label>
{{ if .telegramEnabled }}
<label class="row switch pb-4">
<input type="radio" name="contact-via" value="telegram" id="contact-via-telegram"><span>Contact through Telegram</span>
<label class="row switch pb-4 unfocused">
<input type="checkbox" name="contact-via" value="telegram" id="contact-via-telegram" class="mr-2"><span>Contact through Telegram</span>
</label>
{{ end }}
{{ if .discordEnabled }}
<label class="row switch pb-4">
<input type="radio" name="contact-via" value="discord" id="contact-via-discord"><span>Contact through Discord</span>
<label class="row switch pb-4 unfocused">
<input type="checkbox" name="contact-via" value="discord" id="contact-via-discord" class="mr-2"><span>Contact through Discord</span>
</label>
{{ end }}
{{ if .matrixEnabled }}
<label class="row switch pb-4">
<input type="radio" name="contact-via" value="matrix" id="contact-via-matrix"><span>Contact through Matrix</span>
<label class="row switch pb-4 unfocused">
<input type="checkbox" name="contact-via" value="matrix" id="contact-via-matrix" class="mr-2"><span>Contact through Matrix</span>
</label>
{{ end }}
</div>
@@ -151,7 +104,7 @@
{{ end }}
<label class="label supra" for="create-password">{{ .strings.password }}</label>
<input type="password" class="input ~neutral @high mt-2 mb-4" placeholder="{{ .strings.password }}" id="create-password" aria-label="{{ .strings.password }}">
<label class="label supra" for="create-reenter-password">{{ .strings.reEnterPassword }}</label>
<input type="password" class="input ~neutral @high mt-2 mb-4" placeholder="{{ .strings.password }}" id="create-reenter-password" aria-label="{{ .strings.reEnterPassword }}">
<label>
@@ -166,9 +119,12 @@
</label>
</form>
</div>
<div class="col">
<div class="flex-initial">
{{ if .fromUser }}
<aside class="col aside sm ~positive mb-4" id="invite-from-user" data-from="{{ .fromUser }}">{{ .strings.invitedBy }}</aside>
{{ end }}
<div class="card ~neutral @low mb-4">
<span class="label supra" for="inv-uses">{{ .strings.passwordRequirementsHeader }}</span>
<span class="label supra">{{ .strings.passwordRequirementsHeader }}</span>
<ul>
{{ range $key, $value := .requirements }}
<li class="" id="requirement-{{ $key }}" min="{{ $value }}">
@@ -177,8 +133,17 @@
{{ end }}
</ul>
</div>
{{ if .captcha }}
<div class="card ~neutral @low mb-4">
<span class="label supra mb-2">CAPTCHA {{ if not .reCAPTCHA }}<span id="captcha-regen" title="{{ .strings.refresh }}" class="badge lg @low ~info ml-2 float-right"><i class="ri-refresh-line"></i></span><span id="captcha-success" class="badge lg @low ~critical ml-2 float-right"><i class="ri-close-line"></i></span>{{ end }}</span>
<div id="captcha-img" class="mt-2 mb-2 {{ if .reCAPTCHA }}g-recaptcha{{ end }}"></div>
{{ if not .reCAPTCHA }}
<input class="field ~neutral @low" id="captcha-input" class="mt-2" placeholder="CAPTCHA">
{{ end }}
</div>
{{ end }}
{{ if .contactMessage }}
<aside class="col aside sm ~info mt-4">{{ .contactMessage }}</aside>
<aside class="col aside sm ~info mt-4">{{ .contactMessage }}</aside>
{{ end }}
</div>
</div>

View File

@@ -1,12 +1,32 @@
<meta charset="utf-8">
<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="css/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>

37
html/login-modal.html Normal file
View File

@@ -0,0 +1,37 @@
<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-11/12 sm:w-4/5 lg:w-1/2">
{{ $hasTwoCards := 0 }}
{{ if index . "LoginMessageEnabled" }}
{{ if .LoginMessageEnabled }}
{{ $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 }}
{{ $hasTwoCards = 1 }}
<div class="card mx-2 flex-initial w-full lg:w-[35%] mb-4 lg:mb-0 dark:~d_neutral @low content">
<span class="heading row">{{ .strings.loginNotAdmin }}</span>
<a class="button ~info h-12 w-full" href="{{ .pages.Base }}{{ .pages.MyAccount }}"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
</div>
{{ end }}
{{ end }}
<form class="card mx-2 form-login w-full {{ if eq $hasTwoCards 1 }}lg:w-[55%]{{ end }} mb-0" href="">
<span class="heading">{{ .strings.login }}</span>
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.username }}" id="login-user">
<input type="password" class="field input ~neutral @high mb-4" placeholder="{{ .strings.password }}" id="login-password">
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.login }}</span>
{{ if index . "pwrEnabled" }}
{{ if .pwrEnabled }}
<span class="button ~info @low full-width center supra submit my-2" id="modal-login-pwr">{{ .strings.resetPassword }}</span>
{{ end }}
{{ end }}
</label>
</form>
</div>
</div>

View File

@@ -1,7 +1,6 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/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>

View File

@@ -1,266 +1,340 @@
<!DOCTYPE html>
<html lang="en" class="light">
<head>
<link rel="stylesheet" type="text/css" href="css/bundle.css">
{{ template "header.html" . }}
<title>{{ .lang.Strings.pageTitle }}</title>
</head>
<body class="max-w-full overflow-x-hidden section">
<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" id="lang-list">
</div>
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4 items-center">
<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" . }}
</div>
</span>
</div>
<div class="page-container" id="page-container">
<div class="card ~neutral @low mb-2">
<div class="row">
<img class="banner header" src="banner.svg" alt="jfa-go" />
</div>
<div class="row col flex center">
<span class="heading welcome">{{ .lang.StartPage.welcome }}</span>
</div>
<div class="row col flex center">
<p class="content my-2">{{ .lang.StartPage.pressStart }}</p>
</div>
<section class="section ~neutral banner footer flex-expand middle">
<span class="button ~warning" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
</div>
<div class="card lg:container sectioned ~neutral @low flex flex-col gap-4 justify-between items-center">
<img class="w-[105%] max-w-none" src="banner.svg" alt="jfa-go" />
<span class="heading welcome">{{ .lang.StartPage.welcome }}</span>
<p class="content text-center">{{ .lang.StartPage.pressStart }}</p>
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="support">{{ .lang.StartPage.httpsNotice }}</span>
<span class="button ~urge @low next">{{ .lang.StartPage.start }}</span>
</section>
</div>
<div class="card ~neutral @low mb-2 unfocused">
<span class="heading">{{ .lang.Language.title }}</span>
<p class="content my-2" id="language-description"></p>
<label class="label">
<span class="mt-4">{{ .lang.Language.defaultAdminLang }}</span>
<div class="select ~neutral @low mt-4 mb-2">
<select id="ui-language-admin">
</select>
</div>
</label>
<label class="label">
<span class="mt-4">{{ .lang.Language.defaultFormLang }}</span>
<div class="select ~neutral @low mt-4 mb-2">
<select id="ui-language-form">
</select>
</div>
</label>
<label class="label">
<span class="mt-4">{{ .lang.Language.defaultEmailLang }}</span>
<div class="select ~neutral @low mt-4 mb-2">
<select id="email-language">
</select>
</div>
</label>
<section class="section ~neutral banner footer flex-expand middle">
<div class="card lg:container sectioned ~neutral @low unfocused">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.Language.title }}</span>
<p class="content" id="language-description"></p>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Language.defaultAdminLang }}</span>
<div class="select ~neutral @low">
<select id="ui-language-admin">
</select>
</div>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Language.defaultFormLang }}</span>
<div class="select ~neutral @low">
<select id="ui-language-form">
</select>
</div>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Language.defaultEmailLang }}</span>
<div class="select ~neutral @low">
<select id="email-language">
</select>
</div>
</label>
</section>
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</section>
</div>
<div class="card ~neutral @low mb-2 unfocused">
<span class="heading">{{ .lang.General.title }}</span>
<div class="row">
<div class="col">
<label class="label">
<span class="mt-4">{{ .lang.General.listenAddress }}</span>
<input type="url" class="input ~neutral @low mt-4 mb-2" id="ui-host" value="0.0.0.0">
</label>
<label class="row switch">
<input type="checkbox" class="mr-2" id="advanced-tls"><span>{{ .lang.General.useHTTPS }}</span>
</label>
<p class="support mb-2 mt-1">{{ .lang.General.useHTTPSNotice }}</p>
<label class="label">
<span class="mt-4">{{ .lang.General.pathToCertificate }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="advanced-tls_cert">
</label>
<label class="label">
<span class="mt-4">{{ .lang.General.pathToKeyFile }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="advanced-tls_key">
</label>
<span class="heading">{{ .lang.Updates.title }}</span>
<p class="content my-2" id="updates-description"></p>
<label class="row switch pb-4">
<input type="checkbox" class="mr-2" id="updates-enabled" checked><span>{{ .lang.Strings.enabled }}</span>
</label>
<label class="label">
<span>{{ .lang.Updates.updateChannel }}</span>
<div class="select ~neutral @low mt-4 mb-2">
<select id="updates-channel">
<option value="stable">{{ .lang.Updates.stable }}</option>
<option value="unstable">{{ .lang.Updates.unstable }}</option>
</select>
<div class="card lg:container sectioned ~neutral @low unfocused">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.General.title }}</span>
<div class="flex flex-row gap-2 justify-between">
<div class="flex flex-col gap-2">
<div class="flex flex-row gap-2 justify-between">
<label class="label flex flex-col gap-2 grow">
<span>{{ .lang.General.listenAddress }}</span>
<input type="url" class="input ~neutral @low" id="ui-host" value="0.0.0.0">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.port }}</span>
<input type="number" class="input ~neutral @low" id="ui-port" value="8056">
</label>
</div>
</label>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="advanced-tls"><span>{{ .lang.General.useHTTPS }}</span></div>
<p class="support">{{ .lang.General.useHTTPSNotice }}</p>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.General.pathToCertificate }}</span>
<input type="text" class="input ~neutral @low" id="advanced-tls_cert">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.General.pathToKeyFile }}</span>
<input type="text" class="input ~neutral @low" id="advanced-tls_key">
</label>
<span class="heading">{{ .lang.Updates.title }}</span>
<p class="content" id="updates-description"></p>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="updates-enabled" checked><span>{{ .lang.Strings.enabled }}</span></div>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Updates.updateChannel }}</span>
<div class="select ~neutral @low">
<select id="updates-channel">
<option value="stable">{{ .lang.Updates.stable }}</option>
<option value="unstable">{{ .lang.Updates.unstable }}</option>
</select>
</div>
</label>
</div>
<div class="flex flex-col gap-2">
<label class="label flex flex-col gap-2">
<span>{{ .lang.General.httpsPort }}</span>
<input type="number" class="input ~neutral @low" id="advanced-tls_port" value="8057">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.General.urlBase }} ({{ .lang.Strings.optional }})</span>
<input type="text" class="input ~neutral @low" id="ui-url_base" placeholder="/mysubfolder">
<p class="support">{{ .lang.General.urlBaseNotice }}</p>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.General.externalURL }}</span>
<input type="text" class="input ~neutral @low" id="ui-jfa_url" placeholder="https://jellyf.in/mysubfolder">
<p class="support">{{ .lang.General.externalURLNotice }}</p>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.theme }}</span>
<div class="select ~neutral @low">
<select id="ui-theme">
<option value="Jellyfin (Dark)">{{ .lang.General.darkTheme }}</option>
<option value="Default (Light)">{{ .lang.General.lightTheme }}</option>
</select>
</div>
</label>
<span class="heading">{{ .lang.Proxy.title }}</span>
<p class="content" id="proxy-description">{{ .lang.Proxy.description }}</p>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="advanced-proxy"><span>{{ .lang.Strings.enabled }}</span></div>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Proxy.protocol }}</span>
<div class="select ~neutral @low">
<select id="advanced-proxy_protocol">
<option value="http">HTTP</option>
<option value="socks">SOCKS5</option>
</select>
</div>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Proxy.address }}</span>
<input type="text" class="input ~neutral @low" id="advanced-proxy_address">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.username }}</span>
<input type="text" class="input ~neutral @low" id="advanced-proxy_user">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.password }}</span>
<input type="text" class="input ~neutral @low" id="advanced-proxy_password">
</label>
</div>
</div>
<div class="col">
<label class="label">
<span class="mt-4">{{ .lang.Strings.port }}</span>
<input type="number" class="input ~neutral @low mt-4 mb-2" id="ui-port" value="8056">
</label>
<label class="label">
<span class="mt-4">{{ .lang.General.httpsPort }}</span>
<input type="number" class="input ~neutral @low mt-4 mb-2" id="advanced-tls_port" value="8057">
</label>
<label class="label">
<span class="mt-4">{{ .lang.General.urlBase }} ({{ .lang.Strings.optional }})</span>
<input type="url" class="input ~neutral @low mt-4" id="ui-url_base">
<p class="support mb-2 mt-1">{{ .lang.General.urlBaseNotice }}</p>
</label>
<label class="label">
<span>{{ .lang.Strings.theme }}</span>
<div class="select ~neutral @low mt-4 mb-2">
<select id="ui-theme">
<option value="Jellyfin (Dark)">{{ .lang.General.darkTheme }}</option>
<option value="Default (Light)">{{ .lang.General.lightTheme }}</option>
</select>
</div>
</label>
</div>
</div>
<section class="section ~neutral banner footer flex-expand middle">
</section>
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</section>
</div>
<div class="card ~neutral @low mb-2 unfocused">
<span class="heading">{{ .lang.Login.title }}</span>
<p class="content my-2">{{ .lang.Login.description }}</p>
<div class="pl-4">
<label class="row switch pb-4">
<input type="radio" class="mr-2" name="ui-jellyfin_login" value="true" checked><span>{{ .lang.Login.authorizeWithJellyfin }}</span>
</label>
<label class="row switch pl-4 pb-4">
<input type="checkbox" class="mr-2" id="ui-admin_only"><span>{{ .lang.Login.adminOnly }}</span>
</label>
<label class="row switch pb-4">
<input type="radio" class="mr-2" name="ui-jellyfin_login" value="false"><span>{{ .lang.Login.authorizeManual }}</span>
</label>
</div>
<div id="login-manual">
<label class="label">
<span class="mt-4">{{ .lang.Strings.username }}</span>
<input type="text" id="ui-username" class="input ~neutral @low mt-4 mb-2" placeholder="{{ .lang.Strings.username }}">
</label>
<label class="label">
<span>{{ .lang.Strings.password }}</span>
<input type="password" id="ui-password" class="input ~neutral @low mt-4 mb-2" placeholder="{{ .lang.Strings.password }}">
</label>
<label class="label">
<span>{{ .lang.Strings.emailAddress }} ({{ .lang.Strings.optional }})</span>
<input type="email" id="ui-email" class="input ~neutral @low mt-4" placeholder="email@address">
<span class="support mb-2 mt-1">{{ .lang.Login.emailNotice }}</span>
</label>
</div>
<section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</section>
</div>
<div class="card ~neutral @low mb-2 unfocused">
<span class="heading">{{ .lang.JellyfinEmby.title }}</span>
<p class="content my-2">{{ .lang.JellyfinEmby.description }}</p>
<div class="row">
<div class="col">
<label class="label">
<span>{{ .lang.Strings.serverType }}</span>
<div class="select ~neutral @low mt-4">
<select id="jellyfin-type">
<option value="jellyfin">Jellyfin</option>
<option value="emby">Emby</option>
</select>
</div>
<p class="support mb-2 mt-1">{{ .lang.JellyfinEmby.embyNotice }}</p>
<div class="card lg:container sectioned ~neutral @low unfocused">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.Login.title }}</span>
<p class="content">{{ .lang.Login.description }}</p>
<div class="flex flex-col gap-2">
<label class="label flex flex-col gap-2">
<div class="switch"><input type="radio" name="ui-jellyfin_login" value="true" checked><span>{{ .lang.Login.authorizeWithJellyfin }}</span></div>
</label>
<label class="label">
<span class="mt-4">{{ .lang.JellyfinEmby.replaceJellyfin }} ({{ .lang.Strings.optional }})</span>
<input type="text" class="input ~neutral @low mt-4" id="jellyfin-substitute_jellyfin_strings">
<p class="support mb-2 mt-1">{{ .lang.JellyfinEmby.replaceJellyfinNotice }}</p>
<div class="pl-4 flex flex-col gap-2">
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="ui-admin_only" checked><span>{{ .lang.Login.adminOnly }}</span></div>
</label>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="ui-allow_all"><span>{{ .lang.Login.allowAll }}</span></div>
<p class="support" id="description-ui-allow_all">{{ .lang.Login.allowAllDescription }}</p>
</label>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="label flex flex-col gap-2">
<div class="switch"><input type="radio" name="ui-jellyfin_login" value="false"><span>{{ .lang.Login.authorizeManual }}</span></div>
</label>
<label class="label">
<span class="mt-4">{{ .lang.Strings.username }}</span>
<input type="text" id="jellyfin-username" class="input ~neutral @low mt-4 mb-2" placeholder="{{ .lang.Strings.username }}">
<p class="support">{{ .lang.Login.authorizeManualUserPageNotice }}</p>
</div>
<div class ="flex flex-col gap-2" id="login-manual">
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.username }}</span>
<input type="text" id="ui-username" class="input ~neutral @low" placeholder="{{ .lang.Strings.username }}">
</label>
<label class="label">
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.password }}</span>
<input type="password" id="jellyfin-password" class="input ~neutral @low mt-4 mb-2" placeholder="{{ .lang.Strings.password }}">
<input type="password" id="ui-password" class="input ~neutral @low" placeholder="{{ .lang.Strings.password }}">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.emailAddress }} ({{ .lang.Strings.optional }})</span>
<input type="email" id="ui-email" class="input ~neutral @low" placeholder="email@address">
<span class="support">{{ .lang.Login.emailNotice }}</span>
</label>
</div>
<div class="col">
<label class="label">
<span class="mt-4">{{ .lang.Strings.serverAddress }} ({{ .lang.JellyfinEmby.internal }})</span>
<input type="url" class="input ~neutral @low mt-4 mb-2" id="jellyfin-server" placeholder="http://jellyf.in:80">
</label>
<label class="label">
<span class="mt-4">{{ .lang.Strings.serverAddress }} ({{ .lang.JellyfinEmby.external }})</span>
<input type="url" class="input ~neutral @low mt-4" id="jellyfin-public_server" placeholder="https://jellyf.in">
<p class="support mb-2 mt-1">{{ .lang.JellyfinEmby.addressExternalNotice }}</p>
</label>
</div>
</div>
<section class="section ~neutral banner footer flex-expand middle">
</section>
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</section>
</div>
<div class="card lg:container sectioned ~neutral @low unfocused">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.JellyfinEmby.title }}</span>
<p class="content">{{ .lang.JellyfinEmby.description }}</p>
<div class="flex flex-row gap-2 justify-between">
<div class="flex flex-col gap-2 grow">
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.serverType }}</span>
<div class="select ~neutral @low">
<select id="jellyfin-type">
<option value="jellyfin">Jellyfin</option>
<option value="emby">Emby</option>
</select>
</div>
<p class="support">{{ .lang.JellyfinEmby.embyNotice }}</p>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.JellyfinEmby.replaceJellyfin }} ({{ .lang.Strings.optional }})</span>
<input type="text" class="input ~neutral @low" id="jellyfin-substitute_jellyfin_strings">
<p class="support">{{ .lang.JellyfinEmby.replaceJellyfinNotice }}</p>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.username }}</span>
<input type="text" id="jellyfin-username" class="input ~neutral @low" placeholder="{{ .lang.Strings.username }}">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.password }}</span>
<input type="password" id="jellyfin-password" class="input ~neutral @low" placeholder="{{ .lang.Strings.password }}">
</label>
</div>
<div class="flex flex-col gap-2 grow ">
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.serverAddress }} ({{ .lang.JellyfinEmby.internal }})</span>
<input type="url" class="input ~neutral @low" id="jellyfin-server" placeholder="http://jellyf.in:80">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.serverAddress }} ({{ .lang.JellyfinEmby.external }})</span>
<input type="url" class="input ~neutral @low" id="jellyfin-public_server" placeholder="https://jellyf.in">
<p class="support">{{ .lang.JellyfinEmby.addressExternalNotice }}</p>
</label>
</div>
</div>
</section>
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div class="flex flex-row gap-2">
<span class="button ~urge @low" id="jellyfin-test-connection">{{ .lang.JellyfinEmby.testConnection }}</span>
<span class="button ~urge @low next" disabled>{{ .lang.Strings.next }}</span>
</div>
</section>
</div>
<div class="card ~neutral @low mb-2 unfocused">
<span class="heading">{{ .lang.Ombi.title }}</span>
<p class="content my-2">{{ .lang.Ombi.description }}</p>
<label class="row switch pb-4">
<input type="checkbox" class="mr-2" id="ombi-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label>
<label class="label">
<span class="mt-4">{{ .lang.Strings.serverAddress }}</span>
<input type="url" class="input ~neutral @low mt-4 mb-2" id="ombi-server" placeholder="ombi.jellyf.in">
</label>
<label class="label">
<span class="mt-4">{{ .lang.Strings.apiKey }}</span>
<input type="text" class="input ~neutral @low mt-4" id="ombi-api_key">
<p class="support mb-2 mt-1">{{ .lang.Ombi.apiKeyNotice }}</p>
</label>
<section class="section ~neutral banner footer flex-expand middle">
<div class="card lg:container sectioned ~neutral @low unfocused">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.Ombi.title }}</span>
<p class="content">{{ .lang.Ombi.description }}</p>
<aside class="aside ~warning" id="ombi-stability-warning">{{ .lang.Ombi.stabilityWarning }}</aside>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="ombi-enabled"><span>{{ .lang.Strings.enabled }}</span></div>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.serverAddress }}</span>
<input type="url" class="input ~neutral @low" id="ombi-server" placeholder="ombi.jellyf.in">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.apiKey }}</span>
<input type="text" class="input ~neutral @low" id="ombi-api_key">
<p class="support">{{ .lang.Ombi.apiKeyNotice }}</p>
</label>
<span class="heading">{{ .lang.Jellyseerr.title }}</span>
<p class="content">{{ .lang.Jellyseerr.description }}</p>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="jellyseerr-enabled"><span>{{ .lang.Strings.enabled }}</span></div>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.serverAddress }}</span>
<input type="url" class="input ~neutral @low" id="jellyseerr-server" placeholder="https://jellyseerr.jellyf.in:5055">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.apiKey }}</span>
<input type="text" class="input ~neutral @low" id="jellyseerr-api_key">
</label>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="jellyseerr-import_existing" checked><span>{{ .lang.Jellyseerr.importExisting }}</span></div>
<p class="support">{{ .lang.Jellyseerr.importExistingDescription }}</p>
</label>
</section>
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</div>
</section>
</div>
<div class="card ~neutral @low mb-2 unfocused">
<span class="heading">{{ .lang.Messages.title }}</span>
<p class="content my-2" id="messages-description"></p>
<label class="row switch pb-4">
<input type="checkbox" class="mr-2" id="messages-enabled" checked><span>{{ .lang.Strings.enabled }}</span>
</label>
<label class="label">
<span class="mt-4">{{ .lang.Email.dateFormat }}</span>
<input type="text" class="input ~neutral @low mt-4" id="email-date_format" value="%d/%m/%y">
<p class="support mb-2 mt-1" id="email-dateformat-notice"></p>
</label>
<div>
<label class="row switch pb-4">
<input type="radio" class="mr-2" name="email-24h" value="true" checked><span>{{ .lang.Strings.time24h }}</span>
<div class="card lg:container sectioned ~neutral @low unfocused">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.UserPage.title }}</span>
<p class="content">{{ .lang.UserPage.description }}</p>
<p class="content">{{ .lang.UserPage.customizeMessages }}</p>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="userpage-enabled"><span>{{ .lang.Strings.enabled }}</span></div>
<p class="support">{{ .lang.UserPage.requiredSettings }}</p>
</label>
<label class="row switch pb-4">
<input type="radio" class="mr-2" name="email-24h" value="false"><span>{{ .lang.Strings.time12h }}</span>
</section>
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</div>
</section>
</div>
<div class="card lg:container sectioned ~neutral @low unfocused">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.Messages.title }}</span>
<p class="content" id="messages-description"></p>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="messages-enabled" checked><span>{{ .lang.Strings.enabled }}</span></div>
</label>
</div>
<div id="email-sect">
<span class="heading">{{ .lang.Email.title }}</span>
<p class="content my-2" id="email-description"></p>
<div class="row">
<div class="col">
<label class="label">
<label class="label flex flex-col gap-2">
<span>{{ .lang.Email.dateFormat }}</span>
<input type="text" class="input ~neutral @low" id="email-date_format" value="%d/%m/%y">
<p class="support" id="email-dateformat-notice"></p>
</label>
<div class="flex flex-col gap-2">
<label class="label flex flex-col gap-2">
<div class="switch"><input type="radio" class="mr-2" name="email-24h" value="true" checked><span>{{ .lang.Strings.time24h }}</span></div>
</label>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="radio" class="mr-2" name="email-24h" value="false"><span>{{ .lang.Strings.time12h }}</span></div>
</label>
</div>
<div id="email-sect" class="flex flex-row gap-2 justify-between">
<div class="flex flex-col gap-2">
<span class="heading">{{ .lang.Email.title }}</span>
<p class="content" id="email-description"></p>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Email.method }}</span>
<div class="select ~neutral @low mt-4 mb-2">
<div class="select ~neutral @low">
<select id="email-method">
<option value="">{{ .lang.Strings.disabled }}</option>
<option value="smtp">SMTP</option>
@@ -268,223 +342,228 @@
</select>
</div>
</label>
<label class="row switch">
<input type="checkbox" class="mr-2" id="email-no_username"><span>{{ .lang.Email.useEmailAsUsername }}</span>
<p class="support mb-2 mt-1">{{ .lang.Email.useEmailAsUsernameNotice }}</p>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="email-no_username"><span>{{ .lang.Email.useEmailAsUsername }}</span></div>
<p class="support">{{ .lang.Email.useEmailAsUsernameNotice }}</p>
</label>
<label class="label">
<span class="mt-4">{{ .lang.Email.fromAddress }}</span>
<input type="email" class="input ~neutral @low mt-4 mb-2" id="email-address" placeholder="mail@jellyf.in">
<label class="label flex flex-col gap-2">
<span>{{ .lang.Email.fromAddress }}</span>
<input type="email" class="input ~neutral @low" id="email-address" placeholder="mail@jellyf.in">
</label>
<label class="label">
<span class="mt-4">{{ .lang.Email.senderName }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="email-from" value="Jellyfin">
<label class="label flex flex-col gap-2">
<span>{{ .lang.Email.senderName }}</span>
<input type="text" class="input ~neutral @low" id="email-from" value="Jellyfin">
</label>
</div>
<div class="col">
<div id="email-smtp">
<p class="text-2xl font-semibold mb-2">SMTP</p>
<label class="label">
<span>{{ .lang.Email.encryption }}</span>
<div class="select ~neutral @low mt-4 mb-2">
<select id="smtp-encryption">
<option value="starttls">STARTTLS ({{ .lang.Strings.port }} 587)</option>
<option value="ssl_tls">SSL/TLS ({{ .lang.Strings.port }} 465)</option>
</select>
</div>
</label>
<label class="label">
<span class="mt-4">{{ .lang.Strings.serverAddress }}</span>
<input type="url" class="input ~neutral @low mt-4 mb-2" id="smtp-server" placeholder="smtp.jellyf.in">
</label>
<label class="label">
<span class="mt-4">{{ .lang.Strings.port }}</span>
<input type="number" class="input ~neutral @low mt-4 mb-2" id="smtp-port" placeholder="587">
</label>
<label class="label">
<span class="mt-4">{{ .lang.Strings.username }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="smtp-username">
</label>
<label class="label">
<span class="mt-4">{{ .lang.Strings.password }}</span>
<input type="password" class="input ~neutral @low mt-4 mb-2" id="smtp-password">
</label>
</div>
<div id="email-mailgun">
<p class="text-2xl font-semibold mb-2">Mailgun</p>
<label class="label">
<span class="mt-4">{{ .lang.Email.mailgunApiURL }}</span>
<input type="url" class="input ~neutral @low mt-4 mb-2" id="mailgun-api_url" placeholder="https://api.eu.mailgun.net/v3/mail.jellyf.in/messages">
</label>
<label class="label">
<span class="mt-4">{{ .lang.Strings.apiKey }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="mailgun-api_key">
</label>
</div>
<div id="email-smtp" class="flex flex-col gap-2 min-w-[40%]">
<p class="text-2xl font-semibold">SMTP</p>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Email.encryption }}</span>
<div class="select ~neutral @low">
<select id="smtp-encryption">
<option value="starttls">STARTTLS ({{ .lang.Strings.port }} 587)</option>
<option value="ssl_tls">SSL/TLS ({{ .lang.Strings.port }} 465)</option>
</select>
</div>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.serverAddress }}</span>
<input type="url" class="input ~neutral @low" id="smtp-server" placeholder="smtp.jellyf.in">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.port }}</span>
<input type="number" class="input ~neutral @low" id="smtp-port" placeholder="587">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.username }}</span>
<input type="text" class="input ~neutral @low" id="smtp-username">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.password }}</span>
<input type="password" class="input ~neutral @low" id="smtp-password">
</label>
</div>
<div id="email-mailgun" class="flex flex-col gap-2 min-w-[40%]">
<p class="text-2xl font-semibold">Mailgun</p>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Email.mailgunApiURL }}</span>
<input type="url" class="input ~neutral @low" id="mailgun-api_url" placeholder="https://api.eu.mailgun.net/v3/mail.jellyf.in/messages">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.apiKey }}</span>
<input type="text" class="input ~neutral @low" id="mailgun-api_key">
</label>
</div>
</div>
</div>
<section class="section ~neutral banner footer flex-expand middle">
</section>
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</div>
</section>
</div>
<div class="card ~neutral @low mb-2 unfocused related-to-email">
<span class="heading">{{ .lang.Notifications.title }}</span>
<p class="content my-2">{{ .lang.Notifications.description }}</p>
<label class="row switch pb-4">
<input type="checkbox" class="mr-2" id="notifications-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label>
<span class="heading">{{ .lang.WelcomeEmails.title }}</span>
<p class="content my-2">{{ .lang.WelcomeEmails.description }}</p>
<label class="row switch pb-4">
<input type="checkbox" class="mr-2" id="welcome_email-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label>
<label class="label">
<span class="mt-4">{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="welcome_email-subject" placeholder="{{ .emailLang.WelcomeEmail.title }}">
</label>
<section class="section ~neutral banner footer flex-expand middle">
<div class="card lg:container sectioned ~neutral @low unfocused related-to-email">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.Notifications.title }}</span>
<p class="content">{{ .lang.Notifications.description }}</p>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="notifications-enabled"><span>{{ .lang.Strings.enabled }}</span></div>
</label>
<span class="heading">{{ .lang.WelcomeEmails.title }}</span>
<p class="content">{{ .lang.WelcomeEmails.description }}</p>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="welcome_email-enabled"><span>{{ .lang.Strings.enabled }}</span></div>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral @low" id="welcome_email-subject" placeholder="{{ .emailLang.WelcomeEmail.title }}">
</label>
</section>
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</div>
</section>
</div>
<div class="card ~neutral @low mb-2 unfocused related-to-email">
<span class="heading">{{ .lang.InviteEmails.title }}</span>
<p class="content my-2">{{ .lang.InviteEmails.description }}</p>
<label class="row switch pb-4">
<input type="checkbox" class="mr-2" id="invite_emails-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label>
<label class="label">
<span class="mt-4">{{ .lang.Strings.URL }}</span>
<input type="url" class="input ~neutral @low mt-4 mb-2" id="invite_emails-url_base" placeholder="https://accounts.jellyf.in/invite">
</label>
<label class="label">
<span class="mt-4">{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="invite_emails-subject" placeholder="{{ .emailLang.InviteEmail.title }}">
</label>
<section class="section ~neutral banner footer flex-expand middle">
<div class="card lg:container sectioned ~neutral @low unfocused related-to-email">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.InviteEmails.title }}</span>
<p class="content">{{ .lang.InviteEmails.description }}</p>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="invite_emails-enabled"><span>{{ .lang.Strings.enabled }}</span></div>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral @low" id="invite_emails-subject" placeholder="{{ .emailLang.InviteEmail.title }}">
</label>
</section>
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</div>
</section>
</div>
<div id="password-resets" class="card ~neutral @low mb-2 unfocused related-to-email">
<span class="heading">{{ .lang.PasswordResets.title }}</span>
<p class="content my-2">{{ .lang.PasswordResets.description }}</p>
<label class="row switch pb-4">
<input type="checkbox" class="mr-2" id="password_resets-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label>
<label class="label">
<span class="mt-4">{{ .lang.PasswordResets.pathToJellyfin }}</span>
<input type="text" class="input ~neutral @low mt-4" id="password_resets-watch_directory" placeholder="/config/jellyfin">
<p class="support mb-2 mt-1">{{ .lang.PasswordResets.pathToJellyfinNotice }}</p>
</label>
<label class="switch">
<input type="checkbox" class="mr-2" id="password_resets-link_reset"><span>{{ .lang.PasswordResets.resetLinks }}</span>
<p class="support mb-2 mt-1">{{ .lang.PasswordResets.resetLinksNotice }}</p>
</label>
<label class="switch">
<input type="checkbox" class="mr-2" id="password_resets-set_password"><span>{{ .lang.PasswordResets.setPassword }}</span>
<p class="support mb-2 mt-1">{{ .lang.PasswordResets.setPasswordNotice }}</p>
</label>
<label class="label">
<p class="mt-4">{{ .lang.PasswordResets.resetLinksLanguage }}</p>
<div class="select ~neutral @low mt-4 mb-2">
<select id="password_resets-language">
</select>
</div>
</label>
<label class="row label">
<span class="mt-4">{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="password_resets-subject" placeholder="{{ .emailLang.PasswordReset.title }}">
</label>
<section class="section ~neutral banner footer flex-expand middle">
<div id="password-resets" class="card lg:container sectioned ~neutral @low unfocused related-to-email">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.PasswordResets.title }}</span>
<p class="content">{{ .lang.PasswordResets.description }}</p>
<p class="content" id="password_resets-more-info">{{ .lang.PasswordResets.moreInfo }}</p>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="password_resets-enabled"><span>{{ .lang.Strings.enabled }}</span></div>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.PasswordResets.pathToJellyfin }}</span>
<input type="text" class="input ~neutral @low" id="password_resets-watch_directory" placeholder="/config/jellyfin">
<p class="support">{{ .lang.PasswordResets.pathToJellyfinNotice }}</p>
</label>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="password_resets-link_reset"><span>{{ .lang.PasswordResets.resetLinks }}</span></div>
<p class="support">{{ .lang.PasswordResets.resetLinksNotice }} {{ .lang.PasswordResets.resetLinksRequiredForUserPage }}</p>
</label>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="password_resets-set_password"><span>{{ .lang.PasswordResets.setPassword }}</span></div>
<p class="support">{{ .lang.PasswordResets.setPasswordNotice }}</p>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.PasswordResets.resetLinksLanguage }}</span>
<div class="select ~neutral @low">
<select id="password_resets-language">
</select>
</div>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral @low" id="password_resets-subject" placeholder="{{ .emailLang.PasswordReset.title }}">
</label>
</section>
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</div>
</section>
</div>
<div class="card ~neutral @low mb-2 unfocused">
<span class="heading">{{ .lang.PasswordValidation.title }}</span>
<p class="content my-2">{{ .lang.PasswordValidation.description }}</p>
<label class="row switch pb-4">
<input type="checkbox" class="mr-2" id="password_validation-enabled" checked><span>{{ .lang.Strings.enabled }}</span>
</label>
<label class="label">
<span class="mt-4">{{ .lang.PasswordValidation.length }}</span>
<input type="number" class="input ~neutral @low mt-4 mb-2" id="password_validation-min_length" value="8">
</label>
<label class="label">
<span class="mt-4">{{ .lang.PasswordValidation.uppercase }}</span>
<input type="number" class="input ~neutral @low mt-4 mb-2" id="password_validation-upper" value="1">
</label>
<label class="label">
<span class="mt-4">{{ .lang.PasswordValidation.lowercase }}</span>
<input type="number" class="input ~neutral @low mt-4 mb-2" id="password_validation-lower" value="0">
</label>
<label class="label">
<span class="mt-4">{{ .lang.PasswordValidation.numbers }}</span>
<input type="number" class="input ~neutral @low mt-4 mb-2" id="password_validation-number" value="0">
</label>
<label class="label">
<span class="mt-4">{{ .lang.PasswordValidation.special }}</span>
<input type="number" class="input ~neutral @low mt-4 mb-2" id="password_validation-special" value="0">
</label>
<section class="section ~neutral banner footer flex-expand middle">
<div class="card lg:container sectioned ~neutral @low unfocused">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.PasswordValidation.title }}</span>
<p class="content">{{ .lang.PasswordValidation.description }}</p>
<label class="label flex flex-col gap-2">
<div class="switch"><input type="checkbox" id="password_validation-enabled" checked><span>{{ .lang.Strings.enabled }}</span></div>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.PasswordValidation.length }}</span>
<input type="number" class="input ~neutral @low" id="password_validation-min_length" value="8">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.PasswordValidation.uppercase }}</span>
<input type="number" class="input ~neutral @low" id="password_validation-upper" value="1">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.PasswordValidation.lowercase }}</span>
<input type="number" class="input ~neutral @low" id="password_validation-lower" value="0">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.PasswordValidation.numbers }}</span>
<input type="number" class="input ~neutral @low" id="password_validation-number" value="0">
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.PasswordValidation.special }}</span>
<input type="number" class="input ~neutral @low" id="password_validation-special" value="0">
</label>
</section>
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</div>
</section>
</div>
<div class="card ~neutral @low mb-2 unfocused">
<span class="heading">{{ .lang.HelpMessages.title }}</span>
<p class="content my-2">{{ .lang.HelpMessages.description }}</p>
<label class="label">
<span class="mt-4">{{ .lang.HelpMessages.contactMessage }}</span>
<input type="text" class="input ~neutral @low mt-4" id="ui-contact_message">
<p class="support mb-2 mt-1">{{ .lang.HelpMessages.contactMessageNotice }}</p>
</label>
<label class="label">
<span class="mt-4">{{ .lang.HelpMessages.helpMessage }}</span>
<input type="text" class="input ~neutral @low mt-4" id="ui-help_message">
<p class="support mb-2 mt-1">{{ .lang.HelpMessages.helpMessageNotice }}</p>
</label>
<label class="label">
<span class="mt-4">{{ .lang.HelpMessages.successMessage }}</span>
<input type="text" class="input ~neutral @low mt-4" id="ui-success_message">
<p class="support mb-2 mt-1">{{ .lang.HelpMessages.successMessageNotice }}</p>
</label>
<label class="label related-to-email">
<span class="mt-4">{{ .lang.HelpMessages.emailMessage }}</span>
<input type="text" class="input ~neutral @low mt-4" id="email-message">
<p class="support mb-2 mt-1">{{ .lang.HelpMessages.emailMessageNotice }}</p>
</label>
<section class="section ~neutral banner footer flex-expand middle">
<div class="card lg:container sectioned ~neutral @low unfocused">
<section class="section flex flex-col gap-2 justify-between">
<span class="heading">{{ .lang.HelpMessages.title }}</span>
<p class="content">{{ .lang.HelpMessages.description }}</p>
<p class="content">{{ .lang.HelpMessages.markdownMessageNotice }}</p>
<label class="label flex flex-col gap-2">
<span>{{ .lang.HelpMessages.contactMessage }}</span>
<input type="text" class="input ~neutral @low" id="ui-contact_message">
<p class="support">{{ .lang.HelpMessages.contactMessageNotice }}</p>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.HelpMessages.helpMessage }}</span>
<input type="text" class="input ~neutral @low" id="ui-help_message">
<p class="support">{{ .lang.HelpMessages.helpMessageNotice }}</p>
</label>
<label class="label flex flex-col gap-2">
<span>{{ .lang.HelpMessages.successMessage }}</span>
<input type="text" class="input ~neutral @low" id="ui-success_message">
<p class="support">{{ .lang.HelpMessages.successMessageNotice }}</p>
</label>
<label class="label related-to-email">
<span>{{ .lang.HelpMessages.emailMessage }}</span>
<input type="text" class="input ~neutral @low" id="email-message">
<p class="support">{{ .lang.HelpMessages.emailMessageNotice }}</p>
</label>
</section>
<section class="section w-full ~neutral footer flex flex-row justify-between items-center">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</div>
</section>
</div>
<div class="card ~neutral @low mb-2 unfocused">
<div class="row col flex center">
<div class="card lg:container sectioned ~neutral @low unfocused">
<section class="section flex flex-col gap-2 justify-center items-center">
<span class="heading">{{ .lang.EndPage.finished }}</span>
</div>
<div class="row col flex center">
<p class="content my-2">{{ .lang.EndPage.restartMessage }}</p>
</div>
<div class="row col flex center">
<span class="button ~neutral @low back mr-4">{{ .lang.Strings.back }}</span>
<p class="content text-center">{{ .lang.EndPage.moreFeatures }} {{ .lang.EndPage.restartReload }} {{ .lang.EndPage.ifFailedLoad }}</p>
</section>
<section class="section w-full ~neutral footer flex flex-row justify-center items-center gap-2">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<span class="button ~urge @low" id="restart">{{ .lang.Strings.submit }}</span>
<span class="button ~urge @low unfocused" id="refresh">{{ .lang.EndPage.refreshPage }}</span>
<a class="button ~urge @low flex flex-col gap-0.5 unfocused" id="refresh-internal"></a>
<a class="button ~urge @low flex flex-col gap-0.5 unfocused" id="refresh-external"></a>
</div>
</div>
</div>
@@ -495,4 +574,3 @@
<script src="js/setup.js" type="module"></script>
</body>
</html>

152
html/user.html Normal file
View File

@@ -0,0 +1,152 @@
<!DOCTYPE html>
<html lang="en" class="light">
<head>
<script>
window.langFile = JSON.parse({{ .language }});
window.linkResetEnabled = {{ .linkResetEnabled }};
window.language = "{{ .langName }}";
window.telegramRequired = {{ .telegramRequired }};
window.telegramUsername = {{ .telegramUsername }};
window.telegramURL = {{ .telegramURL }};
window.emailRequired = {{ .emailRequired }};
window.discordRequired = {{ .discordRequired }};
window.discordServerName = "{{ .discordServerName }}";
window.discordInviteLink = {{ .discordInviteLink }};
window.discordSendPINMessage = "{{ .discordSendPINMessage }}";
window.matrixRequired = {{ .matrixRequired }};
window.matrixUserID = "{{ .matrixUser }}";
window.validationStrings = JSON.parse({{ .validationStrings }});
</script>
{{ template "header.html" . }}
<title>{{ .strings.myAccount }}</title>
</head>
<body class="max-w-full overflow-x-hidden section">
<div id="modal-email" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<div class="content">
<span class="heading mb-4 my-2"></span>
<label class="label supra row m-1" for="modal-email-input">{{ .strings.emailAddress }}</label>
<div class="row">
<input type="email" class="col sm field ~neutral @low input" id="modal-email-input" placeholder="{{ .strings.emailAddress }}">
</div>
<button class="button ~urge @low supra full-width center lg my-2 modal-submit">{{ .strings.submit }}</button>
</div>
<div class="confirmation-required unfocused">
<span class="heading mb-4">{{ .strings.confirmationRequired }} <span class="modal-close">&times;</span></span>
<p class="content mb-4">{{ .strings.confirmationRequiredMessage }}</p>
</div>
</div>
</div>
{{ if .pwrEnabled }}
<div id="modal-pwr" class="modal">
<div class="card content relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.resetPassword }}</span>
<p class="content my-2">
{{ if .linkResetEnabled }}
{{ .strings.resetPasswordThroughLinkStart }}
<ul class="content">
{{ if .resetPasswordUsername }}<li>{{ .strings.resetPasswordUsername }}</li>{{ end }}
{{ if .resetPasswordEmail }}<li>{{ .strings.resetPasswordEmail }}</li>{{ end }}
{{ if .resetPasswordContactMethod }}<li>{{ .strings.resetPasswordContactMethod }}</li>{{ end }}
</ul>
{{ .strings.resetPasswordThroughLinkEnd }}
{{ else }}
{{ .strings.resetPasswordThroughJellyfin }}
{{ end }}
</p>
<div class="row">
<input type="text" class="col sm field ~neutral @low input" id="pwr-address" placeholder="username | example@example.com | user#1234 | @user:host | @username">
</div>
{{ if .linkResetEnabled }}
<span class="button ~info @low full-width center mt-4" id="pwr-submit">
{{ .strings.submit }}
</span>
{{ else }}
<a class="button ~info @low full-width center mt-4" href="{{ .jfLink }}" target="_blank">{{ .strings.continue }}</a>
{{ end }}
</div>
</div>
{{ end }}
{{ template "login-modal.html" . }}
{{ template "account-linking.html" . }}
<div id="notification-box"></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>
<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="columns-1 sm:columns-2 gap-4" id="user-cardlist">
{{ if index . "PageMessageEnabled" }}
{{ if .PageMessageEnabled }}
<div class="card @low dark:~d_neutral content break-words" id="card-message">
{{ .PageMessageContent }}
</div>
{{ end }}
{{ end }}
<div class="card @low dark:~d_neutral flex-col" id="card-contact">
<span class="heading mb-2">{{ .strings.contactMethods }}</span>
<div class="content flex justify-between flex-col h-100"></div>
</div>
<div>
<div class="card @low dark:~d_neutral content" id="card-password">
<span class="heading row mb-2">{{ .strings.changePassword }}</span>
<div class="">
<div class="my-2">
<span class="label supra row">{{ .strings.passwordRequirementsHeader }}</span>
<ul>
{{ range $key, $value := .requirements }}
<li class="" id="requirement-{{ $key }}" min="{{ $value }}">
<span class="badge lg ~positive requirement-valid"></span> <span class="content requirement-content"></span>
</li>
{{ end }}
</ul>
</div>
<div class="my-2">
<label class="label supra" for="user-old-password">{{ .strings.oldPassword }}</label>
<input type="password" class="input ~neutral @low mt-2 mb-4" placeholder="{{ .strings.password }}" id="user-old-password" aria-label="{{ .strings.oldPassword }}">
<label class="label supra" for="user-new-password">{{ .strings.newPassword }}</label>
<input type="password" class="input ~neutral @low mt-2 mb-4" placeholder="{{ .strings.password }}" id="user-new-password" aria-label="{{ .strings.newPassword }}">
<label class="label supra" for="user-reenter-password">{{ .strings.reEnterPassword }}</label>
<input type="password" class="input ~neutral @low mt-2 mb-4" placeholder="{{ .strings.password }}" id="user-reenter-new-password" aria-label="{{ .strings.reEnterPassword }}">
<span class="button ~info @low full-width center mt-4" id="user-password-submit">
{{ .strings.changePassword }}
</span>
</div>
</div>
</div>
</div>
<div>
<div class="card @low dark:~d_neutral unfocused" id="card-status">
<span class="heading mb-2">{{ .strings.expiry }}</span>
<aside class="aside ~warning user-expiry my-4"></aside>
<div class="user-expiry-countdown"></div>
</div>
</div>
{{ if .referralsEnabled }}
<div>
<div class="card @low dark:~d_neutral unfocused" id="card-referrals">
<span class="heading mb-2">{{ .strings.referrals }}</span>
<aside class="aside ~neutral my-4 col user-referrals-description"></aside>
<div class="flex flex-row justify-between gap-2">
<div class="user-referrals-info"></div>
<div class="grid my-2">
<button type="button" class="user-referrals-button button ~info dark:~d_info @low" title="Copy">{{ .strings.copyReferral }}<i class="ri-file-copy-line ml-2"></i></button>
</div>
</div>
</div>
</div>
{{ end }}
</div>
</div>
<script src="{{ .pages.Base }}/js/user.js" type="module"></script>
</body>
</html>

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 523 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 411 KiB

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

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

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

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 38 KiB

BIN
images/myaccount.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 72 KiB

View File

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 91 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 59 KiB

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

View File

@@ -1,50 +0,0 @@
package main
import "time"
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
type inviteDaemon struct {
Stopped bool
ShutdownChannel chan string
Interval time.Duration
period time.Duration
app *appContext
}
func newInviteDaemon(interval time.Duration, app *appContext) *inviteDaemon {
return &inviteDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
}
}
func (rt *inviteDaemon) 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()
rt.app.storage.loadInvites()
rt.app.debug.Println("Daemon: Checking invites")
rt.app.checkInvites()
finished := time.Now()
duration := finished.Sub(started)
rt.period = rt.Interval - duration
}
}
func (rt *inviteDaemon) Shutdown() {
rt.Stopped = true
rt.ShutdownChannel <- "Down"
<-rt.ShutdownChannel
close(rt.ShutdownChannel)
}

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

78
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
@@ -26,8 +28,10 @@ func (ls *adminLangs) getOptions() [][2]string {
type commonLangs map[string]commonLang
type commonLang struct {
Meta langMeta `json:"meta"`
Strings langSection `json:"strings"`
Meta langMeta `json:"meta"`
Strings langSection `json:"strings"`
Notifications langSection `json:"notifications"`
QuantityStrings map[string]quantityString `json:"quantityStrings"`
}
type adminLang struct {
@@ -38,34 +42,36 @@ type adminLang struct {
JSON string
}
type formLangs map[string]formLang
type userLangs map[string]userLang
func (ls *formLangs) 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
}
type formLang struct {
type userLang struct {
Meta langMeta `json:"meta"`
Strings langSection `json:"strings"`
Notifications langSection `json:"notifications"`
notificationsJSON string
ValidationStrings map[string]quantityString `json:"validationStrings"`
validationStringsJSON string
QuantityStrings map[string]quantityString `json:"quantityStrings"`
JSON string
}
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
@@ -78,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
@@ -112,13 +119,16 @@ type setupLang struct {
EndPage langSection `json:"endPage"`
General langSection `json:"general"`
Updates langSection `json:"updates"`
Proxy langSection `json:"proxy"`
Language langSection `json:"language"`
Login langSection `json:"login"`
JellyfinEmby langSection `json:"jellyfinEmby"`
Ombi langSection `json:"ombi"`
Jellyseerr langSection `json:"jellyseerr"`
Email langSection `json:"email"`
Messages langSection `json:"messages"`
Notifications langSection `json:"notifications"`
UserPage langSection `json:"userPage"`
WelcomeEmails langSection `json:"welcomeEmails"`
PasswordResets langSection `json:"passwordResets"`
InviteEmails langSection `json:"inviteEmails"`
@@ -127,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
@@ -144,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

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

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

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

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

View File

@@ -1,6 +1,6 @@
{
"meta": {
"name": "Dansk"
"name": "Dansk (DK)"
},
"strings": {
"invites": "Invitationer",
@@ -15,20 +15,11 @@
"warning": "Advarsel",
"inviteInfiniteUsesWarning": "invitationer med uendelig brug kan blive misbrugt",
"inviteSendToEmail": "Send til",
"login": "Log på",
"logout": "Log ud",
"create": "Opret",
"apply": "Anvend",
"delete": "Slet",
"add": "Tilføj",
"select": "Vælg",
"name": "Navn",
"date": "Dato",
"enabled": "Aktiveret",
"disabled": "Deaktiveret",
"reEnable": "Genaktiver",
"disable": "Deaktiver",
"admin": "Administrator",
"updates": "Opdateringer",
"update": "Opdatering",
"download": "Hent",
@@ -37,7 +28,6 @@
"lastActiveTime": "Sidst Aktiv",
"from": "Fra",
"user": "Bruger",
"expiry": "Udløb",
"userExpiry": "Brugerens Udløb",
"userExpiryDescription": "En specificeret tid efter hver tilmelding, sletter/deaktiverer jfa-go kontoen. Du kan ændre denne adfærd i indstillingerne.",
"aboutProgram": "Om",
@@ -47,24 +37,23 @@
"profile": "Profil",
"unknown": "Ukendt",
"label": "Etiket",
"announce": "Annoncere",
"announce": "Meddelelse",
"subject": "Emne",
"message": "Meddelelse",
"message": "Besked",
"variables": "Variabler",
"conditionals": "Betingelser",
"preview": "Eksempel",
"reset": "Nulstil",
"edit": "Rediger",
"donate": "Doner",
"contactThrough": "Kontakt gennem:",
"extendExpiry": "Forlæng udløb",
"customizeMessages": "Tilpas Meddelelser",
"customizeMessagesDescription": "Hvis du ikke vil bruge jfa-go's meddelelses skabeloner, kan du oprette din egen ved hjælp af Markdown.",
"customizeMessages": "Tilpas Beskeder",
"customizeMessagesDescription": "Hvis du ikke vil bruge jfa-go's besked skabeloner, kan du oprette din egen ved hjælp af Markdown.",
"markdownSupported": "Markdown understøttes.",
"modifySettings": "Rediger indstillinger",
"modifySettingsDescription": "Anvend indstillinger fra en eksisterende profil, eller hent dem direkte fra en bruger.",
"applyHomescreenLayout": "Anvend startskærmens layout",
"sendDeleteNotificationEmail": "Send notifikations meddelelse",
"sendDeleteNotificationEmail": "Send notifikations besked",
"sendDeleteNotifiationExample": "Din konto er blevet slettet.",
"settingsRestart": "Genstart",
"settingsRestarting": "Genstarter…",
@@ -77,7 +66,7 @@
"settingsRequiredOrRestartMessage": "Bemærk: {n} angiver et obligatorisk felt, {n} angiver at ændringer kræver genstart.",
"settingsSave": "Gem",
"ombiUserDefaults": "Ombi bruger standarder",
"ombiUserDefaultsDescription": "Opret en Ombi bruger og konfigurer den, vælg den derefter nedenfor. Brugerens indstillinger/tilladelser gemmes og anvendes på nye Ombi brugere oprettet af jfa-go",
"ombiUserDefaultsDescription": "Opret en Ombi bruger og konfigurer den, vælg den derefter nedenfor. Brugerens indstillinger/tilladelser gemmes og anvendes på nye Ombi brugere oprettet af jfa-go når denne profil er valgt.",
"userProfiles": "Bruger Profiler",
"userProfilesDescription": "Profiler anvendes på brugere når de opretter en konto. En profil inkluderer adgangsrettigheder til biblioteket og layout på startskærmen.",
"userProfilesIsDefault": "Standard",
@@ -90,7 +79,6 @@
"inviteUsersCreated": "Oprettet brugere",
"inviteNoProfile": "Ingen Profil",
"inviteDateCreated": "Oprettet",
"inviteRemainingUses": "Resterende anvendelser",
"inviteNoInvites": "Ingen",
"inviteExpiresInTime": "Udløber om {n}",
"notifyEvent": "Meddel den:",
@@ -104,7 +92,45 @@
"saveAsTemplate": "Gem som skabelon",
"templates": "Skabeloner",
"deleteTemplate": "Slet skabelon",
"templateEnterName": "Indtast et navn for at gemme denne skabelon."
"templateEnterName": "Indtast et navn for at gemme denne skabelon.",
"ombiProfile": "Ombi bruger profil",
"setExpiry": "Sæt udløb",
"logs": "Log",
"sendPWR": "Send Nulstilling af Adgangskode",
"sendPWRManual": "Brugeren {n} har ingen kontaktinformation, tryk kopier for at få et link du kan sende til dem.",
"sendPWRSuccess": "Link til nulstilling af adgangskode sendt.",
"sendPWRSuccessManual": "Hvis brugeren ikke er modtaget den, så tryk på kopier for manuelt at sende et link til dem.",
"sendPWRValidFor": "Dette link er gyldigt i 30m.",
"accessJFA": "Få adgang til jfa-go",
"accessJFASettings": "Kan ikke ændres, da enten \"Kun administrator\" eller \"Tillad alle\" er blevet indstillet i Indstillinger > Generelt.",
"after": "Efter",
"settingsHiddenDependency": "Matchende indstillinger er skjult, fordi de afhænger af værdien af en anden indstilling:",
"userPageLogin": "Brugerside: Login",
"buildTime": "Bygnings Tid",
"invite": "inviter",
"loginNotAdmin": "Ikke en Admin?",
"userLabel": "Brugeretiket",
"userLabelDescription": "Etiket, der skal anvendes på brugere, der er oprettet med denne invitation.",
"sortingBy": "Sortering Efter",
"clickToRemoveFilter": "Klik for at fjerne dette filter.",
"clearSearch": "Ryd søgning",
"actions": "Handlinger",
"unlink": "Fjern linket til konto",
"enableReferrals": "Aktiver henvisninger",
"disableReferrals": "Deaktiver henvisninger",
"enableReferralsDescription": "Giv brugerne et personligt henvisningslink, der ligner en invitation, til at sende til venner/familie. Kan hentes fra en henvisningsskabelon i en profil eller fra en eksisterende invitation.",
"enableReferralsProfileDescription": "Giv brugere oprettet med denne profil et personligt henvisningslink, der ligner en invitation, til at sende til venner/familie. Opret en invitation med de ønskede indstillinger, og vælg den her. Hver henvisning vil så være baseret på denne invitation. Du kan slette invitationen, når den er fuldført.",
"before": "Før",
"noResultsFound": "Ingen Resultater Fundet",
"settingsDependsOn": "{setting}: afhænger af {dependency}",
"settingsMaybeUnderAdvanced": "Tip: Du finder muligvis det du leder efter, ved at aktivere Avancerede indstillinger.",
"settingsAdvancedMode": "{setting}: Avanceret Indstillinger skal være aktiveret",
"filters": "Filtre",
"searchOptions": "Søge Indstillinger",
"matchText": "Match Tekst",
"jellyfinID": "Jellyfin ID",
"userPagePage": "Brugerside: Side",
"builtBy": "Bygget Af"
},
"notifications": {
"changedEmailAddress": "Ændret e-mail adresse på {n}.",
@@ -118,13 +144,9 @@
"updateAppliedRefresh": "Opdatering anvendt, genindlæs venligst siden.",
"telegramVerified": "Telegram konto verificeret.",
"accountConnected": "Konto tilsluttet.",
"errorConnection": "Kunne ikke oprette forbindelse til jfa-go.",
"error401Unauthorized": "Adgang nægtet. Prøv at genindlæse siden.",
"errorSettingsAppliedNoHomescreenLayout": "Indstillingerne blev anvendt, men anvendelse af startskærmens layout mislykkedes muligvis.",
"errorHomescreenAppliedNoSettings": "Startskærmens layout blev anvendt, men anvendelsen af indstillingerne mislykkedes muligvis.",
"errorSettingsFailed": "Ansøgningen mislykkedes.",
"errorLoginBlank": "Brugernavnet og/eller adgangskoden blev efterladt tomme.",
"errorUnknown": "Ukendt fejl.",
"errorSaveEmail": "Kunne ikke gemme e-mail.",
"errorBlankFields": "Felter blev efterladt tomme",
"errorDeleteProfile": "Kunne ikke slette profilen {n}",
@@ -132,7 +154,6 @@
"errorCreateProfile": "Kunne ikke oprette profilen {n}",
"errorSetDefaultProfile": "Standard profilen kunne ikke indstilles.",
"errorLoadUsers": "Kunne ikke indlæse brugere.",
"errorSaveSettings": "Kunne ikke gemme indstillingerne.",
"errorLoadSettings": "Indstillingerne kunne ikke indlæses.",
"errorSetOmbiDefaults": "Ombi standarderne kunne ikke gemmes.",
"errorLoadOmbiUsers": "Kunne ikke indlæse ombi brugere.",
@@ -140,12 +161,16 @@
"errorFailureCheckLogs": "Mislykkedes (tjek konsol/logfiler)",
"errorPartialFailureCheckLogs": "Delvis fejl (tjek konsol/logfiler)",
"errorUserCreated": "Kunne ikke oprette bruger {n}.",
"errorSendWelcomeEmail": "Kunne ikke sende velkomst meddelelse (tjek konsol/logfiler",
"errorSendWelcomeEmail": "Kunne ikke sende velkomst besked (tjek konsol/logfiler",
"errorApplyUpdate": "Kunne ikke anvende opdateringen, prøv manuelt.",
"errorCheckUpdate": "Kunne ikke kontrollere for opdatering.",
"updateAvailable": "En ny opdatering er tilgængelig, tjek indstillingerne.",
"noUpdatesAvailable": "Ingen nye opdateringer tilgængelige.",
"savedAnnouncement": "Meddelelse gemt."
"savedAnnouncement": "Meddelelse gemt.",
"setOmbiProfile": "Gemt i ombi profilen.",
"errorSetOmbiProfile": "Ombi profilen kunne ikke gemmes.",
"referralsEnabled": "Henvisninger aktiveret.",
"errorNoReferralTemplate": "Profilen indeholder ikke en henvisningsskabelon. Tilføj en i indstillingerne."
},
"quantityStrings": {
"modifySettingsFor": {
@@ -185,8 +210,8 @@
"plural": "Aktiveret {n} brugere."
},
"announceTo": {
"singular": "Annoncer til {n} bruger",
"plural": "Annoncer til {n} brugere"
"singular": "Send Meddelelse til {n} bruger",
"plural": "Send Meddelelse til {n} brugere"
},
"appliedSettings": {
"singular": "Anvendte indstillinger til {n} bruger.",
@@ -199,6 +224,14 @@
"extendedExpiry": {
"singular": "Forlængede udløb for {n} bruger.",
"plural": "Forlængede udløb for {n} brugere."
},
"setExpiry": {
"singular": "Indstil udløb for {n} bruger",
"plural": "Indstil udløb for {n} brugere"
},
"enableReferralsFor": {
"singular": "Aktiver Henvisninger for {n} bruger",
"plural": "Aktiver Henvisninger for {n} brugere"
}
}
}

View File

@@ -3,7 +3,7 @@
"name": "Deutsch (DE)"
},
"strings": {
"invites": "Invites",
"invites": "Einladungen",
"accounts": "Konten",
"settings": "Einstellungen",
"inviteDays": "Tage",
@@ -13,11 +13,8 @@
"warning": "Warnung",
"inviteInfiniteUsesWarning": "Invites mit unendlich vielen Verwendungen können missbräuchlich verwendet werden",
"inviteSendToEmail": "Senden an",
"login": "Anmelden",
"logout": "Abmelden",
"create": "Erstellen",
"apply": "Anwenden",
"delete": "Löschen",
"name": "Name",
"date": "Datum",
"lastActiveTime": "Zuletzt aktiv",
@@ -43,7 +40,7 @@
"settingsRequiredOrRestartMessage": "Hinweis: {n} zeigt ein erforderliches Feld an, {n} zeigt an, dass Änderungen einen Neustart erfordern.",
"settingsSave": "Speichern",
"ombiUserDefaults": "Ombi-Benutzerstandardeinstellungen",
"ombiUserDefaultsDescription": "Erstelle einen Ombi-Benutzer und konfiguriere ihn, dann wähle ihn unten aus. Seine Einstellungen/Berechtigungen werden gespeichert und auf neue Ombi-Benutzer, erstellt von jfa-go, angewendet",
"ombiUserDefaultsDescription": "Erstelle einen Ombi-Benutzer, konfiguriere ihn und wähle ihn dann unten aus. Seine Einstellungen/Berechtigungen werden gespeichert und auf neue Ombi-Benutzer, welche von jfa-go erstellt werden, angewendet, sofern dieses Profil ausgewählt ist.",
"userProfiles": "Benutzerprofile",
"userProfilesDescription": "Profile werden auf Benutzer angewendet, wenn sie ein Konto erstellen. Ein Profil beinhaltet Bibliothekszugriffsrechte und das Startbildschirmlayout.",
"userProfilesIsDefault": "Standard",
@@ -56,7 +53,6 @@
"inviteUsersCreated": "Erstellte Benutzer",
"inviteNoProfile": "Kein Profil",
"inviteDateCreated": "Erstellt",
"inviteRemainingUses": "Verbleibende Verwendungen",
"inviteNoInvites": "Keine",
"inviteExpiresInTime": "Läuft in {n} ab",
"notifyEvent": "Benachrichtigen bei:",
@@ -68,8 +64,7 @@
"variables": "Variablen",
"preview": "Vorschau",
"reset": "Zurücksetzen",
"edit": "Bearbeiten",
"customizeMessages": "E-Mails anpassen",
"customizeMessages": "Benachrichtigungen anpassen",
"customizeMessagesDescription": "Wenn du jfa-go's E-Mail-Vorlagen nicht benutzen willst, kannst du deinen eigenen unter Verwendung von Markdown erstellen.",
"announce": "Ankündigen",
"subject": "Betreff",
@@ -79,29 +74,69 @@
"search": "Suchen",
"userExpiry": "Benutzer Ablaufdatum",
"inviteDuration": "Invite Dauer",
"enabled": "Aktiviert",
"userExpiryDescription": "Eine bestimmte Zeit nach der Anmeldung wird jfa-go das Konto löschen/deaktivieren. Du kannst dieses Verhalten in den Einstellungen ändern.",
"disabled": "Deaktiviert",
"admin": "Admin",
"download": "Herunterladen",
"update": "Aktualisieren",
"updates": "Aktualisierungen",
"expiry": "Ablaufdatum",
"extendExpiry": "Ablaufdatum verlängern",
"reEnable": "Wieder aktivieren",
"disable": "Deaktivieren",
"donate": "Spenden",
"conditionals": "Bedingungen",
"contactThrough": "Kontakt über:",
"sendPIN": "Bitte den Benutzer, die unten stehende PIN an den Bot zu senden.",
"inviteMonths": "Monate",
"add": "Hinzufügen",
"select": "Auswählen",
"searchDiscordUser": "Gib den Discord-Benutzername ein, um den Benutzer zu finden.",
"findDiscordUser": "Suche Discord-Benutzer",
"linkMatrixDescription": "Gib den Benutzernamen und das Passwort des Benutzers ein, der als Bot verwendet werden soll. Nach dem Absenden wird die App neu gestartet.",
"matrixHomeServer": "Adresse des Homeservers",
"templates": "Vorlagen"
"templates": "Vorlagen",
"ombiProfile": "Ombi-Benutzerprofil",
"accessJFA": "jfa-go Zugriff",
"sendPWRValidFor": "Der Link ist 30m gültig.",
"logs": "Logdaten",
"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",
"sendPWRManual": "Benutzer {n} hat keine Kontaktmöglichkeit hinterlegt. Klicke \"Kopieren\" um einen Link zu erhalten, den du dem Benutzer manuell senden kannst.",
"accessJFASettings": "Kann nicht geändert werden, da entweder \"Nur Admin-Benutzer\" oder \"Erlaube allen Jellyfin-Nutzern sich anzumelden\" in Einstellungen > Allgemein aktiviert ist.",
"saveAsTemplate": "Als Vorlage speichern",
"deleteTemplate": "Vorlage löschen",
"templateEnterName": "Gebe einen Namen ein, um diese Vorlage zu speichern.",
"filters": "Filter",
"clickToRemoveFilter": "zum Entfernen des Filters klicken.",
"clearSearch": "Suche löschen",
"actions": "Aktionen",
"searchOptions": "Suchoptionen",
"matchText": "Textübereinstummung",
"jellyfinID": "Jellyfin ID",
"userPageLogin": "Benutzer Seite: Login",
"userPagePage": "Benutzer Seite: Seite",
"after": "nach",
"before": "vor",
"unlink": "Account trennen",
"sortingBy": "Sortieren nach",
"activity": "Aktivität",
"settingsMaybeUnderAdvanced": "Tipp: Du könntest finden, wonach Du suchst, indem Du die erweiterten Einstellungen aktivierst.",
"enableReferralsProfileDescription": "Gib Benutzern, die mit diesem Profil erstellt wurden, einen persönlichen Empfehlungslink, ähnlich einer Einladung, den sie an Freunde und Familie senden können. Erstelle eine Einladung mit den gewünschten Einstellungen und wähle sie dann hier aus. Jede Empfehlung basiert dann auf dieser Einladung. Du kannst die Einladung nach Abschluss löschen.",
"removeExpiry": "Ablaufdatum entfernen",
"enterExpiry": "Ablaufdatum eingeben",
"keepSearchingDescription": "Die Suche umfasst nur bereits geladene Aktivitäten. Klicke unten um alle Aktivitäten zu durchsuchen.",
"useInviteExpiry": "Ablaufdatum des Profils/der Einladung setzen",
"useInviteExpiryNote": "Standardmässig laufen Einladungen nach 90 Tagen ab, können jedoch vom Benutzer erneuert werden. Aktiviere diese Option, damit die Empfehlung nach der festgelegten Zeit deaktiviert wird.",
"settingsHiddenDependency": "Zutreffende Einstellungen sind ausgeblendet, da sie vom Wert einer anderen Einstellung abhängen:",
"deleted": "Gelöscht",
"disabled": "Deaktiviert",
"keepSearching": "Weiter suchen",
"enableReferralsDescription": "Gib Benutzern einen persönlichen Empfehlungslink, ähnlich einer Einladung, den sie an Freunde und Familie senden können. Dieser kann aus einer Empfehlungsvorlage im Profil oder aus einer bestehenden Einladung stammen.",
"settingsDependsOn": "{setting}: abhängig von {dependency}",
"settingsAdvancedMode": "{setting}: Erweiterte Einstellungen müssen aktiviert sein",
"invite": "Einladung",
"userLabelDescription": "Label welches auf Benutzer angewendet wird, die mit dieser Einladung erstellt wurden.",
"enableReferrals": "Empfehlungen aktivieren",
"disableReferrals": "Empfehlungen deaktivieren",
"userLabel": "Benutzer Label",
"noResultsFound": "Keine Resultate gefunden"
},
"notifications": {
"changedEmailAddress": "E-Mail-Adresse von {n} geändert.",
@@ -109,20 +144,15 @@
"createProfile": "Profil {n} erstellt.",
"saveSettings": "Einstellungen wurden gespeichert",
"setOmbiDefaults": "Ombi-Standardeinstellungen gespeichert.",
"errorConnection": "Konnte keine Verbindung zu jfa-go herstellen.",
"error401Unauthorized": "Unberechtigt. Versuch, die Seite zu aktualisieren.",
"errorSettingsAppliedNoHomescreenLayout": "Einstellungen wurden angewendet, aber die Anwendung des Startbildschirmlayouts ist möglicherweise fehlgeschlagen.",
"errorHomescreenAppliedNoSettings": "Startbildschirmlayout wurde angewendet, aber die Anwendung der Einstellungen ist möglicherweise fehlgeschlagen.",
"errorSettingsFailed": "Anwendung ist fehlgeschlagen.",
"errorLoginBlank": "Der Benutzername und/oder das Passwort wurden nicht ausgefüllt.",
"errorUnknown": "Unbekannter Fehler.",
"errorBlankFields": "Felder wurden nicht ausgefüllt",
"errorDeleteProfile": "Fehler beim Löschen des Profils {n}",
"errorLoadProfiles": "Fehler beim Laden der Profile.",
"errorCreateProfile": "Fehler beim Erstellen des Profils {n}",
"errorSetDefaultProfile": "Fehler beim Setzen des Standardprofils.",
"errorLoadUsers": "Fehler beim Laden der Benutzer.",
"errorSaveSettings": "Einstellungen konnten nicht gespeichert werden.",
"errorLoadSettings": "Fehler beim Laden der Einstellungen.",
"errorSetOmbiDefaults": "Fehler beim Speichern der Ombi-Standardeinstellungen.",
"errorLoadOmbiUsers": "Fehler beim Laden der Ombi-Benutzer.",
@@ -142,7 +172,9 @@
"updateAppliedRefresh": "Update angewendet, bitte aktualisieren.",
"telegramVerified": "Telegram-Konto verifiziert.",
"accountConnected": "Konto verbunden.",
"savedAnnouncement": "Ankündigung gespeichert."
"savedAnnouncement": "Ankündigung gespeichert.",
"errorSetOmbiProfile": "Ombi-Profil konnte nicht gespeichert werden.",
"setOmbiProfile": "Ombi-Profil gespeichert."
},
"quantityStrings": {
"modifySettingsFor": {
@@ -196,6 +228,10 @@
"reEnableUsers": {
"singular": "Benutzer {n} wieder aktivieren",
"plural": "Benutzer {n} wieder aktivieren"
},
"setExpiry": {
"singular": "Ablauf für {n} Benutzer setzen",
"plural": "Ablauf für {n} Benutzer setzen"
}
}
}

View File

@@ -13,11 +13,8 @@
"warning": "Προσοχή",
"inviteInfiniteUsesWarning": "μπορεί να γίνει κατάχρηση των προσκλήσεων με άπειρες χρήσεις",
"inviteSendToEmail": "Αποστολή σε",
"login": "Σύνδεση",
"logout": "Αποσύνδεση",
"create": "Δημιουργία",
"apply": "Εφαρμογή",
"delete": "Διαγραφή",
"name": "Όνομα",
"date": "Ημερομηνία",
"lastActiveTime": "Τελευταία Ενεργός",
@@ -59,7 +56,6 @@
"inviteUsersCreated": "Δημιουργηθέντες χρήστες",
"inviteNoProfile": "Κανένα Προφίλ",
"inviteDateCreated": "Δημιουργηθέντα",
"inviteRemainingUses": "Εναπομείναντες χρήσεις",
"inviteNoInvites": "Καμία",
"inviteExpiresInTime": "Λήγει σε {n}",
"notifyEvent": "Ενημέρωση όταν:",
@@ -68,7 +64,6 @@
"variables": "Μεταβλητές",
"preview": "Προεπισκόπηση",
"reset": "Επαναφορά",
"edit": "Επεξεργασία",
"customizeMessages": "Παραμετροποίηση Emails",
"advancedSettings": "Προχωρημένες Ρυθμίσεις",
"customizeMessagesDescription": "Αν δεν θέλετε να ζρησιμοποιήσετε τα πρότυπα email του jfa-go, μπορείτε να δημιουργήσετε τα δικά σας με χρήση Markdown.",
@@ -77,10 +72,6 @@
"download": "Λήψη",
"search": "Αναζήτηση",
"inviteDuration": "Διάρκεια Πρόσκλησης",
"enabled": "Ενεργοποιημένο",
"disabled": "Απενεργοποιημένο",
"admin": "Διαχειριστής",
"expiry": "Λήξη",
"userExpiry": "Λήξη Χρήστη",
"userExpiryDescription": "Μετά απο ένα καθορισμένο χρόνο μετά απο κάθε εγγραφή, το jfa-go θα διαγράφει/απενεργοποιεί τον λογαριασμό. Μπορείτε να αλλάξετε αυτή την συμπεριφορά στις ρυθμίσεις.",
"announce": "Ανακοίνωση",
@@ -88,8 +79,6 @@
"message": "Μήνυμα",
"extendExpiry": "Παράταση λήξης",
"markdownSupported": "Το Markdown υποστυρίζεται.",
"reEnable": "Επανα-ενεργοποίηση",
"disable": "Απενεργοποίηση",
"inviteMonths": "Μήνες"
},
"notifications": {
@@ -98,20 +87,15 @@
"createProfile": "Δημιουργήθηκε το {n} προφίλ.",
"saveSettings": "Οι ρυθμίσεις αποθηκεύτηκαν",
"setOmbiDefaults": "Αποθηκεύτηκαν οι προκαθορισμένες ρυθμίσεις του ombi.",
"errorConnection": "Δεν μπόρεσε να συνδεθεί με το jfa-go.",
"error401Unauthorized": "Ανεξουσιοδότητος. Προσπαθήστε να κάνετε επαναφόρτωση την σελίδα.",
"errorSettingsAppliedNoHomescreenLayout": "Οι ρυθμίσεις αποθηκεύτηκαν, αλλά η καταχώρηση δομής αρχικής οθόνης ίσως απέτυχε.",
"errorHomescreenAppliedNoSettings": "Η δομή αρχικής οθόνης εφαρμόστηκε, αλλά οι ρυθμίσεις ίσως απέτυχαν.",
"errorSettingsFailed": "Η εφαρμογή απέτυχε.",
"errorLoginBlank": "Το όνομα χρήστη και/ή ο κωδικός ήταν κενά.",
"errorUnknown": "Άγνωστο σφάλμα.",
"errorBlankFields": "Τα πεφία ήταν κενά",
"errorDeleteProfile": "Αποτυχία διαγραφής του προφίλ {n}",
"errorLoadProfiles": "Αποτυχία φόρτωσης των προφίλ.",
"errorCreateProfile": "Αποτυχία δημιουργίας του προφίλ {n}",
"errorSetDefaultProfile": "Αποτυχία ορισμού του προκαθορισμένου προφίλ.",
"errorLoadUsers": "Αποτυχία φόρτωσης χρηστών.",
"errorSaveSettings": "Αποτυχία αποθήκευσης ρυθμίσεων.",
"errorLoadSettings": "Αποτυχία φόρτωσης ρυθμίσεων.",
"errorSetOmbiDefaults": "Αποτυχία αποθήκευσης προκαθορισμένων ρυθμίσεων για το Ombi.",
"errorLoadOmbiUsers": "Αποτυχία φόρτωσης χρηστών Ombi.",
@@ -183,4 +167,4 @@
"plural": "Εργοποιήθηκαν {n} χρήστες."
}
}
}
}

View File

@@ -1,5 +1,200 @@
{
"meta": {
"name": "English (GB)"
},
"quantityStrings": {
"deleteUser": {
"singular": "Delete User",
"plural": "Delete Users"
},
"deletedUser": {
"singular": "Deleted {n} user.",
"plural": "Deleted {n} users."
},
"disabledUser": {
"plural": "Disabled {n} users.",
"singular": "Disabled {n} user."
},
"extendExpiry": {
"singular": "Extend expiry for {n} user",
"plural": "Extend expiry for {n} users"
},
"extendedExpiry": {
"plural": "Extended expiry for {n} users.",
"singular": "Extended expiry for {n} user."
},
"addUser": {
"singular": "Add user",
"plural": "Add users"
},
"modifySettingsFor": {
"singular": "Modify Settings for {n} user",
"plural": "Modify Settings for {n} users"
},
"deleteNUsers": {
"plural": "Delete {n} users",
"singular": "Delete {n} user"
},
"disableUsers": {
"singular": "Disable {n} user",
"plural": "Disable {n} users"
},
"enabledUser": {
"singular": "Enabled {n} user.",
"plural": "Enabled {n} users."
},
"announceTo": {
"singular": "Announce to {n} user",
"plural": "Announce to {n} users"
},
"appliedSettings": {
"singular": "Applied settings to {n} user.",
"plural": "Applied settings to {n} users."
},
"setExpiry": {
"singular": "Set expiry for {n} user",
"plural": "Set expiry for {n} users"
},
"reEnableUsers": {
"singular": "Re-enable {n} user",
"plural": "Re-enable {n} users"
}
},
"strings": {
"invites": "Invites",
"accounts": "Accounts",
"settings": "Settings",
"inviteDays": "Days",
"inviteHours": "Hours",
"inviteInfiniteUsesWarning": "invites with infinite uses can be used abusively",
"inviteSendToEmail": "Send to",
"apply": "Apply",
"updates": "Updates",
"variables": "Variables",
"preview": "Preview",
"markdownSupported": "Markdown is supported.",
"applyHomescreenLayout": "Apply homescreen layout",
"ombiProfile": "Ombi user profile",
"settingsApplyRestartNow": "Apply & restart",
"settingsApplied": "Settings applied.",
"userProfiles": "User Profiles",
"addProfile": "Add Profile",
"userProfilesLibraries": "Libraries",
"addProfileNameOf": "Profile Name",
"inviteDateCreated": "Created",
"settingsRestart": "Restart",
"inviteMinutes": "Minutes",
"inviteNumberOfUses": "Number of uses",
"warning": "Warning",
"create": "Create",
"name": "Name",
"conditionals": "Conditionals",
"contactThrough": "Contact through:",
"select": "Select",
"date": "Date",
"extendExpiry": "Extend expiry",
"sendPWR": "Send Password Reset",
"inviteMonths": "Months",
"inviteDuration": "Invite Duration",
"update": "Update",
"user": "User",
"userExpiryDescription": "A specified amount of time after each signup, jfa-go will delete/disable the account. You can change this behaviour in settings.",
"templates": "Templates",
"accessJFA": "Access jfa-go",
"message": "Message",
"reset": "Reset",
"donate": "Donate",
"sendPWRSuccessManual": "If the user hasn't received it, press copy to get a link to manually send to them.",
"modifySettingsDescription": "Apply settings from an existing profile, or source them directly from a user.",
"logs": "Logs",
"sendPWRManual": "User {n} has no method of contact, press copy to get a link to send to them.",
"sendPWRSuccess": "Password reset link sent.",
"customizeMessages": "Customise Messages",
"customizeMessagesDescription": "If you don't want to use jfa-go's message templates, you can create your own using Markdown.",
"modifySettings": "Modify Settings",
"sendDeleteNotificationEmail": "Send notification message",
"sendDeleteNotifiationExample": "Your account has been deleted.",
"settingsRestarting": "Restarting…",
"settingsRestartRequired": "Restart needed",
"settingsRefreshPage": "Refresh the page in a few seconds.",
"settingsRequiredOrRestartMessage": "Note: {n} indicates a required field, {n} indicates changes require a restart.",
"settingsSave": "Save",
"userProfilesDescription": "Profiles are applied to users when they create an account. A profile include library access rights and homescreen layout.",
"addProfileDescription": "Create a Jellyfin user and configure it, then select it below. When this profile is applied to an invite, new users will be created with the settings.",
"addProfileStoreHomescreenLayout": "Store homescreen layout",
"inviteNoUsersCreated": "None yet!",
"inviteUsersCreated": "Created users",
"inviteNoInvites": "None",
"inviteExpiresInTime": "Expires in {n}",
"notifyEvent": "Notify on:",
"notifyInviteExpiry": "On expiry",
"notifyUserCreation": "On user creation",
"sendPIN": "Ask the user to send the PIN below to the bot.",
"findDiscordUser": "Find Discord user",
"linkMatrixDescription": "Enter the username and password of the user to use as a bot. Once submitted, the app will restart.",
"matrixHomeServer": "Home server address",
"saveAsTemplate": "Save as template",
"deleteTemplate": "Delete template",
"settingsRestartRequiredDescription": "A restart is necessary to apply some settings you changed. Restart now or later?",
"settingsApplyRestartLater": "Apply, restart later",
"subject": "Subject",
"setExpiry": "Set expiry",
"download": "Download",
"search": "Search",
"advancedSettings": "Advanced Settings",
"lastActiveTime": "Last Active",
"from": "From",
"userExpiry": "User Expiry",
"aboutProgram": "About",
"version": "Version",
"commitNoun": "Commit",
"newUser": "New User",
"profile": "Profile",
"unknown": "Unknown",
"label": "Label",
"announce": "Announce",
"sendPWRValidFor": "The link is valid for 30m.",
"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.",
"userProfilesIsDefault": "Default",
"inviteNoProfile": "No Profile",
"searchDiscordUser": "Start typing the Discord username to find the user.",
"templateEnterName": "Enter a name to save this template.",
"accessJFASettings": "Cannot be changed as either \"Admin Only\" or \"Allow All\" has been set in Settings > General."
},
"notifications": {
"errorSettingsFailed": "Application failed.",
"errorLoadSettings": "Failed to load settings.",
"errorDeleteProfile": "Failed to delete profile {n}",
"sentAnnouncement": "Announcement sent.",
"savedAnnouncement": "Announcement saved.",
"setOmbiProfile": "Stored ombi profile.",
"updateApplied": "Update applied, please restart.",
"updateAppliedRefresh": "Update applied, please refresh.",
"telegramVerified": "Telegram account verified.",
"accountConnected": "Account connected.",
"errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.",
"errorSaveEmail": "Failed to save email.",
"errorLoadProfiles": "Failed to load profiles.",
"errorCreateProfile": "Failed to create profile {n}",
"errorLoadUsers": "Failed to load users.",
"errorSetOmbiProfile": "Failed to store ombi profile.",
"errorLoadOmbiUsers": "Failed to load ombi users.",
"errorFailureCheckLogs": "Failed (check console/logs)",
"errorPartialFailureCheckLogs": "Partial failure (check console/logs)",
"errorUserCreated": "Failed to create user {n}.",
"errorSendWelcomeEmail": "Failed to send welcome message (check console/logs)",
"errorApplyUpdate": "Failed to apply update, try manually.",
"errorCheckUpdate": "Failed to check for update.",
"noUpdatesAvailable": "No new updates available.",
"changedEmailAddress": "Changed email address of {n}.",
"userCreated": "User {n} created.",
"saveEmail": "Email saved.",
"createProfile": "Created profile {n}.",
"saveSettings": "Settings were saved",
"errorHomescreenAppliedNoSettings": "Homescreen layout was applied, but applying settings may have failed.",
"errorBlankFields": "Fields were left blank",
"errorSetDefaultProfile": "Failed to set default profile.",
"errorChangedEmailAddress": "Couldn't change email address of {n}.",
"updateAvailable": "A new update is available, check settings."
}
}
}

View File

@@ -4,7 +4,9 @@
},
"strings": {
"invites": "Invites",
"invite": "Invite",
"accounts": "Accounts",
"activity": "Activity",
"settings": "Settings",
"inviteMonths": "Months",
"inviteDays": "Days",
@@ -15,21 +17,11 @@
"warning": "Warning",
"inviteInfiniteUsesWarning": "invites with infinite uses can be used abusively",
"inviteSendToEmail": "Send to",
"login": "Login",
"logout": "Logout",
"create": "Create",
"apply": "Apply",
"delete": "Delete",
"add": "Add",
"select": "Select",
"name": "Name",
"date": "Date",
"enabled": "Enabled",
"disabled": "Disabled",
"reEnable": "Re-enable",
"setExpiry": "Set expiry",
"disable": "Disable",
"admin": "Admin",
"updates": "Updates",
"update": "Update",
"download": "Download",
@@ -37,8 +29,9 @@
"advancedSettings": "Advanced Settings",
"lastActiveTime": "Last Active",
"from": "From",
"after": "After",
"before": "Before",
"user": "User",
"expiry": "Expiry",
"userExpiry": "User Expiry",
"userExpiryDescription": "A specified amount of time after each signup, jfa-go will delete/disable the account. You can change this behaviour in settings.",
"aboutProgram": "About",
@@ -48,6 +41,9 @@
"profile": "Profile",
"unknown": "Unknown",
"label": "Label",
"userLabel": "User Label",
"userLabelDescription": "Label to apply to users created with this invite.",
"logs": "Logs",
"announce": "Announce",
"templates": "Templates",
"subject": "Subject",
@@ -56,11 +52,20 @@
"conditionals": "Conditionals",
"preview": "Preview",
"reset": "Reset",
"edit": "Edit",
"donate": "Donate",
"unlink": "Unlink Account",
"deleted": "Deleted",
"disabled": "Disabled",
"sendPWR": "Send Password Reset",
"noResultsFound": "No Results Found",
"noResultsFoundLocally": "Only loaded records were searched. You can load more, or perform the search over all records on the server.",
"keepSearching": "Keep Searching",
"keepSearchingDescription": "Only the current loaded activities were searched. Click below if you wish to search all activities.",
"contactThrough": "Contact through:",
"extendExpiry": "Extend expiry",
"setExpiry": "Set expiry",
"removeExpiry": "Remove expiry",
"enterExpiry": "Enter an expiry",
"sendPWRManual": "User {n} has no method of contact, press copy to get a link to send to them.",
"sendPWRSuccess": "Password reset link sent.",
"sendPWRSuccessManual": "If the user hasn't received it, press copy to get a link to manually send to them.",
@@ -70,7 +75,16 @@
"markdownSupported": "Markdown is supported.",
"modifySettings": "Modify Settings",
"modifySettingsDescription": "Apply settings from an existing profile, or source them directly from a user.",
"enableReferrals": "Enable Referrals",
"disableReferrals": "Disable Referrals",
"enableReferralsDescription": "Give users a personal referral link similiar to an invite, to send to friends/family. Can be sourced from a referral template in a profile, or from an existing invite.",
"enableReferralsProfileDescription": "Give users created with this profile a personal referral link similiar to an invite, to send to friends/family. Create an invite with the desired settings, then select it here. Each referral will then be based on this invite. You can delete the invite once complete.",
"useInviteExpiry": "Set expiry from profile/invite",
"useInviteExpiryNote": "By default, invites expire after 90 days but can be renewed by the user. Enable for the referral to be disabled after the time set.",
"applyHomescreenLayout": "Apply homescreen layout",
"applyConfigurationAndPolicy": "Apply Jellyfin configuration/policy",
"applyOmbi": "Apply Ombi profile (if available)",
"applyJellyseerr": "Apply Jellyseerr profile (if available)",
"sendDeleteNotificationEmail": "Send notification message",
"sendDeleteNotifiationExample": "Your account has been deleted.",
"settingsRestart": "Restart",
@@ -83,10 +97,16 @@
"settingsRefreshPage": "Refresh the page in a few seconds.",
"settingsRequiredOrRestartMessage": "Note: {n} indicates a required field, {n} indicates changes require a restart.",
"settingsSave": "Save",
"settingsHiddenDependency": "Matching settings are hidden because they depend on the value of another setting:",
"settingsDependsOn": "{setting}: Depends on {dependency}",
"settingsAdvancedMode": "{setting}: Advanced Settings must be enabled",
"settingsMaybeUnderAdvanced": "Tip: You might find what you're looking for by enabling Advanced Settings.",
"ombiProfile": "Ombi user profile",
"ombiUserDefaultsDescription": "Create an Ombi user and configure it, then select it below. It's settings/permissions will be stored and applied to new Ombi users created by jfa-go when this profile is selected.",
"jellyseerrProfile": "Jellyseerr user profile",
"jellyseerrUserDefaultsDescription": "Create a Jellyseerr user and configure it, then select it below. It's settings/permissions will be stored and applied to new Jellyseerr users created by jfa-go when this profile is selected.",
"userProfiles": "User Profiles",
"userProfilesDescription": "Profiles are applied to users when they create an account. A profile include library access rights and homescreen layout.",
"userProfilesDescription": "Profiles are applied to users when they create an account. A profile includes library access rights and homescreen layout.",
"userProfilesIsDefault": "Default",
"userProfilesLibraries": "Libraries",
"addProfile": "Add Profile",
@@ -97,7 +117,6 @@
"inviteUsersCreated": "Created users",
"inviteNoProfile": "No Profile",
"inviteDateCreated": "Created",
"inviteRemainingUses": "Remaining uses",
"inviteNoInvites": "None",
"inviteExpiresInTime": "Expires in {n}",
"notifyEvent": "Notify on:",
@@ -110,9 +129,88 @@
"matrixHomeServer": "Home server address",
"saveAsTemplate": "Save as template",
"deleteTemplate": "Delete template",
"templateEnterName": "Enter a name to save this template."
"templateEnterName": "Enter a name to save this template.",
"accessJFA": "Access jfa-go",
"accessJFASettings": "Cannot be changed as either \"Admin Only\" or \"Allow All\" has been set in Settings > General.",
"sortingBy": "Sorting By",
"sortDirection": "Sort Direction",
"filters": "Filters",
"clickToRemoveFilter": "Click to remove this filter.",
"clearSearch": "Clear search",
"searchAll": "Search/sort all",
"searchAllRecords": "Search/sort all records (on server)",
"actions": "Actions",
"searchOptions": "Search Options",
"matchText": "Match Text",
"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}",
"accountUnlinked": "{contactMethod} removed: {user}",
"accountResetPassword": "{user} reset their password",
"accountChangedPassword": "{user} changed their password",
"accountCreated": "Account created: {user}",
"accountDeleted": "Account deleted: {user}",
"accountDisabled": "Account disabled: {user}",
"accountReEnabled": "Account re-enabled: {user}",
"accountExpired": "Account expired: {user}",
"accountWillExpire": "Account will expire on {date}.",
"expirationBasedOn": "Given date based on 1st user.",
"userDeleted": "User was deleted.",
"userDisabled": "User was disabled",
"inviteCreated": "Invite created: {invite}",
"inviteDeleted": "Invite deleted: {invite}",
"inviteExpired": "Invite expired: {invite}",
"fromInvite": "From Invite",
"byAdmin": "By Admin",
"byUser": "By User",
"byJfaGo": "By jfa-go",
"activityID": "Activity ID",
"title": "Title",
"usersMentioned": "User mentioned",
"actor": "Actor",
"actorDescription": "The thing that caused this action. \"user\"/\"admin\"/\"daemon\" or a username.",
"accountCreationFilter": "Account Creation",
"accountDeletionFilter": "Account Deletion",
"accountDisabledFilter": "Account Disabled",
"accountEnabledFilter": "Account Enabled",
"contactLinkedFilter": "Contact Linked",
"contactUnlinkedFilter": "Contact Unlinked",
"passwordChangeFilter": "Password Changed",
"passwordResetFilter": "Password Reset",
"inviteCreatedFilter": "Invite Created",
"inviteDeletedFilter": "Invite Deleted/Expired",
"loadMore": "Load More",
"loadAll": "Load All",
"noMoreResults": "No more results.",
"totalRecords": "{n} Total Records",
"loadedRecords": "{n} Loaded",
"shownRecords": "{n} Shown",
"selectedRecords": "{n} Selected",
"backups": "Backups",
"backupsDescription": "Backups of the database can be made, restored, or downloaded from here.",
"backupsFormatNote": "Only backup files with the standard name format will be shown here. To use any other, upload the backup manually.",
"backupsCopy": "When applying a backup, a copy of the original \"db\" folder will be made next to it, in case anything goes wrong.",
"backupDownloadRestore": "Download / Restore",
"backupUpload": "Upload & Restore Backup",
"backupDownload": "Download Backup",
"backupRestore": "Restore Backup",
"backupNow": "Backup Now",
"backupCreated": "Backup created",
"backupCanBeFound": "The backup can be found on the server at {filepath}.",
"backupCanDownload": "Alternatively, click below to download the backup.",
"wikiPage": "Wiki Page",
"wiki": "Wiki"
},
"notifications": {
"pathCopied": "Full path copied to clipboard.",
"changedEmailAddress": "Changed email address of {n}.",
"userCreated": "User {n} created.",
"createProfile": "Created profile {n}.",
@@ -121,25 +219,26 @@
"sentAnnouncement": "Announcement sent.",
"savedAnnouncement": "Announcement saved.",
"setOmbiProfile": "Stored ombi profile.",
"savedProfile": "Stored profile changes.",
"updateApplied": "Update applied, please restart.",
"updateAppliedRefresh": "Update applied, please refresh.",
"telegramVerified": "Telegram account verified.",
"accountConnected": "Account connected.",
"errorConnection": "Couldn't connect to jfa-go.",
"error401Unauthorized": "Unauthorized. Try refreshing the page.",
"referralsEnabled": "Referrals enabled.",
"activityDeleted": "Activity Deleted.",
"errorInviteNoLongerExists": "Invite no longer exists.",
"errorInviteNotFound": "Invite not found.",
"errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.",
"errorHomescreenAppliedNoSettings": "Homescreen layout was applied, but applying settings may have failed.",
"errorSettingsFailed": "Application failed.",
"errorLoginBlank": "The username and/or password were left blank.",
"errorUnknown": "Unknown error.",
"errorSaveEmail": "Failed to save email.",
"errorBlankFields": "Fields were left blank",
"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.",
"errorSaveSettings": "Couldn't save settings.",
"errorLoadSettings": "Failed to load settings.",
"errorSetOmbiProfile": "Failed to store ombi profile.",
"errorLoadOmbiUsers": "Failed to load ombi users.",
@@ -150,6 +249,9 @@
"errorSendWelcomeEmail": "Failed to send welcome message (check console/logs)",
"errorApplyUpdate": "Failed to apply update, try manually.",
"errorCheckUpdate": "Failed to check for update.",
"errorNoReferralTemplate": "Profile doesn't contain referral template, add one in settings.",
"errorLoadActivities": "Failed to load activities.",
"errorInvalidDate": "Date is invalid.",
"updateAvailable": "A new update is available, check settings.",
"noUpdatesAvailable": "No new updates available."
},
@@ -158,6 +260,10 @@
"singular": "Modify Settings for {n} user",
"plural": "Modify Settings for {n} users"
},
"enableReferralsFor": {
"singular": "Enable Referrals for {n} user",
"plural": "Enable Referrals for {n} users"
},
"deleteNUsers": {
"singular": "Delete {n} user",
"plural": "Delete {n} users"
@@ -202,6 +308,10 @@
"singular": "Extend expiry for {n} user",
"plural": "Extend expiry for {n} users"
},
"setExpiry": {
"singular": "Set expiry for {n} user",
"plural": "Set expiry for {n} users"
},
"extendedExpiry": {
"singular": "Extended expiry for {n} user.",
"plural": "Extended expiry for {n} users."

View File

@@ -15,18 +15,10 @@
"warning": "Advertencia",
"inviteInfiniteUsesWarning": "Las invitaciones con usos infinitos se pueden usar de forma abusiva",
"inviteSendToEmail": "Enviar a",
"login": "Acceso",
"logout": "Cerrar sesión",
"create": "Crear",
"apply": "Aplicar",
"delete": "Eliminar",
"name": "Nombre",
"date": "Fecha",
"enabled": "Activado",
"disabled": "Desactivado",
"reEnable": "Reactivar",
"disable": "Desactivar",
"admin": "Administrador",
"updates": "Actualizaciones",
"update": "Actualizar",
"download": "Descargar",
@@ -35,7 +27,6 @@
"lastActiveTime": "Último activo",
"from": "De",
"user": "Usuario",
"expiry": "Expiración",
"userExpiry": "Caducidad del usuario",
"userExpiryDescription": "Una cantidad de tiempo específica después de cada registro, jfa-go eliminará / deshabilitará la cuenta. Puede cambiar este comportamiento en la configuración.",
"aboutProgram": "Acerca de",
@@ -51,7 +42,6 @@
"variables": "Variables",
"preview": "Vista previa",
"reset": "Reiniciar",
"edit": "Editar",
"extendExpiry": "Extender el vencimiento",
"customizeMessages": "Personalizar mensajes",
"customizeMessagesDescription": "Si no desea utilizar las plantillas de mensajes de jfa-go, puede crear las suyas con Markdown.",
@@ -85,7 +75,6 @@
"inviteUsersCreated": "Usuarios creados",
"inviteNoProfile": "Sin perfil",
"inviteDateCreated": "Creado",
"inviteRemainingUses": "Usos restantes",
"inviteNoInvites": "Ninguno",
"inviteExpiresInTime": "Caduca en {n}",
"notifyEvent": "Notificar en:",
@@ -93,7 +82,6 @@
"notifyUserCreation": "Sobre la creación de usuarios",
"conditionals": "Condicionales",
"donate": "Donar",
"add": "Agregar",
"templates": "Plantillas",
"contactThrough": "Contactar a través de:",
"select": "Seleccionar",
@@ -111,7 +99,44 @@
"sendPWRSuccessManual": "Si el usuario no lo ha recibido, presione copiar para generar el enlace y enviárselo manualmente.",
"sendPWRValidFor": "El enlace es válido por 30m.",
"sendPWRManual": "El usuario {n} no tiene ningún método de contacto, presione copiar para generar el enlace para enviarle.",
"ombiProfile": "Perfil de usuario de Ombi"
"ombiProfile": "Perfil de usuario de Ombi",
"logs": "Registros",
"accessJFA": "Acceso",
"accessJFASettings": "No se puede cambia, ya que se ha establecido \"Solo administradores\" o \"Permitir a todos\" en Configuración > General.",
"buildTime": "Tiempo de construcción",
"builtBy": "Construido por",
"sortingBy": "Ordenar por",
"filters": "Filtros",
"clearSearch": "Borrar búsqueda",
"searchOptions": "Opciones de búsqueda",
"matchText": "Coincidir texto",
"jellyfinID": "Jellyfin ID",
"userPageLogin": "Página de usuario: Iniciar sesión",
"userPagePage": "Página de usuario: Página",
"after": "Después",
"before": "Antes",
"unlink": "Desvincular cuenta",
"clickToRemoveFilter": "Haga clic para eliminar el filtro.",
"removeExpiry": "Eliminar caducidad",
"enterExpiry": "Introduzca una caducidad",
"useInviteExpiry": "Establecer caducidad desde el perfil/invitación",
"noResultsFound": "Ningún resultado encontrado",
"settingsDependsOn": "{setting}: Depende de {dependency}",
"activity": "Actividad",
"disabled": "Desactivado",
"deleted": "Eliminado",
"keepSearching": "Seguir buscando",
"keepSearchingDescription": "Solo se ha buscado en las actividades cargadas actualmente. Clique a continuación si quiere buscar en todas las actividades.",
"settingsAdvancedMode": "{setting}: Los ajustes avanzados deben estar habilitados",
"settingsMaybeUnderAdvanced": "Consejo: Puede que encuentre lo que busca si habilita los Ajustes avanzados.",
"invite": "Invitar",
"userLabel": "Etiqueta de usuario",
"userLabelDescription": "Etiqueta que aplicar a usuarios creados con esta invitación.",
"enableReferrals": "Habilitar referencias",
"disableReferrals": "Deshabilitar referencias",
"enableReferralsDescription": "Proporciona a los usuarios un enlace personal de referencia, parecido a una invitación, para que lo compartan con amigos y familiares. Puede conseguirse a través de una plantilla de referencia en un perfil, o a través de una invitación existente.",
"enableReferralsProfileDescription": "Proporciona a los usuarios creados con este perfil un enlace personal de referencia, parecido a una invitación, para que lo compartan con amigos y familiares. Cree una invitación con los ajustes deseados y selecciónela aquí. Cada referencia se basará en esta invitación. Puede eliminar la invitación una vez completado.",
"useInviteExpiryNote": "Por defecto las invitaciones caducan a los 90 días, pero pueden ser renovadas por el usuario. Habilite que la referencia sea desactivada cuando pase el tiempo establecido."
},
"notifications": {
"changedEmailAddress": "Se cambió la dirección de correo electrónico de {n}.",
@@ -122,13 +147,9 @@
"sentAnnouncement": "Anuncio enviado.",
"setOmbiDefaults": "Valores predeterminados de ombi almacenados.",
"updateApplied": "Actualización aplicada, por favor reinicie.",
"errorConnection": "No se pudo conectar a jfa-go.",
"error401Unauthorized": "No autorizado. Intente actualizar la página.",
"errorSettingsAppliedNoHomescreenLayout": "Se aplicó la configuración, pero es posible que no se haya aplicado el diseño de la pantalla de inicio.",
"errorHomescreenAppliedNoSettings": "Se aplicó el diseño de la pantalla de inicio, pero es posible que la aplicación de la configuración haya fallado.",
"errorSettingsFailed": "La aplicación falló.",
"errorLoginBlank": "El nombre de usuario y/o la contraseña se dejaron en blanco.",
"errorUnknown": "Error desconocido.",
"errorSaveEmail": "No se pudo guardar el correo electrónico.",
"errorBlankFields": "Los campos se dejaron en blanco",
"errorDeleteProfile": "No se pudo borrar el perfil {n}",
@@ -136,7 +157,6 @@
"errorCreateProfile": "No se pudo crear el perfil {n}",
"errorSetDefaultProfile": "No se pudo establecer el perfil predeterminado.",
"errorLoadUsers": "No se pudieron cargar los usuarios.",
"errorSaveSettings": "No se pudo guardar la configuración.",
"errorLoadSettings": "No se pudo cargar la configuración.",
"errorSetOmbiDefaults": "No se pudieron almacenar los valores predeterminados de ombi.",
"errorLoadOmbiUsers": "No se pudieron cargar los usuarios de Ombi.",
@@ -208,6 +228,10 @@
"extendedExpiry": {
"singular": "Caducidad extendida para {n} usuario.",
"plural": "Caducidad extendida para {n} usuarios."
},
"setExpiry": {
"singular": "Fijar la caducidad del usuario {n}",
"plural": "Establecer la caducidad para {n} usuarios"
}
}
}

View File

@@ -15,11 +15,8 @@
"warning": "Attention",
"inviteInfiniteUsesWarning": "les invitations infinies peuvent être utilisées abusivement",
"inviteSendToEmail": "Envoyer à",
"login": "S'identifier",
"logout": "Se déconnecter",
"create": "Créer",
"apply": "Appliquer",
"delete": "Effacer",
"name": "Nom",
"date": "Date",
"lastActiveTime": "Dernière activité",
@@ -55,16 +52,15 @@
"addProfileNameOf": "Nom de profil",
"addProfileStoreHomescreenLayout": "Enregistrer la disposition de l'écran d'accueil",
"inviteNoUsersCreated": "Aucun pour l'instant !",
"inviteUsersCreated": "Utilisateurs créer",
"inviteUsersCreated": "Utilisateurs créés",
"inviteNoProfile": "Aucun profil",
"inviteDateCreated": "Créer",
"inviteRemainingUses": "Utilisations restantes",
"inviteNoInvites": "Aucune",
"inviteExpiresInTime": "Expires dans {n}",
"notifyEvent": "Notifier sur :",
"notifyInviteExpiry": "À l'expiration",
"notifyUserCreation": "à la création de l'utilisateur",
"label": "Etiquette",
"label": "Nom",
"settingsRestarting": "Redémarrage…",
"settingsRestart": "Redémarrer",
"announce": "Annoncer",
@@ -75,15 +71,8 @@
"variables": "Variables",
"preview": "Aperçu",
"reset": "Réinitialisation",
"edit": "Éditer",
"customizeMessages": "Personnaliser les e-mails",
"inviteDuration": "Durée de l'invitation",
"enabled": "Activé",
"disabled": "Désactivé",
"reEnable": "Ré-activé",
"disable": "Désactivé",
"admin": "Administrateur",
"expiry": "Expiration",
"advancedSettings": "Paramètres avancés",
"userExpiry": "Expiration de l'utilisateur",
"updates": "Mises à jour",
@@ -96,7 +85,6 @@
"extendExpiry": "Prolonger l'expiration",
"contactThrough": "Contacté par :",
"sendPIN": "Demandez à l'utilisateur d'envoyer le code PIN ci-dessous au bot.",
"add": "Ajouter",
"select": "Sélectionner",
"findDiscordUser": "Trouver l'utilisateur Discord",
"linkMatrixDescription": "Entrez le nom d'utilisateur et le mot de passe de l'utilisateur pour lutilisateur comme bot. Une fois soumis, l'application va redémarrer.",
@@ -112,7 +100,103 @@
"sendPWRValidFor": "Ce lien est valable 30min.",
"sendPWRManual": "L'utilisateur {n} n'a pas indiqué de méthode de contact, appuyez sur copier pour recevoir un lien à lui envoyer.",
"sendPWRSuccessManual": "Si l'utilisateur ne l'a pas reçu, appuyez sur copier pour recevoir un lien à lui envoyer manuellement.",
"ombiProfile": "Profil d'utilisateur Ombi"
"ombiProfile": "Profil d'utilisateur Ombi",
"logs": "Logs",
"accessJFA": "Accès à jfa-go",
"accessJFASettings": "Ne peut pas être changé car \"Admin Only\" ou \"Allow All\" a été défini dans Paramètres > Général.",
"buildTime": "Heure de la version",
"builtBy": "Version créée par",
"sortingBy": "Trier par",
"filters": "Filtres",
"clickToRemoveFilter": "Cliquer pour supprimer ce filtre.",
"clearSearch": "Réinitialiser la recherche",
"actions": "Actions",
"searchOptions": "Recherche avancée",
"matchText": "Texte correspondant",
"jellyfinID": "ID Jellyfin",
"userPageLogin": "Page utilisateur : Connexion",
"userPagePage": "Page utilisateur : Page",
"after": "Après",
"before": "Avant",
"unlink": "Délier le compte",
"enableReferrals": "Activer Parrainage",
"enableReferralsDescription": "Offrez aux utilisateurs un lien de parrainage personnel semblable à une invitation, à envoyer à vos amis/famille. Peut provenir modèle de profil ou dune invitation existante.",
"invite": "Inviter",
"userLabel": "Étiquette",
"userLabelDescription": "Étiquette à appliquer aux utilisateurs créés avec cette invitation.",
"disableReferrals": "Désactiver Parrainage",
"enableReferralsProfileDescription": "Donnez aux utilisateurs créés avec ce profil un lien de parrainage personnel semblable à une invitation, à envoyer à vos amis/famille. Créez une invitation avec les paramètres souhaités, puis sélectionnez-la ici. Chaque référence sera alors basée sur cette invitation. Vous pouvez supprimer l'invitation une fois terminée.",
"loginNotAdmin": "Vous n'êtes pas administrateur ?",
"removeExpiry": "Supprimer l'expiration",
"enterExpiry": "Entrez une date d'expiration",
"useInviteExpiry": "Définir l'expiration du profil/invitation",
"sortDirection": "Ordre de trie",
"referrer": "Référence",
"accountLinked": "{contactMethod} lié : {user}",
"accountUnlinked": "{contactMethod} supprimé : {user}",
"accountResetPassword": "{user} réinitialise son mot de passe",
"expirationBasedOn": "Date donnée basée sur le 1er utilisateur.",
"accountDeleted": "Compte supprimé : {user}",
"accountChangedPassword": "{user} a changé son mot de passe",
"accountCreated": "Compte créé : {user}",
"accountDisabled": "Compte désactivé : {user}",
"accountReEnabled": "Compte réactivé : {user}",
"accountExpired": "Compte expiré : {user}",
"accountWillExpire": "Le compte expirera le {date}.",
"backups": "Sauvegardes",
"backupsDescription": "Des sauvegardes de la base de données peuvent être effectuées, restaurées ou téléchargées à partir d'ici.",
"backupsCopy": "Lors de l'application d'une sauvegarde, une copie du dossier \"db\" d'origine sera créée à côté, en cas de problème.",
"backupDownloadRestore": "Télécharger/Restaurer",
"backupUpload": "Télécharger et restaurer la sauvegarde",
"backupDownload": "Télécharger la sauvegarde",
"backupRestore": "Restaurer la sauvegarde",
"backupNow": "Sauvegarder maintenant",
"backupCreated": "Sauvegarde créée",
"backupCanDownload": "Vous pouvez également cliquer ci-dessous pour télécharger la sauvegarde.",
"wikiPage": "Wiki page",
"activity": "Activité",
"deleted": "Supprimé",
"disabled": "Désactivé",
"keepSearching": "Continuer la recherche",
"keepSearchingDescription": "Seules les activités actuellement chargées ont été recherchées. Cliquez ci-dessous si vous souhaitez rechercher toutes les activités.",
"settingsHiddenDependency": "Les paramètres correspondants sont masqués car ils dépendent de la valeur d'un autre paramètre :",
"settingsDependsOn": "{setting} : dépend de {dependency}",
"settingsMaybeUnderAdvanced": "Astuce : Vous trouverez peut-être ce que vous cherchez en activant les paramètres avancés.",
"settingsAdvancedMode": "{setting} : les paramètres avancés doivent être activés",
"actorDescription": "La chose qui a provoqué cette action. \"user\"/\"admin\"/\"service\" ou un nom d'utilisateur.",
"activityID": "ID d'activité",
"byUser": "Par Utilisateur",
"inviteExpired": "Invitation expirée : {invite}",
"byJfaGo": "Par jfa-go",
"accountDisabledFilter": "Compte désactivé",
"inviteCreated": "Invitation créée : {invite}",
"inviteDeleted": "Invitation supprimée : {invite}",
"fromInvite": "À partir de l'invitation",
"accountDeletionFilter": "Suppression de compte",
"userDeleted": "L'utilisateur a été supprimé.",
"userDisabled": "L'utilisateur a été désactivé",
"accountCreationFilter": "Création de compte",
"title": "Titre",
"usersMentioned": "Utilisateur mentionné",
"actor": "Acteur",
"byAdmin": "Par Administrateur",
"passwordResetFilter": "Réinitialisation du mot de passe",
"loadMore": "Charger plus",
"accountEnabledFilter": "Compte activé",
"inviteCreatedFilter": "Invitation crée",
"inviteDeletedFilter": "Invitation supprimée/expirée",
"noMoreResults": "Plus de résultats.",
"totalRecords": "{n} Nombre total d'enregistrements",
"passwordChangeFilter": "Mot de passe changé",
"loadedRecords": "{n} Chargé",
"shownRecords": "{n} affiché",
"contactUnlinkedFilter": "Contact sans lien",
"contactLinkedFilter": "Contact lié",
"loadAll": "Tout charger",
"noResultsFound": "Aucun résultat trouvé",
"useInviteExpiryNote": "Par défaut, les invitations expirent après 90 jours mais peuvent être renouvelées par l'utilisateur. Activez la désactivation de la référence après le délai défini.",
"backupsFormatNote": "Seuls les fichiers de sauvegarde au format standard seront affichés ici. Pour en utiliser un autre, veuillez charger la sauvegarde manuellement.",
"backupCanBeFound": "La sauvegarde peut être trouvée sur le serveur à {filepath}."
},
"notifications": {
"changedEmailAddress": "Adresse e-mail modifiée de {n}.",
@@ -120,20 +204,15 @@
"createProfile": "Profil créé {n}.",
"saveSettings": "Les paramètres ont été enregistrés",
"setOmbiDefaults": "Valeurs par défaut de Ombi.",
"errorConnection": "Impossible de se connecter à jfa-go.",
"error401Unauthorized": "Non autorisé. Essayez d'actualiser la page.",
"errorSettingsAppliedNoHomescreenLayout": "Les paramètres ont été appliqués, mais l'application de la disposition de l'écran d'accueil a peut-être échoué.",
"errorHomescreenAppliedNoSettings": "La disposition de l'écran d'accueil a été appliquée, mais l'application des paramètres a peut-être échoué.",
"errorSettingsFailed": "L'application a échoué.",
"errorLoginBlank": "Le nom d'utilisateur et/ou le mot de passe sont vides.",
"errorUnknown": "Erreur inconnue.",
"errorBlankFields": "Les champs sont vides",
"errorDeleteProfile": "Échec de la suppression du profil {n}",
"errorLoadProfiles": "Échec du chargement des profils.",
"errorCreateProfile": "Échec de la création du profil {n}",
"errorSetDefaultProfile": "Échec de la définition du profil par défaut.",
"errorLoadUsers": "Échec du chargement des utilisateurs.",
"errorSaveSettings": "Impossible d'enregistrer les paramètres.",
"errorLoadSettings": "Échec du chargement des paramètres.",
"errorSetOmbiDefaults": "Impossible de stocker les valeurs par défaut d'Ombi.",
"errorLoadOmbiUsers": "Échec du chargement des utilisateurs Ombi.",
@@ -155,7 +234,15 @@
"accountConnected": "Compte connecté.",
"savedAnnouncement": "Annonce enregistrée.",
"setOmbiProfile": "Profil ombi enregistré.",
"errorSetOmbiProfile": "Echec de la sauvegarde du profil ombi."
"errorSetOmbiProfile": "Echec de la sauvegarde du profil ombi.",
"errorNoReferralTemplate": "Le profil ne contient pas de modèle de référence, ajoutez-en un dans les paramètres.",
"referralsEnabled": "Parrainage activer.",
"errorLoadActivities": "Échec du chargement des activités.",
"pathCopied": "Chemin complet copié dans le presse-papiers.",
"activityDeleted": "Activité supprimée.",
"errorInviteNoLongerExists": "L'invitation n'existe plus.",
"errorInviteNotFound": "Invitation introuvable.",
"errorInvalidDate": "La date n'est pas valide."
},
"quantityStrings": {
"modifySettingsFor": {
@@ -209,6 +296,14 @@
"disabledUser": {
"singular": "{n} utilisateur désactivé.",
"plural": "{n} utilisateurs désactivés."
},
"setExpiry": {
"singular": "Définir l'expiration pour {n} utilisateur",
"plural": "Définir l'expiration pour {n} utilisateurs"
},
"enableReferralsFor": {
"singular": "Activer les parrainages pour {n} utilisateur",
"plural": "Activer les parrainages pour {n} utilisateur"
}
}
}

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