Compare commits

..

812 Commits
db ... main

Author SHA1 Message Date
Harvey Tindall
d72a5c91cf jf_activity_test: reduce time to run
values were reasonable when testing with a size limit of 20 or
something, now it's ~6000.
2025-12-20 11:58:56 +00:00
Harvey Tindall
cacd992aad jf_activity: remove debug line
also bump mediabrowser again to do the same.
2025-12-20 11:49:55 +00:00
Harvey Tindall
23ad016476 mediabrowser: bump to v0.3.34
forgot to switch back from my local copy in the previous commit.
2025-12-20 11:41:35 +00:00
Harvey Tindall
cc571409b9 Merge branch 'jf-log'
accidentally did a fix on the wrong branch, so i'm merging it
prematurely.
2025-12-20 11:23:18 +00:00
Harvey Tindall
d7bad69d40 jf-actvitity: functioning route, ombi fixes
forgot to switch branches before doing a fix for #455, so it's in here
too. OmbiUserByJfID/getOmbiUser takes an optional email *string, to
optionally pass an override email address to search with, used when
changing it.
2025-12-20 11:21:13 +00:00
Harvey Tindall
63bb678a72 Merge branch 'main' of github.com:hrfee/jfa-go 2025-12-19 13:12:27 +00:00
Harvey Tindall
2a41c5a393 admin: scroll current tab in nav to center
for #456, ensures you can see and press both the next and previous tabs
    even if you can't scroll for some reason.
2025-12-19 13:11:37 +00:00
Harvey Tindall
9b80492b4f userpage: fix display of email confirmation required message
recent adjustments to modals and CSS in general meant a "content" class
was removed that was being querySelector'ed. For #455.
2025-12-16 18:37:54 +00:00
Harvey Tindall
315d74eb2d userpage: fix redirect on contact method change
root userpage paths ("") would mess up the redirect, wrapper function
returns "/" if thats the case. For #455.
2025-12-16 18:32:37 +00:00
Harvey Tindall
c21df253a1 jf-activity: initial changes
functionality mostly there but needs a UI.
2025-12-16 18:15:39 +00:00
Harvey Tindall
e4e9369d54 theme: set theme-color for ios devices
colours the search bar, sometimes.
2025-12-11 14:47:22 +00:00
Harvey Tindall
26d05911fd ui: fix hidden overflow on mobile devices
mostly the accounts table was causing an overflow on mobile browsers,
specifically anything on ios and samsung browser. For #450.
2025-12-10 20:37:29 +00:00
Harvey Tindall
85a6d66228 html: fix headers
go's html/template was thinking header.html and other template snippets
were full documents, so inserting their own <html> and other tags,
breaking the head up. Renaming to txt seems to fix things.
2025-12-10 20:31:45 +00:00
Harvey Tindall
1c755306f9 tray: fix broken dependency. 2025-12-10 17:43:00 +00:00
Harvey Tindall
2e97142d9e accounts: reduce initial load time even further
took the referralCache idea further and did it for all db queries. Now
take ~40ms for ~5000 users on arm64 through QEMU, and ~60ms on a
rockpro64.
2025-12-10 17:39:54 +00:00
Harvey Tindall
a08a0fd3e6 accounts: reduce initial load time further
When generating the cache and calling userSummary per user, previously
the DB was queried for an invite with the ReferrerJellyfinID set to the
user's ID. This was fast enough on my test system (~5000 users in
~1.5s), while testing cross-compilation, I found it ran extremely slow
on an arm64 build (running through QEMU admittedly), doing ~5000 in
~18s. Instead, a map of IDs to invites/referrals is generated once and
queried instead. Initial load now takes ~80ms on my system, and 0.95s
through QEMU, and 0.68s on a rockpro64 SBC.
2025-12-10 16:44:21 +00:00
Harvey Tindall
420a22970d goreleaser: unset GOOS/GOARCH for precompile step
these should be run natively.
2025-12-10 15:05:30 +00:00
Harvey Tindall
d4109c8cf5 matrix: use goolm over libolm, ci: user smaller docker image base
removes one dependency. jfa-go-build-docker has been updated to reflect
this, and in general with a newer debian version and properly included
goreleaser, and a build for amd64. Dockerfiles now use a
"distroless"-style container as their base. Was gonna use
chainguard/glibc-dynamic, but it running as a different user meant it
wouldn't read/write from your /data mount without manual intervention.
2025-12-10 13:25:21 +00:00
Harvey Tindall
945d579f57 config: un-advanced date format, fix dependencies
changed dependency on "method" (from when it was in the email section)
to "enabled". For #453.
2025-12-10 09:51:12 +00:00
Harvey Tindall
6e1f07563d ci: use caches for container build too 2025-12-09 17:53:26 +00:00
Harvey Tindall
ce6f8b41dc ci: use npm cache too 2025-12-09 17:43:04 +00:00
Harvey Tindall
aa75305da4 scripts/version.sh: downgrade version
use go 1.24 and remove usage of wg.Go().
2025-12-09 17:42:45 +00:00
Harvey Tindall
03cf533c9d ci: use podman volumes for go mod/build cache 2025-12-09 17:38:12 +00:00
Harvey Tindall
ccf0584db8 build: use tsgo for type checking
I don't use and fancy typescript features, and apparently this version
is very usable at this point. Removes the typescript dependency, which
should have been in dev-deps anyway.
2025-12-09 17:31:23 +00:00
Harvey Tindall
833fd26091 scripts/variants: add parallel flag
doesn't result in much gain on my machine or my CI server so it's not
the default. On local machine (5800x), went from ~5ms to ~4ms, on my CI
(hetzner ampere altra with 8 vCPUs) went from ~15ms to ~10ms.
2025-12-09 15:44:47 +00:00
Harvey Tindall
fd72c838c3 userpage: don't trigger submit on "reset password" press
Fiex #452.
2025-12-09 15:25:54 +00:00
Harvey Tindall
63c770db73 css: fix lists with RTL
changes text-align:left to start, and margin-left: to
margin-inline-start:. Submitted a PR to a17t to change these values as
well. For #450.
2025-12-09 15:06:49 +00:00
Harvey Tindall
6237620390 scripts/variants: add verbose -v flag
disable output by default, enable with -v
2025-12-09 14:38:48 +00:00
Harvey Tindall
4a9bac1027 scripts: replace dark-variant.sh with go scripts/variants
uses regex mostly, resulting in a significantly faster execution (old:
~2s, new: ~31ms). Only matches classList.add/remove and class="..."
(won't touch string literal variables or colours), but these weren't
really used anyway. Supports multi-line classList.add/remove, which was
the reason I wrote this anyway (formatting the codebase introduced some
of these).
2025-12-09 14:28:05 +00:00
Harvey Tindall
817107622a ts: format finally
formatted with biome, a config file is provided.
2025-12-08 20:38:30 +00:00
Harvey Tindall
ca7c553147 telegram: add "always use default lang", /lang default
For #451. Setting in integrations > chat bots > telegram makes jfa-go
ignore the telegram users clients lanugage setting and always use
whatever is set as the default language in jfa-go. Also added /lang
default, to un-set a preferred language. the start message also now
shows up on typing /help or !help too.
2025-12-08 18:16:27 +00:00
Harvey Tindall
d00507fd20 accounts: fix matrix input
was too small to see what you were typing.
2025-12-08 17:50:50 +00:00
Harvey Tindall
2bbf97be19 accounts/activity: fix clear search button on RTL
use margin-inline-start instead of margin-left. For #450.
2025-12-08 17:46:11 +00:00
Harvey Tindall
4f135220bc lang: display ckb (kurdish) as RTL 2025-12-08 15:36:03 +00:00
Harvey Tindall
a2500add5a lang: add lang tag to name of Farsi and Kurdish 2025-12-08 15:35:12 +00:00
Harvey Tindall
26284e89f6 web: add more RTL fixes
For #450.
2025-12-08 15:30:24 +00:00
KSAm3lm
8030a7c896 Translated using Weblate (Arabic)
Currently translated at 100.0% (136 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/ar/
2025-12-08 16:13:58 +01:00
KSAm3lm
5010159621 Translated using Weblate (Arabic)
Currently translated at 100.0% (59 of 59 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/ar/
2025-12-08 16:13:58 +01:00
KSAm3lm
92090bcf99 translation from Weblate (Arabic)
Currently translated at 100.0% (304 of 304 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2025-12-08 16:13:58 +01:00
KSAm3lm
655096ea1e Translated using Weblate (Arabic)
Currently translated at 100.0% (56 of 56 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/ar/
2025-12-08 16:13:58 +01:00
Harvey Tindall
362984a391 web: remove almost every use of ml/mr
replace with flex and gap mostly. For #450.
2025-12-08 15:12:40 +00:00
Harvey Tindall
9c9e55147d lang: don't show lanugages with empty name fields 2025-12-08 14:38:24 +00:00
Harvey Tindall
f7a72133c6 lang: lowercase names 2025-12-08 14:36:01 +00:00
Harvey Tindall
341dd5930e settings: force LTR always
since it's untranslatable for now.
2025-12-07 20:43:14 +00:00
Harvey Tindall
5baa3b98cd tooltip: add .force-ltr class 2025-12-07 20:43:01 +00:00
Harvey Tindall
99c6559a54 ui: set RTL on some languages
checking with a list I found online. need to set some things as forced
ltr, such as settings. For #450.
2025-12-07 20:38:43 +00:00
Harvey Tindall
12827f6c84 admin: remove all non-dynamic ml/mr usage
replaced with flex gaps. For #450.
2025-12-07 20:28:05 +00:00
Harvey Tindall
ad942fd194 tooltip: flip on rtl 2025-12-07 19:50:15 +00:00
Harvey Tindall
b5e348ad2b ui: correct html lang tag
set according to the language picked.
2025-12-07 19:31:52 +00:00
KSAm3lm
0b8376a19e translation from Weblate (Arabic)
Currently translated at 48.6% (139 of 286 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
b64e2ac9a5 Translated using Weblate (Arabic)
Currently translated at 100.0% (136 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
c9e2e72d60 translation from Weblate (Arabic)
Currently translated at 40.5% (116 of 286 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
fc990a9ac2 translation from Weblate (Arabic)
Currently translated at 34.9% (100 of 286 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
8601fbf8e0 translation from Weblate (Arabic)
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/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
48b547661f translation from Weblate (Arabic)
Currently translated at 26.9% (77 of 286 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
423b060857 Translated using Weblate (Arabic)
Currently translated at 100.0% (136 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
48cf1a7e39 translation from Weblate (Arabic)
Currently translated at 26.9% (77 of 286 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
47954db751 Translated using Weblate (Arabic)
Currently translated at 100.0% (54 of 54 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
1fe868b41e translation from Weblate (Arabic)
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/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
07f683282a Translated using Weblate (Arabic)
Currently translated at 61.0% (83 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
0a98eea1ac translation from Weblate (Arabic)
Currently translated at 26.9% (77 of 286 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
40d86214cb Translated using Weblate (Arabic)
Currently translated at 59.5% (81 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
959b10c2a1 Translated using Weblate (Arabic)
Currently translated at 100.0% (54 of 54 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
f11fc2e3bc Translated using Weblate (Arabic)
Currently translated at 13.2% (18 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
2e2fcb0beb translation from Weblate (Arabic)
Currently translated at 26.2% (75 of 286 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
d2d90083be Translated using Weblate (Arabic)
Currently translated at 100.0% (54 of 54 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
355ffd5975 translation from Weblate (Arabic)
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/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
0010ece89c Translated using Weblate (Arabic)
Currently translated at 100.0% (13 of 13 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
c8db639701 Translated using Weblate (Arabic)
Currently translated at 11.7% (16 of 136 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
3bb499dc9d Translated using Weblate (Arabic)
Currently translated at 100.0% (59 of 59 strings)

Translation: jfa-go/Emails
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/emails/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
45d83435cb translation from Weblate (Arabic)
Currently translated at 25.8% (74 of 286 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
2535aba2fe Translated using Weblate (Arabic)
Currently translated at 100.0% (54 of 54 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/ar/
2025-12-07 20:07:14 +01:00
KSAm3lm
3dc039135a 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/
2025-12-07 20:07:14 +01:00
KSAm3lm
726f90bfae Translated using Weblate (Arabic)
Currently translated at 100.0% (13 of 13 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/ar/
2025-12-07 20:07:14 +01:00
Harvey Tindall
709c394d2d invites: use string getting func on CopyButton
should fix #449.
2025-12-07 11:40:16 +00:00
Harvey Tindall
fb5ea43ef5 CopyButton: add string getting func option 2025-12-07 11:38:46 +00:00
Harvey Tindall
49f2026c47 npm: bump esbuild 2025-12-06 20:25:06 +00:00
Harvey Tindall
5ad225fa78 Merge pull request #395 from hrfee/dependabot/npm_and_yarn/cross-spawn-7.0.6
build(deps): bump cross-spawn from 7.0.3 to 7.0.6
2025-12-06 20:21:25 +00:00
Harvey Tindall
155edc997a Merge branch 'main' into dependabot/npm_and_yarn/cross-spawn-7.0.6 2025-12-06 20:20:34 +00:00
Harvey Tindall
591547c167 Merge branch 'main' of github.com:hrfee/jfa-go 2025-12-06 20:19:13 +00:00
Harvey Tindall
14e8e63f30 easyproxy: update deps
closes #398.
2025-12-06 20:18:50 +00:00
Harvey Tindall
ccba0e5137 Merge pull request #434 from hrfee/dependabot/npm_and_yarn/glob-10.5.0
build(deps): bump glob from 10.4.5 to 10.5.0
2025-12-06 20:16:50 +00:00
Harvey Tindall
a62e6a5dfc Merge pull request #435 from hrfee/dependabot/npm_and_yarn/site/glob-10.5.0
build(deps): bump glob from 10.4.5 to 10.5.0 in /site
2025-12-06 20:16:37 +00:00
Harvey Tindall
975c47a79b settings: fix showing advanced ones
fixes #448.
2025-12-06 20:14:44 +00:00
Harvey Tindall
fcfd5f4981 query unescape more routes
fixes #447. Went through all routes with components in the path to check
if they needed escaping, quite a few did.
2025-12-06 20:04:30 +00:00
Harvey Tindall
e5315095be invites: improve dropdown animation
involves negative margins and removing padding resulting in a messy
expand function, but it looks smooth. Could also be done on the settings
page, but it looks mostly alright there already.
2025-12-06 17:43:54 +00:00
Harvey Tindall
44e4b5fce2 invites: editable label, /invites/edit route
PATCH /invite/edit lets you edit an invite by giving new values for a
subset of inviteDTO (EditableInviteDTO). Replaces /invite/profile and
/invite/notify, and allows changing (user)label and user expiry as well
as the previously customizable values through other routes. An edit
button next to the code/label allows changing on the invites tab.
2025-12-06 15:38:06 +00:00
Harvey Tindall
4bb116417e messages: fix black page background when editing
changed the "body, .body {" to ".body" in the mail CSS so it doesn't
affect the web page. Seems to look fine still in gmail in both light and
dark modes. Fixes #446
2025-12-06 14:05:12 +00:00
Harvey Tindall
51f604d061 ivnites: use actual inviteDTO for DOMInvite
no intermediary parsing step. Also, moved the date -> duration (3mo6d3h
sorta thing) to the web, there's now a ValidTill field with a unix
timestamp. Used the new Temporal api with a polyfill. Bumped api version
(although it still isn't semver).
2025-12-06 14:01:06 +00:00
Harvey Tindall
ab7694b50b messages: improve editor CSS
improved wrapping behaviour.
2025-12-05 20:46:42 +00:00
Harvey Tindall
556e1411f4 invites: add "send to" to dropdown
allows sending to more people after creating the invite. Fixes #444.
2025-12-05 19:51:42 +00:00
Harvey Tindall
5fa528fd2d invites: add /invites/send, store more details in new SentTo field
deprecated SendTo string field for SentTo, which holds successful send
addresses, and failures with a reason that isn't plain text. Will soon
add an interface for sending invites after their creation. For #444
(ha).
2025-12-05 12:03:21 +00:00
Harvey Tindall
3635a13682 fix opening create profile when jellyseerr disabled
fixes #445.
2025-12-04 18:19:33 +00:00
Harvey Tindall
b59cd73e43 messages: switch to new template package
rewrote and put in its own repo. Now supports {if a ==/!= b}, string
literals ({if a == "b"}), else/else if and nested if statements.
2025-12-04 11:50:14 +00:00
Harvey Tindall
6e31a7e2dd form: add pre-signup card
same as the post-signup card, but shown on the sidebar.
2025-12-02 14:56:15 +00:00
Harvey Tindall
a4b94b4f45 tray: switch to fork, use higher res icon
switched to a fork of a fork of getlantern/systray which has no
ayatana-appindicator dependency.
2025-12-02 13:05:06 +00:00
Harvey Tindall
622de21dcf ci: move buildrone upload back to before container build 2025-12-02 11:46:37 +00:00
Harvey Tindall
0ab5bd1705 Merge branch 'main' of github.com:hrfee/jfa-go 2025-12-02 00:41:23 +00:00
Harvey Tindall
f74e85662a email: add migration for pwr email file rename
sorry this was broken for a while for old users.
2025-12-02 00:40:48 +00:00
Harvey Tindall
862217a627 ci: use python3, not python 2025-12-01 17:37:28 +00:00
Harvey Tindall
861e72b331 ci: finalize merged ci files
for merging. renamed to unstable and stable, removed the -docker files,
    and modified the stable one too.
2025-12-01 16:31:13 +00:00
Harvey Tindall
550cb36bd1 ci: fix goreleaser, rename arm_v6, upload to dockerhub temporarily 2025-12-01 15:14:06 +00:00
Harvey Tindall
03d3cee18b ci: attempt to combine git-docker with git-binary
using a second Dockerfile, and the prevous Makefile changes to share the
precompile step. git-binary.yaml temporarily set for ci-streamline
branch and is in a "dry run" form.
2025-12-01 14:52:10 +00:00
Harvey Tindall
331e7c13fa Makefile: share $(DATA) on internal/external, keep record of build flags
This means you could make precompile, then make INTERNAL=on then make
INTERNAL=off and each time it would re-generate the go files only since
the flags have changed.
2025-12-01 14:18:10 +00:00
Harvey Tindall
429538688c ci: pull tags for docker build
needed for CSSVERSION.
2025-12-01 13:24:28 +00:00
Harvey Tindall
70ee98f9f0 fix printf types
was causing builds to fail on ci.
2025-12-01 12:55:24 +00:00
Harvey Tindall
320e9cd9d0 mediabrowser: bump
imrpvoed UserNotFound error classification, no longer vulnerable to
random 404s from proxies or whatever (for Jellyfin only, not emby)
2025-12-01 12:43:40 +00:00
Harvey Tindall
50455b828d housekeeping/discord: respect remove role on disable setting
only remove it if it's set to true.
2025-11-30 21:59:46 +00:00
Harvey Tindall
b525b03ef8 Merge branch 'main' of github.com:hrfee/jfa-go 2025-11-30 21:42:12 +00:00
Harvey Tindall
641669873d setup: mark external URL required
this should really be given but things'll probably work without it.
2025-11-30 21:40:59 +00:00
Harvey Tindall
fb1b673dee settings: add "tasks" button (advanced)
added a GET /tasks route to list tasks with a description (untranslated,
but this is mostly a dev feature anyway). Loaded in a modal by enabling
advanced settings and pressing the Tasks button at the top (where logs,
backups, restart are). Also added some icons in settings, and removed
some redundant "flex flex-row"s on buttons and reduced the spacing in
those with icons to gap-1.
2025-11-29 15:43:06 +00:00
Harvey Tindall
598a389e3d jellyseerr: fix extremely long import, run only once
cache was being invalidated for every user, and on my 5000 user test
instance, this sweated jellyseerr and my computer (audibly). Also, since
this only needs to realistically run once, a flag is set in the database
to indicate it's been done, and unset once the feature is disabled.
It'll only run on boot if the flag is unset, or if triggered by the
/tasks route. Will likely add manual trigger buttons on the web as well.
2025-11-29 14:13:34 +00:00
Harvey Tindall
1a0e32504f accounts: hide js/ombi checks when not enabled correctly on modify
settings
2025-11-28 20:52:37 +00:00
Harvey Tindall
cbff3085fa profiles: "add jellyseerr" checkbox on profile creation
makes things clearer to new users. Fixes #438.
2025-11-28 20:37:45 +00:00
Harvey Tindall
0ecacc6064 tasks: add /tasks/jellyseerr, document
now live in tasks.go and have actual API documentation.
/tasks/jellyseerr triggers account import.
2025-11-28 17:26:27 +00:00
Harvey Tindall
f36a32773a jellyseerr: fix background daemon auto import of telegram
ChatID fix was done in the TPS implementation but not in the daemon.
2025-11-28 17:10:38 +00:00
Harvey Tindall
58a3fe1f72 jellyseerr: don't attempt to apply profile if disabled
fixes #441
2025-11-28 16:44:03 +00:00
Harvey Tindall
4d58fc5f88 telegram: fix linking on sign-up
fixes #440.
2025-11-28 16:34:39 +00:00
Harvey Tindall
7d947015d3 pwr: allow jellyfin pwrs when not using email
switched if !emailEnabled { return } to if !messagesEnabled { return }.
Fixes #439.
2025-11-28 16:21:00 +00:00
Harvey Tindall
77d2ad3b6b profiles: add ability to directly edit profile JSON
allows for customizing small things, like changing admin status.
2025-11-28 15:13:46 +00:00
Harvey Tindall
f83695190d build: use most recent tag as cssversion
the value is fixed in html files though, hopefully this will the light
the fire under me to make sure I keep changing the version with updates.
2025-11-28 12:36:49 +00:00
Harvey Tindall
815721adb2 profiles: add routes for viewing/modifying
most of Profile struct is now in ProfileDTO (which is embedded in the
former), admin can pull it from /profiles/raw/{name} GET, and replace it
with PUT. Will make a ui for this.
2025-11-28 12:06:17 +00:00
Harvey Tindall
836974e1b2 scripts: stop using gopkg.in/yaml
used a different library in scripts/yaml, forgot to in scripts/ini and
main.go
2025-11-27 20:38:16 +00:00
Harvey Tindall
96983d70c8 ci: fix stable, add gh token for publishing
haven't done a stable with woodpecker, I hope it works!
2025-11-27 20:31:56 +00:00
Harvey Tindall
9400a5bc66 update LICENSE date 2025-11-27 20:19:59 +00:00
Harvey Tindall
033319af29 css: bump CSSVERSION
gonna set it to the version number of the software from now on, i think.
2025-11-27 20:13:31 +00:00
Harvey Tindall
787d0e7b4c config: rename some sections
removing redundant words from section title for those now in groups.
2025-11-27 19:50:32 +00:00
Aldo
d90617c027 Translated using Weblate (Spanish)
Currently translated at 100.0% (13 of 13 strings)

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

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

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

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

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

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/tr/
2025-11-27 20:49:58 +01:00
Adnan
59400dbc61 translation from Weblate (Turkish)
Currently translated at 100.0% (68 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/tr/
2025-11-27 20:49:58 +01:00
Adnan
0b06dd29c4 Translated using Weblate (Turkish)
Currently translated at 100.0% (54 of 54 strings)

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

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

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

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

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

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

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

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

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/hu/
2025-11-27 20:49:58 +01:00
Oszi2
2543cd08c2 translation from Weblate (Hungarian)
Currently translated at 100.0% (68 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/hu/
2025-11-27 20:49:58 +01:00
Oszi2
9c353f2a91 Translated using Weblate (English (United Kingdom))
Currently translated at 78.6% (107 of 136 strings)

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

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

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

added more/most sections to a group now.

groups have their maxHeight set to 9999px once animation finishes, and
have it quickly set back to ~scrollHeight before they're animated
closed.
2025-11-24 18:31:35 +00:00
Harvey Tindall
a3dc8b7e07 settings: render groups
a little off at the moment but works, groups show as accordions and can
be nested. Maybe add indentation, and probably show them first in the
list. Also make sure search works with them.
2025-11-24 15:19:20 +00:00
Harvey Tindall
6bfb345169 config add "Group" notion
a group contains an ordered list of settings sections and/or other
groups. Intended to be rendered as an accordion tree in the app. Has no
effect on INI structure.
2025-11-24 15:16:47 +00:00
Harvey Tindall
704157be00 bump api version 2025-11-24 11:49:26 +00:00
Harvey Tindall
b1c578ccf4 mediabrowser: bump
also updated everything else.
2025-11-23 20:10:34 +00:00
Harvey Tindall
7c9f917114 swag: add new statistics tag, add filtered user count route 2025-11-23 16:55:23 +00:00
Harvey Tindall
b5f28da452 updater: demote "tag empty" to debug log
the stable tag is usually empty because i rarely update it so it'd be
nice if this didn't show up so much for normal users. For #313, #329 and
more, probably.
2025-11-21 17:22:49 +00:00
dependabot[bot]
34f5455ba5 build(deps): bump glob from 10.4.5 to 10.5.0 in /site
Bumps [glob](https://github.com/isaacs/node-glob) from 10.4.5 to 10.5.0.
- [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/node-glob/compare/v10.4.5...v10.5.0)

---
updated-dependencies:
- dependency-name: glob
  dependency-version: 10.5.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-19 17:42:14 +00:00
dependabot[bot]
1caf9b3c88 build(deps): bump glob from 10.4.5 to 10.5.0
Bumps [glob](https://github.com/isaacs/node-glob) from 10.4.5 to 10.5.0.
- [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/node-glob/compare/v10.4.5...v10.5.0)

---
updated-dependencies:
- dependency-name: glob
  dependency-version: 10.5.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-19 02:33:43 +00:00
Harvey Tindall
60ccc51232 settings: deprecate most custom file path settings 2025-10-21 17:32:05 +01:00
Harvey Tindall
1780aa567f Merge branch 'main' of github.com:hrfee/jfa-go 2025-10-21 17:15:52 +01:00
Harvey Tindall
6a8b21c5f2 mention 10.11.0 compatibility
seems to work, someone opened an issue but closed it right after also.
Release notes don't say anything alarming either.
2025-10-21 17:15:00 +01:00
Harvey Tindall
fc4cd4cd27 Merge pull request #432 from mxkyb/fix-template-key
Adjust Template Key
2025-09-25 19:56:34 +01:00
Max Kieltyka
465ed9f84f adjust template key 2025-09-25 20:28:02 +02:00
Harvey Tindall
d88194b9bd accounts: invalidate user cache in more/all places
using app.userSummary as a source of relevant storage places and things
to look for.
2025-09-01 21:28:56 +01:00
Harvey Tindall
6ebc7d18bf accounts: fix bool queries on (some) string fields
wasn't implemented for things like email on the server side.
also changed text mail variant's footers to all use {{ .footer }} like I
should have before.
2025-09-01 20:44:19 +01:00
Harvey Tindall
0fe574fbd9 discord: clarify "Invite channel" setting
mention it's the name you put there, not the ID.
2025-09-01 19:18:48 +01:00
Harvey Tindall
c7ba9944f0 images: change banner font
use plus jakarta sans, the font used on the newer Jellyfin logo for the
banner and social images.
2025-09-01 18:30:16 +01:00
Harvey Tindall
8781e48601 email: change font, template common parts
Using the newer Jellyfin logo font for the header and hanken grotesk for the body.
Tried to redo emails with maizzle because using tailwind sounded nice, but getting it
to look like a17t would be more trouble than it's worth, since you can't
use CSS vars in emails and a17t uses them heavily. Instead, cleaned up
the mj-header a little and stored it in a separate file, and also the
header & footer, and changed the template vars with {{ .header }}  and
{{ .footer }} for all emails. Values are determined by
CustomContentInfo.Header/FooterText funcs. nil values are replaced at
program start by _runtimeValidator.

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

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

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

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

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

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

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

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

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

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

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

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

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

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/th/
2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
dd91a5cb86 Translated using Weblate (Thai)
Currently translated at 100.0% (10 of 10 strings)

Translation: jfa-go/Password Reset Links
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/password-reset-links/th/
2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
9998aff69a Translated using Weblate (Thai)
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/th/
2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
5ebcb9d51c translation from Weblate (Thai)
Currently translated at 100.0% (68 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/th/
2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
c60f93dfe8 add translation from Weblate (Thai) 2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
95e77b2e21 Added translation using Weblate (Thai) 2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
5a335a1465 Added translation using Weblate (Thai) 2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
4e7256fb6c Added translation using Weblate (Thai) 2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
dd8119d952 Added translation using Weblate (Thai) 2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
4f8fd7fb5b Added translation using Weblate (Thai) 2025-07-19 21:19:47 +02:00
Cartoon Kritthapath Yaviraj
bd573f34c0 add translation from Weblate (Thai) 2025-07-19 21:19:47 +02:00
nehogyirj
37576f332c translation from Weblate (Hungarian)
Currently translated at 38.6% (106 of 274 strings)

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

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

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/pl/
2025-07-19 21:19:47 +02:00
Sainaif
cbb8de01b7 Added translation using Weblate (Persian) 2025-07-19 21:19:47 +02:00
Blackspirits
a2e263a7d1 Translated using Weblate (Portuguese (Portugal))
Currently translated at 100.0% (12 of 12 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/pt_PT/
2025-07-19 21:19:47 +02:00
Blackspirits
7a51acbfe4 Added translation using Weblate (Portuguese) 2025-07-19 21:19:47 +02:00
Blackspirits
aa04ede019 translation from Weblate (Portuguese (Portugal))
Currently translated at 100.0% (68 of 68 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/pt_PT/
2025-07-19 21:19:47 +02:00
Blackspirits
9cca1d97cd add translation from Weblate (Portuguese) 2025-07-19 21:19:47 +02:00
TrollCLGT
e7fcdf0e65 Translated using Weblate (Vietnamese)
Currently translated at 33.3% (17 of 51 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/vi/
2025-07-19 21:19:47 +02:00
Vittat
d123d6aa9e Translated using Weblate (Spanish)
Currently translated at 100.0% (12 of 12 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/es/
2025-07-19 21:19:47 +02:00
Vittat
42d5785025 translation from Weblate (Spanish)
Currently translated at 97.0% (66 of 68 strings)

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

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

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

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

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

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

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

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-15 14:52:27 +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
338 changed files with 38897 additions and 23278 deletions

View File

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

1
.git-blame-ignore-revs Normal file
View File

@@ -0,0 +1 @@
817107622a8fe6f2fdaf198da4b2632854aa9bac

6
.gitignore vendored
View File

@@ -24,3 +24,9 @@ matacc.txt
scripts/langmover/lang
scripts/langmover/lang2
scripts/langmover/out
tinyproxy.conf
static/banner.svg
start.sh
ts/*.tsbuildinfo
ts/**/*.tsbuildinfo
js/

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,53 +8,37 @@ release:
name_template: "v{{.Version}}"
before:
hooks:
- go mod download
- rm -rf data/web
- mkdir -p data/web/css
- bash -c 'cp -r static/* data/web/'
- npm install
- npm install esbuild
- cp node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 data/web/css/
- cp -r html data/
- node scripts/missing-colors.js html data/html
- cp -r lang data/
- cp LICENSE data/
- cp jfa-go.service data/
- python3 scripts/enumerate_config.py -i config/config-base.json -o data/config-base.json
- python3 scripts/generate_ini.py -i config/config-base.json -o data/config-default.ini
- python3 scripts/compile_mjml.py -o data/
- rm -rf tempts
- cp -r ts tempts
- scripts/dark-variant.sh tempts
- scripts/dark-variant.sh tempts/modules
- mkdir -p data/web/js
- npx esbuild --target=es6 --bundle tempts/admin.ts --outfile=./data/web/js/admin.js --minify
- npx esbuild --target=es6 --bundle tempts/user.ts --outfile=./data/web/js/user.js --minify
- npx esbuild --target=es6 --bundle tempts/pwr.ts --outfile=./data/web/js/pwr.js --minify
- npx esbuild --target=es6 --bundle tempts/form.ts --outfile=./data/web/js/form.js --minify
- npx esbuild --target=es6 --bundle tempts/setup.ts --outfile=./data/web/js/setup.js --minify
- npx esbuild --target=es6 --bundle tempts/crash.ts --outfile=./data/crash.js --minify
- rm -r tempts
- npx esbuild --bundle css/base.css --outfile=./data/web/css/bundle.css --external:remixicon.css --external:../fonts/hanken* --minify
- cp html/crash.html data/
- npx tailwindcss -i data/web/css/bundle.css -o data/bundle.css --content "html/crash.html"
- node scripts/inline.js root data data/crash.html data/crash.html
- rm data/bundle.css
- npx tailwindcss -i data/web/css/bundle.css -o data/web/css/bundle.css
- mv data/crash.html data/html/
- go install github.com/swaggo/swag/cmd/swag@latest
- swag init -g main.go
- mv data/web/css/bundle.css data/web/css/{{.Env.JFA_GO_CSS_VERSION}}bundle.css
- npm ci
- env GOOS= GOARCH= 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.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater={{.Env.JFA_GO_UPDATER}} {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
goos:
- linux
- darwin
- 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,goolm,{{ .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
@@ -64,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 -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}" -H=windowsgui
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater={{.Env.JFA_GO_UPDATER}} {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}" -H=windowsgui
goos:
- windows
goarch:
@@ -75,19 +61,22 @@ builds:
dir: ./
env:
- CGO_ENABLED=1
- CC=x86_64-linux-gnu-gcc
- CXX=x86_64-linux-gnu-gcc
- PKG_CONFIG_PATH=/usr/lib/{{ if eq .Arch "amd64" }}x86_64{{ else if eq .Arch "arm64" }}aarch64{{ else }}{{ .Arch }}{{ end }}-linux-gnu{{ if eq .Arch "arm" }}eabihf{{ end }}/pkgconfig:$PKG_CONFIG_PATH
flags:
- -tags=tray
- -tags=tray,e2ee,goolm,{{ .Env.JFA_GO_TAG }}
ldflags:
- -s -w -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
- -X main.version={{.Env.JFA_GO_VERSION}} -X main.commit={{.ShortCommit}} -X main.updater={{.Env.JFA_GO_UPDATER}} {{.Env.JFA_GO_STRIP}} -X main.cssVersion={{.Env.JFA_GO_CSS_VERSION}} -X main.buildTimeUnix={{.Env.JFA_GO_BUILD_TIME}} -X main.builtBy="{{.Env.JFA_GO_BUILT_BY}}"
goos:
- linux
goarch:
- amd64
archives:
- id: windows-tray
builds:
ids:
- windows-tray
format: zip
formats: [ "zip" ]
name_template: >-
{{ .ProjectName }}_{{ .Version }}_TrayIcon_
{{- if eq .Os "darwin" }}macOS
@@ -95,9 +84,9 @@ archives:
{{- if eq .Arch "amd64" }}x86_64
{{- else }}{{ .Arch }}{{ end }}
- id: linux-tray
builds:
ids:
- linux-tray
format: zip
formats: [ "zip" ]
name_template: >-
{{ .ProjectName }}_{{ .Version }}_TrayIcon_
{{- if eq .Os "darwin" }}macOS
@@ -105,19 +94,29 @@ archives:
{{- if eq .Arch "amd64" }}x86_64
{{- else }}{{ .Arch }}{{ end }}
- id: notray
builds:
ids:
- notray
format: zip
formats: [ "zip" ]
name_template: >-
{{ .ProjectName }}_{{ .Version }}_
{{- if eq .Os "darwin" }}macOS
{{- else }}{{- title .Os }}{{ end }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else }}{{ .Arch }}{{ end }}
- id: notray-e2ee
ids:
- notray-e2ee
formats: [ "zip" ]
name_template: >-
{{ .ProjectName }}_{{ .Version }}_MatrixE2EE_
{{- if eq .Os "darwin" }}macOS
{{- else }}{{- title .Os }}{{ end }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else }}{{ .Arch }}{{ end }}
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "0.0.0-{{ .Env.JFA_GO_NFPM_EPOCH }}"
version_template: "0.0.0-{{ .Env.JFA_GO_NFPM_EPOCH }}"
changelog:
sort: asc
filters:
@@ -134,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
@@ -143,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
@@ -152,7 +161,7 @@ nfpms:
license: MIT
vendor: hrfee.dev
version_metadata: git
builds:
ids:
- linux-tray
contents:
- src: ./LICENSE
@@ -168,10 +177,10 @@ nfpms:
replaces:
- jfa-go
dependencies:
- libayatana-appindicator
- libolm-dev
rpm:
dependencies:
- libappindicator-gtk3
- libolm
apk:
dependencies:
- libayatana-appindicator
- olm

101
.woodpecker/stable.yaml Normal file
View File

@@ -0,0 +1,101 @@
when:
- event: tag
branch: main
clone:
git:
image: woodpeckerci/plugin-git
settings:
tags: true
partial: false
depth: 0
steps:
- name: precompile
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
volumes:
- jfa-go-build-cache:/root/.cache/go-build
- jfa-go-mod-cache:/go/pkg/mod
- jfa-go-npm-cache:/npm
commands:
- npm i
- make precompile
- go mod download
- name: test
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
volumes:
- jfa-go-build-cache:/root/.cache/go-build
- jfa-go-mod-cache:/go/pkg/mod
- jfa-go-npm-cache:/npm
commands:
- make test
- name: build
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
GITHUB_TOKEN:
from_secret: GITHUB_TOKEN
volumes:
- jfa-go-build-cache:/root/.cache/go-build
- jfa-go-mod-cache:/go/pkg/mod
- jfa-go-npm-cache:/npm
commands:
- ./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: build-external
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
volumes:
- jfa-go-build-cache:/root/.cache/go-build
- jfa-go-mod-cache:/go/pkg/mod
- jfa-go-npm-cache:/npm
commands:
- sed -i 's#id="password_resets-watch_directory" placeholder="/config/jellyfin"#id="password_resets-watch_directory" value="/jf" disabled#g' ./build/data/html/setup.html
- env GOOS=linux INTERNAL=off ./scripts/version.sh goreleaser build --id notray-e2ee --clean
- mv ./dist/notray-e2ee_linux_arm_6 ./dist/notray-e2ee_linux_arm
- name: container
image: docker.io/woodpeckerci/plugin-docker-buildx
settings:
dry_run: false
dockerfile: Dockerfile.ci
username:
from_secret: DOCKER_USERNAME
password:
from_secret: DOCKER_TOKEN
repo: docker.io/hrfee/jfa-go
tags: stable
registry: docker.io
platforms: linux/amd64,linux/arm64,linux/arm/v7
build_args:
- BUILT_BY:
from_secret: BUILT_BY
- 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'
- python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-stable=true

119
.woodpecker/unstable.yaml Normal file
View File

@@ -0,0 +1,119 @@
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: precompile
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
volumes:
- jfa-go-build-cache:/root/.cache/go-build
- jfa-go-mod-cache:/go/pkg/mod
- jfa-go-npm-cache:/npm
commands:
- npm ci --cache /npm --prefer-offline
- make precompile
- go mod download
- name: test
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
volumes:
- jfa-go-build-cache:/root/.cache/go-build
- jfa-go-mod-cache:/go/pkg/mod
- jfa-go-npm-cache:/npm
commands:
- make test
- name: build
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
volumes:
- jfa-go-build-cache:/root/.cache/go-build
- jfa-go-mod-cache:/go/pkg/mod
- jfa-go-npm-cache:/npm
commands:
- ./scripts/version.sh goreleaser --snapshot --skip=publish --clean
- name: buildrone-binary
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
commands:
- wget https://builds.hrfee.pw/upload.py
- bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --upload ./dist/*.zip ./dist/*.rpm ./dist/*.apk --tag internal-git=true'
- name: 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: build-external
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
JFA_GO_SNAPSHOT: y
JFA_GO_BUILT_BY:
from_secret: BUILT_BY
volumes:
- jfa-go-build-cache:/root/.cache/go-build
- jfa-go-mod-cache:/go/pkg/mod
- jfa-go-npm-cache:/npm
commands:
- sed -i 's#id="password_resets-watch_directory" placeholder="/config/jellyfin"#id="password_resets-watch_directory" value="/jf" disabled#g' ./build/data/html/setup.html
- env GOOS=linux INTERNAL=off ./scripts/version.sh goreleaser build --snapshot --id notray-e2ee --clean
- mv ./dist/notray-e2ee_linux_arm_6 ./dist/notray-e2ee_linux_arm
- name: container
image: docker.io/woodpeckerci/plugin-docker-buildx
settings:
dry_run: false
dockerfile: Dockerfile.ci
username:
from_secret: DOCKER_USERNAME
password:
from_secret: DOCKER_TOKEN
repo: docker.io/hrfee/jfa-go
tags: unstable
registry: docker.io
platforms: linux/amd64,linux/arm64,linux/arm/v7
build_args:
- BUILT_BY:
from_secret: BUILT_BY
- name: buildrone-container
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
commands:
- wget https://builds.hrfee.pw/upload.py
- python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-unstable=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,20 @@
FROM --platform=$BUILDPLATFORM golang:latest AS support
FROM --platform=$BUILDPLATFORM docker.io/hrfee/jfa-go-build-docker:latest AS support
ARG BUILT_BY
ENV JFA_GO_BUILT_BY=$BUILT_BY
COPY . /opt/build
RUN apt-get update -y \
&& apt-get install build-essential python3-pip curl software-properties-common sed -y \
&& (curl -sL https://deb.nodesource.com/setup_current.x | bash -) \
&& apt-get install nodejs \
&& (cd /opt/build; make configuration npm email typescript variants-html bundle-css inline-css swagger copy INTERNAL=off GOESBUILD=on) \
&& sed -i 's#id="password_resets-watch_directory" placeholder="/config/jellyfin"#id="password_resets-watch_directory" value="/jf" disabled#g' /opt/build/build/data/html/setup.html
RUN 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 gcr.io/distroless/base:latest AS final
ARG TARGETARCH
ENV GOARCH=$TARGETARCH
COPY --from=support /opt/build /opt/build
RUN (cd /opt/build; make compile INTERNAL=off UPDATER=docker)
FROM golang:latest
COPY --from=build /opt/build/build /opt/jfa-go
COPY --from=support /opt/build/dist/*_linux_${TARGETARCH}* /jfa-go
COPY --from=support /opt/build/build/data /jfa-go/data
EXPOSE 8056
EXPOSE 8057
CMD [ "/opt/jfa-go/jfa-go", "-data", "/data" ]
CMD [ "/jfa-go/jfa-go", "-data", "/data" ]

10
Dockerfile.ci Normal file
View File

@@ -0,0 +1,10 @@
FROM gcr.io/distroless/base:latest AS final
ARG TARGETARCH
COPY ./dist/notray-e2ee_linux_${TARGETARCH}* /jfa-go
COPY ./build/data /jfa-go/data
EXPOSE 8056
EXPOSE 8057
CMD [ "/jfa-go/jfa-go", "-data", "/data" ]

View File

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

234
Makefile
View File

@@ -1,3 +1,8 @@
.PHONY: configuration email typescript swagger copy compile compress inline-css variants-html install clean npm config-description config-default precompile test
.DEFAULT_GOAL := all
TS ?= npx tsgo
GOESBUILD ?= off
ifeq ($(GOESBUILD), on)
ESBUILD := esbuild
@@ -6,7 +11,8 @@ else
endif
GOBINARY ?= go
CSSVERSION ?= v3
CSSVERSION ?= $(shell git describe --tags --abbrev=0)
CSS_BUNDLE = $(DATA)/web/css/$(CSSVERSION)bundle.css
VERSION ?= $(shell git describe --exact-match HEAD 2> /dev/null || echo vgit)
VERSION := $(shell echo $(VERSION) | sed 's/v//g')
@@ -25,14 +31,16 @@ endif
INTERNAL ?= on
TRAY ?= off
E2EE ?= off
E2EE ?= on
TAGS := -tags "
ifeq ($(INTERNAL), on)
DATA := data
DATA := build/data
COMPDEPS := $(BUILDDEPS)
else
DATA := build/data
TAGS := $(TAGS) external
COMPDEPS :=
endif
ifeq ($(TRAY), on)
@@ -40,7 +48,7 @@ ifeq ($(TRAY), on)
endif
ifeq ($(E2EE), on)
TAGS := $(TAGS) e2ee
TAGS := $(TAGS) e2ee goolm
endif
TAGS := $(TAGS)"
@@ -53,17 +61,19 @@ endif
DEBUG ?= off
ifeq ($(DEBUG), on)
SOURCEMAP := --sourcemap
TYPECHECK := npx tsc -noEmit --project ts/tsconfig.json
MINIFY :=
TYPECHECK := $(TS) -noEmit --incremental --project ts/tsconfig.json
# jank
COPYTS := rm -r $(DATA)/web/js/ts; cp -r tempts $(DATA)/web/js/ts
UNCSS := cp $(DATA)/web/css/bundle.css $(DATA)/bundle.css
UNCSS := cp $(CSS_BUNDLE) $(DATA)/bundle.css
# TAILWIND := --content ""
else
LDFLAGS := -s -w $(LDFLAGS)
SOURCEMAP :=
MINIFY := --minify
COPYTS :=
TYPECHECK :=
UNCSS := npx tailwindcss -i $(DATA)/web/css/bundle.css -o $(DATA)/bundle.css --content "html/crash.html"
UNCSS := npx tailwindcss -i $(CSS_BUNDLE) -o $(DATA)/bundle.css --content "html/crash.html"
# UNCSS := npx uncss $(DATA)/crash.html --csspath web/css --output $(DATA)/bundle.css
TAILWIND :=
endif
@@ -88,95 +98,165 @@ else
NPMOPTS :=
endif
ifeq (, $(shell which swag))
SWAGINSTALL := $(GOBINARY) install github.com/swaggo/swag/cmd/swag@v1.16.4
else
SWAGINSTALL :=
endif
npm:
$(info installing npm dependencies)
npm install $(NPMOPTS)
# FLAG HASHING: To rebuild on flag change.
# credit for idea to https://bnikolic.co.uk/blog/sh/make/unix/2021/07/08/makefile.html
rebuildFlags := GOESBUILD GOBINARY VERSION COMMIT UPDATER INTERNAL TRAY E2EE TAGS DEBUG RACE
rebuildVals := $(foreach v,$(rebuildFlags),$(v)=$($(v)))
rebuildHash := $(strip $(shell echo $(rebuildVals) | sha256sum | cut -d " " -f1))
rebuildHashFile := $(DATA)/buildhash-$(rebuildHash).txt
configuration:
$(info Fixing config-base)
-mkdir -p $(DATA)
python3 scripts/enumerate_config.py -i config/config-base.json -o $(DATA)/config-base.json
$(info Generating config-default.ini)
python3 scripts/generate_ini.py -i config/config-base.json -o $(DATA)/config-default.ini
CONFIG_BASE = config/config-base.yaml
email:
$(info Generating email html)
python3 scripts/compile_mjml.py -o $(DATA)/
# CONFIG_DESCRIPTION = $(DATA)/config-base.json
CONFIG_DEFAULT = $(DATA)/config-default.ini
# $(CONFIG_DESCRIPTION) &: $(CONFIG_BASE)
# $(info Fixing config-base)
# -mkdir -p $(DATA)
typescript:
$(TYPECHECK)
$(adding dark variants to typescript)
rm -rf tempts
cp -r ts tempts
scripts/dark-variant.sh tempts
scripts/dark-variant.sh tempts/modules
$(info compiling typescript)
$(DATA):
mkdir -p $(DATA)/web/js
$(ESBUILD) --target=es6 --bundle tempts/admin.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/admin.js --minify
$(ESBUILD) --target=es6 --bundle tempts/user.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/user.js --minify
$(ESBUILD) --target=es6 --bundle tempts/pwr.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/pwr.js --minify
$(ESBUILD) --target=es6 --bundle tempts/form.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/form.js --minify
$(ESBUILD) --target=es6 --bundle tempts/setup.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/setup.js --minify
$(ESBUILD) --target=es6 --bundle tempts/crash.ts --outfile=./$(DATA)/crash.js --minify
mkdir -p $(DATA)/web/css
$(CONFIG_DEFAULT): $(CONFIG_BASE)
$(info Generating config-default.ini)
CGO_ENABLED=0 go run scripts/ini/main.go -in $(CONFIG_BASE) -out $(DATA)/config-default.ini
configuration: $(CONFIG_DEFAULT)
EMAIL_SRC = $(wildcard mail/*)
EMAIL_TARGET = $(DATA)/confirmation.html
$(EMAIL_TARGET): $(EMAIL_SRC)
$(info Generating email html)
npx mjml mail/*.mjml -o $(DATA)/
$(info Copying plaintext mail)
cp mail/*.txt $(DATA)/
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)
# rm -rf tempts
# cp -r ts tempts
rm -rf tempts
mkdir -p tempts
$(adding dark variants to typescript)
# scripts/dark-variant.sh tempts
# scripts/dark-variant.sh tempts/modules
CGO_ENABLED=0 go run scripts/variants/main.go -dir ts -out tempts
$(info compiling typescript)
$(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@latest
swag init -g main.go
SWAGGER_SRC = $(wildcard api*.go) $(wildcard *auth.go) views.go
SWAGGER_TARGET = docs/docs.go
$(SWAGGER_TARGET): $(SWAGGER_SRC)
$(SWAGINSTALL)
swag init --parseDependency --parseInternal -g main.go
compile:
$(info Downloading deps)
$(GOBINARY) mod download
$(info Building)
mkdir -p build
$(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o build/jfa-go
compress:
upx --lzma build/jfa-go
bundle-css:
mkdir -p $(DATA)/web/css
$(info copying fonts)
cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 $(DATA)/web/css/
$(info bundling css)
$(ESBUILD) --bundle css/base.css --outfile=$(DATA)/web/css/bundle.css --external:remixicon.css --external:../fonts/hanken* --minify
npx tailwindcss -i $(DATA)/web/css/bundle.css -o $(DATA)/web/css/bundle.css $(TAILWIND)
# npx postcss -o $(DATA)/web/css/bundle.css $(DATA)/web/css/bundle.css
inline-css:
cp html/crash.html $(DATA)/crash.html
$(UNCSS)
node scripts/inline.js root $(DATA) $(DATA)/crash.html $(DATA)/crash.html
rm $(DATA)/bundle.css
variants-html:
VARIANTS_SRC = $(wildcard html/*.html) $(wildcard html/*.txt)
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/%)
SYNTAX_LIGHT_SRC = node_modules/highlight.js/styles/base16/atelier-sulphurpool-light.min.css
SYNTAX_LIGHT_TARGET = $(DATA)/web/css/$(CSSVERSION)highlightjs-light.css
SYNTAX_DARK_SRC = node_modules/highlight.js/styles/base16/circus.min.css
SYNTAX_DARK_TARGET = $(DATA)/web/css/$(CSSVERSION)highlightjs-dark.css
CODEINPUT_SRC = node_modules/@webcoder49/code-input/code-input.min.css
CODEINPUT_TARGET = $(DATA)/web/css/$(CSSVERSION)code-input.css
CSS_SRC = $(wildcard css/*.css)
CSS_TARGET = $(DATA)/web/css/part-bundle.css
CSS_FULLTARGET = $(CSS_BUNDLE)
ALL_CSS_SRC = $(ICON_SRC) $(CSS_SRC) $(SYNTAX_LIGHT_SRC) $(SYNTAX_DARK_SRC)
ALL_CSS_TARGET = $(ICON_TARGET)
$(CSS_FULLTARGET): $(TYPESCRIPT_TARGET) $(VARIANTS_TARGET) $(ALL_CSS_SRC) $(wildcard html/*.html) $(wildcard html.*.txt)
$(info copying fonts)
cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 $(DATA)/web/css/
cp -r $(SYNTAX_LIGHT_SRC) $(SYNTAX_LIGHT_TARGET)
cp -r $(SYNTAX_DARK_SRC) $(SYNTAX_DARK_TARGET)
cp -r $(CODEINPUT_SRC) $(CODEINPUT_TARGET)
$(info bundling css)
rm -f $(CSS_TARGET) $(CSS_FULLTARGET)
$(ESBUILD) --bundle css/base.css --outfile=$(CSS_TARGET) --external:remixicon.css --external:../fonts/hanken* --minify
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))
CGO_ENABLED=0 go run scripts/yaml/main.go -in $(CONFIG_BASE) -out $(DATA)/$(shell basename $(CONFIG_BASE))
$(info copying crash page)
mv $(DATA)/crash.html $(DATA)/html/
cp $(DATA)/crash.html $(DATA)/html/
$(info copying static data)
mkdir -p $(DATA)/web
cp images/banner.svg static/banner.svg
cp -r static/* $(DATA)/web/
$(info copying systemd service)
cp jfa-go.service $(DATA)/
$(info copying language files)
cp -r lang $(DATA)/
cp LICENSE $(DATA)/
mv $(DATA)/web/css/bundle.css $(DATA)/web/css/$(CSSVERSION)bundle.css
# internal-files:
# python3 scripts/embed.py internal
#
# external-files:
# python3 scripts/embed.py external
# -mkdir -p build
# $(info copying internal data into build/)
# cp -r data build/
BUILDDEPS := $(DATA) $(CONFIG_DEFAULT) $(EMAIL_TARGET) $(COPY_TARGET) $(SWAGGER_TARGET) $(INLINE_TARGET) $(CSS_FULLTARGET) $(TYPESCRIPT_TARGET)
precompile: $(BUILDDEPS)
COMPDEPS = $(rebuildHashFile)
ifeq ($(INTERNAL), on)
COMPDEPS = $(BUILDDEPS) $(rebuildHashFile)
endif
$(rebuildHashFile):
$(info recording new flags $(rebuildVals))
rm -f $(DATA)/buildhash-*.txt
touch $(rebuildHashFile)
GO_SRC = $(shell find ./ -name "*.go")
GO_TARGET = build/jfa-go
$(GO_TARGET): $(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) $(GOBUILDFLAGS)
test: $(BUILDDEPS) $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
$(GOBINARY) test -ldflags="$(LDFLAGS)" $(TAGS) -p 1
all: $(BUILDDEPS) $(GO_TARGET) $(rebuildHashFile)
compress:
upx --lzma $(GO_TARGET)
install:
cp -r build $(DESTDIR)/jfa-go
@@ -188,6 +268,6 @@ clean:
-rm docs/docs.go docs/swagger.json docs/swagger.yaml
go clean
quick: configuration typescript variants-html bundle-css inline-css copy compile
all: configuration npm email typescript variants-html bundle-css inline-css swagger copy compile
npm:
$(info installing npm dependencies)
npm install $(NPMOPTS)

View File

@@ -1,5 +1,5 @@
![jfa-go](images/banner.svg)
[![Build Status](https://drone.hrfee.dev/api/badges/hrfee/jfa-go/status.svg?ref=refs/heads/main)](https://drone.hrfee.dev/hrfee/jfa-go)
[![Build Status](https://ci.hrfee.dev/api/badges/3/status.svg)](https://ci.hrfee.dev/repos/3)
[![Docker Hub](https://img.shields.io/docker/pulls/hrfee/jfa-go?label=docker)](https://hub.docker.com/r/hrfee/jfa-go)
[![Translation status](https://weblate.jfa-go.com/widgets/jfa-go/-/svg-badge.svg)](https://weblate.jfa-go.com/engage/jfa-go/)
[![Docs/Wiki](https://img.shields.io/static/v1?label=documentation&message=jfa-go.com&color=informational)](https://wiki.jfa-go.com)
@@ -13,57 +13,42 @@
Studies mean I can't work on this project a lot outside of breaks, however I hope i'll be able to fit in general support and things like bug fixes into my time. New features and such will likely come in short bursts throughout the year (if they do at all).
#### Does/Will it still work?
jfa-go currently works on Jellyfin 10.8.9, the latest version. I should be able to maintain compatability in the future, unless any big changes occur.
jfa-go currently works on Jellyfin 10.11.0, the latest version as of 21/10/25. I should be able to maintain compatibility 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.
If you want a bit more guarantee of support [Wizarr](https://github.com/Wizarrrr/wizarr) is popular and seems very polished. It supports multiple media servers, lots of customization and invitation through Discord.
* [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.
* [Organizr](https://github.com/causefx/Organizr) doesn't focus on Jellyfin, but allows putting self-hosted services into "tabs" on a central page, and allows creating users, which lets one control who can access what.
---
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) (and now [Emby](https://emby.media/)) that provides invite-based account creation as well as other features that make one's instance much easier to manage.
a rewrite of [jellyfin-accounts](https://github.com/hrfee/jellyfin-accounts) (original naming for both, ik
😂).
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) (and [Emby](https://emby.media/) as 2nd class) that provides invite-based account creation as well as other features that make ones 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
* Granular control over invites: Validity period as well as number of uses can be specified.
* Account profiles: Assign settings profiles to invites so new users have your predefined permissions, homescreen layout, etc. applied to their account on creation.
* Password validation: Ensure users choose a strong password.
* CAPTCHAs can be enabled to avoid bots
* ⌛ 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 them via email/telegram.
* Admin Notifications: Get notified when someone creates an account, or an invite expires.
* 📣 Announcements: Bulk message your users with announcements about your server.
* Authentication via Jellyfin: Instead of using separate credentials for jfa-go and Jellyfin, jfa-go can use it as the authentication provider.
* Enables the usage of jfa-go by multiple people
* 🌓 Customizations
* Customize emails with variables and markdown
* Specify contact and help messages to appear in emails and pages
* Light and dark themes available
* **Invites**: Send invite links to new users so they can sign up without relying on you.
* Customize with profiles: Apply Jellyfin settings (library access, transcoding, etc.) on sign-up, with different profiles for each user type.
* Limit invites by time or number of uses, enforce strong passwords, require a CAPTCHA, and more
* **Password Resets**: Let your users do it themselves. Works with the Jellyfin "Forgot Password" feature, or through the "My Account" page. [See the wiki for your options](https://wiki.jfa-go.com/docs/pwr/).
* **Contact your users**: Collect email address, Discord/Telegram/Matrix info when the user signs up or add later, and jfa-go will contact them when needed (e.g. on/before account expiry, disabling/enabling, deletion) or when you wish with Markdown announcements.
* "Confirm email" optional, similar is required for Discord/Telegram/Matrix
* **"My Account"**: Lets your users change their password or email/contact info themselves and show them relevant info on a special page. Also,
* Referrals: Allow users a special, limited invite to give to their friends/family.
* **Advanced user management**: See all of your users at once and manage them in bulk (enable/disable/delete, send markdown announcements, apply profiles/settings, and more)
* User expiry: Set on an invite, and any new users will be valid for a fixed period (e.g. 30 days). After time passes, account is disabled, deleted, or disabled then deleted.
* **Ombi/Jellyseerr integration**: Sync username/passwords & contact details between your services.
* **Customizable**: Edit messages sent to users and shown on invites, "My Account" page and more with full Markdown support.
#### 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.
**Note**: `TrayIcon` builds include a tray icon to start/stop/restart, and an option to automatically start when you log-in to your computer.
`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
@@ -72,7 +57,7 @@ docker create \
-p 8056:8056 \
# -p 8057:8057 if using tls
-v /path/to/.config/jfa-go:/data \ # Path to wherever you want to store the config file and other data
-v /path/to/jellyfin:/jf \ # Path to Jellyfin config directory, ignore if using Emby
-v /path/to/jellyfin:/jf \ # Only needed for password resets through Jellyfin, ignore if not using or using Emby
-v /etc/localtime:/etc/localtime:ro \ # Makes sure time is correct
hrfee/jfa-go # hrfee/jfa-go:unstable for latest build from git
```
@@ -80,7 +65,7 @@ docker create \
##### [Debian/Ubuntu](https://apt.hrfee.dev)
```sh
sudo apt-get update && sudo apt-get install curl apt-transport-https gnupg
curl https://apt.hrfee.dev/hrfee.pubkey.gpg | sudo apt-key add -
curl https://apt.hrfee.dev/hrfee.pubkey.gpg | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/apt.hrfee.dev.gpg
# For stable releases
echo "deb https://apt.hrfee.dev trusty main" | sudo tee /etc/apt/sources.list.d/hrfee.list
@@ -94,7 +79,7 @@ sudo apt-get update
# For servers
sudo apt-get install jfa-go
# ------
# For desktops/servers with GUI (has dependencies)
# For desktops/servers with GUI (may pull in lots of dependencies)
sudo apt-get install jfa-go-tray
# ------
```
@@ -108,7 +93,7 @@ Available on the AUR as:
##### Other platforms
Download precompiled binaries from:
* [The releases section](https://github.com/hrfee/jfa-go/releases) (stable)
* [Buildrone](https://builds.hrfee.dev/view/hrfee/jfa-go) (nightly)
* [dl.jfa-go.com](https://dl.jfa-go.com) (nightly)
unzip the `jfa-go`/`jfa-go.exe` executable to somewhere useful.
* For \*nix/macOS users, `chmod +x jfa-go` then place it somewhere in your PATH like `/usr/bin`.
@@ -147,6 +132,8 @@ Usage of jfa-go:
alternate port to host web ui on.
-pprof
Exposes pprof profiler on /debug/pprof.
-restore string
path to database backup to restore.
-swagger
Enable swagger at /swagger/index.html
```
@@ -154,18 +141,9 @@ Usage of jfa-go:
#### Systemd
jfa-go does not run as a daemon by default. Run `jfa-go systemd` to create a systemd `.service` file in your current directory, which you can copy into `~/.config/systemd/user` or somewhere else.
---
If you're switching from jellyfin-accounts, copy your existing `~/.jf-accounts` to:
* `XDG_CONFIG_DIR/jfa-go` (usually ~/.config/jfa-go) on \*nix systems,
* `%AppData%/jfa-go` on Windows,
* `~/Library/Application Support/jfa-go` on macOS.
(or specify config/data path with `-config/-data` respectively.)
#### Contributing
See [the wiki page](https://wiki.jfa-go.com/docs/dev/) or [CONTRIBUTING.md](https://github.com/hrfee/jfa-go/blob/main/CONTRIBUTING.md).
See [the wiki page](https://wiki.jfa-go.com/docs/dev/).
##### Translation
[![Translation status](https://weblate.jfa-go.com/widgets/jfa-go/-/multi-auto.svg)](https://weblate.jfa-go.com/engage/jfa-go/)

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
}

220
api-activities.go Normal file
View File

@@ -0,0 +1,220 @@
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"
}
// generateActivitiesQuery generates a badgerhold query from QueryDTOs and search terms, which can then be searched, counted, or whatever you want.
func (app *appContext) generateActivitiesQuery(req ServerFilterReqDTO) *badgerhold.Query {
var query *badgerhold.Query
if len(req.SearchTerms) != 0 {
query = ActivityMatchesSearchAsDBBaseQuery(req.SearchTerms)
} else {
query = nil
}
for _, q := range req.Queries {
nq := q.AsDBQuery(query)
if nq == nil {
nq = ActivityDBQueryFromSpecialField(app.jf.MediaBrowser, query, q)
}
query = nq
}
if query == nil {
query = &badgerhold.Query{}
}
return query
}
// @Summary Get the requested set of activities, Paginated, filtered and sorted. Is a POST because of some issues I was having, ideally should be a GET.
// @Produce json
// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search parameters"
// @Success 200 {object} GetActivitiesRespDTO
// @Router /activity [post]
// @Security Bearer
// @tags Activity,Statistics
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)
}
query := app.generateActivitiesQuery(req.ServerFilterReqDTO)
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.MediaBrowser),
SourceUsername: act.MustGetSourceUsername(app.jf.MediaBrowser),
}
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,Statistics
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)
}
// @Summary Returns the total number of activities matching the given filtering. Fails silently.
// @Produce json
// @Param ServerFilterReqDTO body ServerFilterReqDTO true "search parameters"
// @Success 200 {object} PageCountDTO
// @Router /activity/count [post]
// @Security Bearer
// @tags Activity,Statistics
func (app *appContext) GetFilteredActivityCount(gc *gin.Context) {
resp := PageCountDTO{}
req := ServerFilterReqDTO{}
gc.BindJSON(&req)
query := app.generateActivitiesQuery(req)
var err error
resp.Count, err = app.storage.db.Count(&Activity{}, query)
if err != nil {
// app.err.Printf(lm.FailedDBReadActivities, err)
resp.Count = 0
}
gc.JSON(200, resp)
}

124
api-backups.go Normal file
View File

@@ -0,0 +1,124 @@
package main
import (
"net/url"
"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) {
escapedFName := gc.Param("fname")
fname, err := url.QueryUnescape(escapedFName)
if err != nil {
respondBool(400, false, gc)
return
}
// 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)
}

View File

@@ -1,6 +1,7 @@
package main
import (
"errors"
"fmt"
"strconv"
"strings"
@@ -8,54 +9,57 @@ import (
"time"
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/itchyny/timefmt-go"
"github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4"
)
const (
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.debug.Printf("Housekeeping: Deleting old invite %s", data.Code)
notify := data.Notify
if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
app.debug.Printf("%s: Expiry notification", data.Code)
var wait sync.WaitGroup
for address, settings := range notify {
if !settings["notify-expiry"] {
continue
}
wait.Add(1)
go func(addr string) {
defer wait.Done()
msg, err := app.email.constructExpiry(data.Code, data, app, false)
if err != nil {
app.err.Printf("%s: Failed to construct expiry notification: %v", data.Code, err)
} else {
// Check whether notify "address" is an email address of Jellyfin ID
if strings.Contains(addr, "@") {
err = app.email.send(msg, addr)
} else {
err = app.sendByID(msg, addr)
}
if err != nil {
app.err.Printf("%s: Failed to send expiry notification: %v", data.Code, err)
} else {
app.info.Printf("Sent expiry notification to %s", addr)
}
}
}(address)
}
wait.Wait()
}
app.storage.DeleteInvitesKey(data.Code)
app.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)
@@ -64,46 +68,21 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
}
expiry := inv.ValidTill
if currentTime.After(expiry) {
app.debug.Printf("Housekeeping: Deleting old invite %s", code)
notify := inv.Notify
if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
app.debug.Printf("%s: Expiry notification", code)
var wait sync.WaitGroup
for address, settings := range notify {
if !settings["notify-expiry"] {
continue
}
wait.Add(1)
go func(addr string) {
defer wait.Done()
msg, err := app.email.constructExpiry(code, inv, app, false)
if err != nil {
app.err.Printf("%s: Failed to construct expiry notification: %v", code, err)
} else {
// Check whether notify "address" is an email address of Jellyfin ID
if strings.Contains(addr, "@") {
err = app.email.send(msg, addr)
} else {
err = app.sendByID(msg, addr)
}
if err != nil {
app.err.Printf("%s: Failed to send expiry notification: %v", code, err)
} else {
app.info.Printf("Sent expiry notification to %s", addr)
}
}
}(address)
}
wait.Wait()
}
app.deleteExpiredInvite(inv)
match = false
app.storage.DeleteInvitesKey(code)
} 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--
@@ -116,6 +95,246 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
return match
}
func (app *appContext) deleteExpiredInvite(data Invite) {
app.debug.Printf(lm.DeleteOldInvite, data.Code)
// Disable referrals for the user if UseReferralExpiry is enabled, so no new ones are made.
if data.IsReferral && data.UseReferralExpiry && data.ReferrerJellyfinID != "" {
user, ok := app.storage.GetEmailsKey(data.ReferrerJellyfinID)
if ok {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(data.ReferrerJellyfinID, user)
app.InvalidateWebUserCache()
}
}
wait := app.sendAdminExpiryNotification(data)
app.storage.DeleteInvitesKey(data.Code)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityDeleteInvite,
SourceType: ActivityDaemon,
InviteCode: data.Code,
Value: data.Label,
Time: time.Now(),
}, nil, false)
if wait != nil {
wait.Wait()
}
}
func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup {
notify := data.Notify
if !emailEnabled || !app.config.Section("notifications").Key("enabled").MustBool(false) || len(notify) == 0 {
return nil
}
var wait sync.WaitGroup
for address, settings := range notify {
if !settings["notify-expiry"] {
continue
}
wait.Add(1)
go func(addr string) {
defer wait.Done()
msg, err := app.email.constructExpiry(data, false)
if err != nil {
app.err.Printf(lm.FailedConstructExpiryAdmin, data.Code, err)
} else {
// Check whether notify "address" is an email address or Jellyfin ID
if strings.Contains(addr, "@") {
err = app.email.send(msg, addr)
} else {
err = app.sendByID(msg, addr)
}
if err != nil {
app.err.Printf(lm.FailedSendExpiryAdmin, data.Code, addr, err)
} else {
app.info.Printf(lm.SentExpiryAdmin, data.Code, addr)
}
}
}(address)
}
return &wait
}
// @Summary Send an existing invite to an email address or discord user.
// @Produce json
// @Param SendInviteDTO body SendInviteDTO true "Email address or Discord username"
// @Success 200 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Router /invites/send [post]
// @Security Bearer
// @tags Invites
func (app *appContext) SendInvite(gc *gin.Context) {
var req SendInviteDTO
gc.BindJSON(&req)
inv, ok := app.storage.GetInvitesKey(req.Invite)
if !ok {
app.err.Printf(lm.FailedGetInvite, req.Invite, lm.NotFound)
respond(500, "Invite not found", gc)
return
}
err := app.sendInvite(req.sendInviteDTO, &inv)
// Even if failed, some error info might have been stored in the invite.
app.storage.SetInvitesKey(req.Invite, inv)
if err != nil {
app.err.Printf(lm.FailedSendInviteMessage, req.Invite, req.SendTo, err)
respond(500, err.Error(), gc)
return
}
app.info.Printf(lm.SentInviteMessage, req.Invite, req.SendTo)
respondBool(200, true, gc)
}
// @Summary Edit an existing invite. Not all fields are modifiable.
// @Produce json
// @Param EditableInviteDTO body EditableInviteDTO true "Email address or Discord username"
// @Success 200 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Failure 400 {object} stringResponse
// @Router /invites/edit [patch]
// @Security Bearer
// @tags Invites
func (app *appContext) EditInvite(gc *gin.Context) {
var req EditableInviteDTO
gc.BindJSON(&req)
inv, ok := app.storage.GetInvitesKey(req.Code)
if !ok {
msg := fmt.Sprintf(lm.InvalidInviteCode, req.Code)
app.err.Println(msg)
respond(400, msg, gc)
return
}
changed := false
if req.NotifyCreation != nil || req.NotifyExpiry != nil {
setNotify := map[string]bool{}
if req.NotifyExpiry != nil {
setNotify["notify-expiry"] = *req.NotifyExpiry
}
if req.NotifyCreation != nil {
setNotify["notify-creation"] = *req.NotifyCreation
}
ch, ok := app.SetNotify(&inv, setNotify, gc)
changed = changed || ch
if ch && !ok {
return
}
}
if req.Profile != nil {
ch, ok := app.SetProfile(&inv, *req.Profile, gc)
changed = changed || ch
if ch && !ok {
return
}
}
if req.Label != nil {
*req.Label = strings.TrimSpace(*req.Label)
changed = changed || (*req.Label != inv.Label)
inv.Label = *req.Label
}
if req.UserLabel != nil {
*req.UserLabel = strings.TrimSpace(*req.UserLabel)
changed = changed || (*req.UserLabel != inv.UserLabel)
inv.UserLabel = *req.UserLabel
}
if req.UserExpiry != nil {
changed = changed || (*req.UserExpiry != inv.UserExpiry)
inv.UserExpiry = *req.UserExpiry
if !inv.UserExpiry {
inv.UserMonths = 0
inv.UserDays = 0
inv.UserHours = 0
inv.UserMinutes = 0
}
}
if req.UserMonths != nil || req.UserDays != nil || req.UserHours != nil || req.UserMinutes != nil {
if inv.UserMonths == 0 &&
inv.UserDays == 0 &&
inv.UserHours == 0 &&
inv.UserMinutes == 0 {
changed = changed || (inv.UserExpiry != false)
inv.UserExpiry = false
}
if req.UserMonths != nil {
changed = changed || (*req.UserMonths != inv.UserMonths)
inv.UserMonths = *req.UserMonths
}
if req.UserDays != nil {
changed = changed || (*req.UserDays != inv.UserDays)
inv.UserDays = *req.UserDays
}
if req.UserHours != nil {
changed = changed || (*req.UserHours != inv.UserHours)
inv.UserHours = *req.UserHours
}
if req.UserMinutes != nil {
changed = changed || (*req.UserMinutes != inv.UserMinutes)
inv.UserMinutes = *req.UserMinutes
}
}
if changed {
app.storage.SetInvitesKey(inv.Code, inv)
}
respondBool(200, true, gc)
}
// sendInvite attempts to send an invite to the given email address or discord username.
func (app *appContext) sendInvite(req sendInviteDTO, invite *Invite) (err error) {
if !(app.config.Section("invite_emails").Key("enabled").MustBool(false)) {
// app.err.Printf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, errors.New(lm.InviteMessagesDisabled))
err = errors.New(lm.InviteMessagesDisabled)
return err
}
discord := ""
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
users := app.discord.GetUsers(req.SendTo)
if len(users) == 0 {
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
Address: req.SendTo,
Reason: NoUser,
})
err = fmt.Errorf(lm.InvalidAddress, req.SendTo)
return err
} else if len(users) > 1 {
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
Address: req.SendTo,
Reason: MultiUser,
})
err = fmt.Errorf(lm.InvalidAddress, req.SendTo)
return err
}
discord = users[0].User.ID
}
var msg *Message
msg, err = app.email.constructInvite(invite, false)
if err != nil {
// Slight misuse of the template
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
Address: req.SendTo,
Reason: CheckLogs,
})
// app.err.Printf(lm.FailedConstructInviteMessage, req.SendTo, err)
return err
}
if discord != "" {
err = app.discord.SendDM(msg, discord)
} else {
err = app.email.send(msg, req.SendTo)
}
if err != nil {
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
Address: req.SendTo,
Reason: CheckLogs,
})
return err
// app.err.Println(invite.SendTo)
}
invite.SentTo.Success = append(invite.SentTo.Success, req.SendTo)
return err
}
// @Summary Create a new invite.
// @Produce json
// @Param generateInviteDTO body generateInviteDTO true "New invite request object"
@@ -125,22 +344,19 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
// @tags Invites
func (app *appContext) GenerateInvite(gc *gin.Context) {
var req generateInviteDTO
app.debug.Println("Generating new invite")
app.debug.Println(lm.GenerateInvite)
gc.BindJSON(&req)
currentTime := time.Now()
validTill := currentTime.AddDate(0, req.Months, req.Days)
validTill = validTill.Add(time.Hour*time.Duration(req.Hours) + time.Minute*time.Duration(req.Minutes))
// 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]))
}
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 {
@@ -159,44 +375,12 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
invite.UserMinutes = req.UserMinutes
}
invite.ValidTill = validTill
if req.SendTo != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
addressValid := false
discord := ""
app.debug.Printf("%s: Sending invite message", inviteCode)
if discordEnabled && !strings.Contains(req.SendTo, "@") {
users := app.discord.GetUsers(req.SendTo)
if len(users) == 0 {
invite.SendTo = fmt.Sprintf("Failed: User not found: \"%s\"", req.SendTo)
} else if len(users) > 1 {
invite.SendTo = fmt.Sprintf("Failed: Multiple users found: \"%s\"", req.SendTo)
} else {
invite.SendTo = req.SendTo
addressValid = true
discord = users[0].User.ID
}
} else if emailEnabled {
addressValid = true
invite.SendTo = req.SendTo
}
if addressValid {
msg, err := app.email.constructInvite(inviteCode, invite, app, false)
if err != nil {
invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo)
app.err.Printf("%s: Failed to construct invite message: %v", inviteCode, 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("Failed to send to %s", req.SendTo)
app.err.Printf("%s: %s: %v", inviteCode, invite.SendTo, err)
} else {
app.info.Printf("%s: Sent invite email to \"%s\"", inviteCode, req.SendTo)
}
}
if req.SendTo != "" {
err := app.sendInvite(req.sendInviteDTO, &invite)
if err != nil {
app.err.Printf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, err)
} else {
app.info.Printf(lm.SentInviteMessage, invite.Code, req.SendTo)
}
}
if req.Profile != "" {
@@ -206,38 +390,97 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
invite.Profile = "Default"
}
}
app.storage.SetInvitesKey(inviteCode, invite)
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 the number of invites stored in the database.
// @Produce json
// @Success 200 {object} PageCountDTO
// @Router /invites/count [get]
// @Security Bearer
// @tags Invites,Statistics
func (app *appContext) GetInviteCount(gc *gin.Context) {
resp := PageCountDTO{}
var err error
resp.Count, err = app.storage.db.Count(&Invite{}, badgerhold.Where("IsReferral").Eq(false))
if err != nil {
resp.Count = 0
}
gc.JSON(200, resp)
}
// @Summary Get the number of invites stored in the database that have been used (but are still valid).
// @Produce json
// @Success 200 {object} PageCountDTO
// @Router /invites/count/used [get]
// @Security Bearer
// @tags Invites,Statistics
func (app *appContext) GetInviteUsedCount(gc *gin.Context) {
resp := PageCountDTO{}
var err error
resp.Count, err = app.storage.db.Count(&Invite{}, badgerhold.Where("IsReferral").Eq(false).And("UsedBy").MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
field := ra.Field()
switch usedBy := field.(type) {
case [][]string:
return len(usedBy) > 0, nil
default:
return false, nil
}
}))
if err != nil {
resp.Count = 0
}
gc.JSON(200, resp)
}
// @Summary Get invites.
// @Produce json
// @Success 200 {object} getInvitesDTO
// @Router /invites [get]
// @Security Bearer
// @tags Invites
// @tags Invites,Statistics
func (app *appContext) GetInvites(gc *gin.Context) {
app.debug.Println("Invites requested")
currentTime := time.Now()
// currentTime := time.Now()
app.checkInvites()
var invites []inviteDTO
for _, inv := range app.storage.GetInvites() {
_, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
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,
EditableInviteDTO: EditableInviteDTO{
Code: inv.Code,
Label: &inv.Label,
UserLabel: &inv.UserLabel,
Profile: &inv.Profile,
UserExpiry: &inv.UserExpiry,
UserMonths: &inv.UserMonths,
UserDays: &inv.UserDays,
UserHours: &inv.UserHours,
UserMinutes: &inv.UserMinutes,
},
ValidTill: inv.ValidTill.Unix(),
// Months: months,
// Days: days,
// Hours: hours,
// Minutes: minutes,
Created: inv.Created.Unix(),
NoLimit: inv.NoLimit,
}
if len(inv.UsedBy) != 0 {
invite.UsedBy = map[string]int64{}
@@ -245,9 +488,9 @@ func (app *appContext) GetInvites(gc *gin.Context) {
// These used to be stored formatted instead of as a unix timestamp.
unix, err := strconv.ParseInt(pair[1], 10, 64)
if err != nil {
date, err := timefmt.Parse(pair[1], app.datePattern+" "+app.timePattern)
date, err := timefmt.Parse(pair[1], datePattern+" "+timePattern)
if err != nil {
app.err.Printf("Failed to parse usedBy time: %v", err)
app.err.Printf(lm.FailedParseTime, err)
}
unix = date.Unix()
}
@@ -258,11 +501,13 @@ func (app *appContext) GetInvites(gc *gin.Context) {
if inv.RemainingUses != 0 {
invite.RemainingUses = inv.RemainingUses
}
if len(inv.SentTo.Success) != 0 || len(inv.SentTo.Failed) != 0 {
invite.SentTo = inv.SentTo
}
if inv.SendTo != "" {
invite.SendTo = inv.SendTo
}
if len(inv.Notify) != 0 {
// app.err.Printf("%s has notify section: %+v, you are %s\n", inv.Code, inv.Notify, gc.GetString("jfId"))
var addressOrID string
if app.config.Section("ui").Key("jellyfin_login").MustBool(false) {
addressOrID = gc.GetString("jfId")
@@ -271,117 +516,71 @@ func (app *appContext) GetInvites(gc *gin.Context) {
}
if _, ok := inv.Notify[addressOrID]; ok {
if _, ok = inv.Notify[addressOrID]["notify-expiry"]; ok {
invite.NotifyExpiry = inv.Notify[addressOrID]["notify-expiry"]
notifyExpiry := inv.Notify[addressOrID]["notify-expiry"]
invite.NotifyExpiry = &notifyExpiry
}
if _, ok = inv.Notify[addressOrID]["notify-creation"]; ok {
invite.NotifyCreation = inv.Notify[addressOrID]["notify-creation"]
notifyCreation := inv.Notify[addressOrID]["notify-creation"]
invite.NotifyCreation = &notifyCreation
}
}
}
invites = append(invites, invite)
}
fullProfileList := app.storage.GetProfiles()
profiles := make([]string, len(fullProfileList))
if len(profiles) != 0 {
defaultProfile := app.storage.GetDefaultProfile()
profiles[0] = defaultProfile.Name
i := 1
if len(fullProfileList) > 1 {
app.storage.db.ForEach(badgerhold.Where("Name").Ne(profiles[0]), func(p *Profile) error {
profiles[i] = p.Name
i++
return nil
})
}
}
resp := getInvitesDTO{
Profiles: profiles,
Invites: invites,
Invites: invites,
}
gc.JSON(200, resp)
}
// @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 Profiles & Settings
func (app *appContext) SetProfile(gc *gin.Context) {
var req inviteProfileDTO
gc.BindJSON(&req)
app.debug.Printf("%s: Setting profile to \"%s\"", req.Invite, req.Profile)
func (app *appContext) SetProfile(inv *Invite, name string, gc *gin.Context) (changed, ok bool) {
changed = false
ok = false
// "" means "Don't apply profile"
if _, ok := app.storage.GetProfileKey(req.Profile); !ok && req.Profile != "" {
app.err.Printf("%s: Profile \"%s\" not found", req.Invite, req.Profile)
if _, profileExists := app.storage.GetProfileKey(name); !profileExists && name != "" {
app.err.Printf(lm.FailedGetProfile, name)
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)
changed = name != inv.Profile
inv.Profile = name
ok = true
return
}
// @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 {
app.debug.Printf("%s: Notification settings change requested", code)
invite, ok := app.storage.GetInvitesKey(code)
if !ok {
app.err.Printf("%s Notification setting change failed: Invalid code", code)
respond(400, "Invalid invite code", gc)
func (app *appContext) SetNotify(inv *Invite, settings map[string]bool, gc *gin.Context) (changed, ok bool) {
changed = false
ok = false
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
}
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("%s: Couldn't find contact method for admin. Make sure one is set.", code)
app.debug.Printf("%s: User ID \"%s\"", code, gc.GetString("jfId"))
respond(500, "Missing user contact method", gc)
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 {
*/
if _, ok := settings["notify-expiry"]; ok && invite.Notify[address]["notify-expiry"] != settings["notify-expiry"] {
invite.Notify[address]["notify-expiry"] = settings["notify-expiry"]
app.debug.Printf("%s: Set \"notify-expiry\" to %t for %s", code, settings["notify-expiry"], address)
address = gc.GetString("jfId")
} else {
address = app.config.Section("ui").Key("email").String()
}
if inv.Notify == nil {
inv.Notify = map[string]map[string]bool{}
}
if _, ok := inv.Notify[address]; !ok {
inv.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 && inv.Notify[address][notifyType] != settings[notifyType] {
inv.Notify[address][notifyType] = settings[notifyType]
app.debug.Printf(lm.SetAdminNotify, notifyType, settings[notifyType], address)
changed = true
}
if _, ok := settings["notify-creation"]; ok && invite.Notify[address]["notify-creation"] != settings["notify-creation"] {
invite.Notify[address]["notify-creation"] = settings["notify-creation"]
app.debug.Printf("%s: Set \"notify-creation\" to %t for %s", code, settings["notify-creation"], address)
changed = true
}
if changed {
app.storage.SetInvitesKey(code, invite)
}
}
ok = true
return
}
// @Summary Delete an invite.
@@ -395,15 +594,24 @@ func (app *appContext) SetNotify(gc *gin.Context) {
func (app *appContext) DeleteInvite(gc *gin.Context) {
var req deleteInviteDTO
gc.BindJSON(&req)
app.debug.Printf("%s: Deletion requested", req.Code)
var ok bool
_, ok = app.storage.GetInvitesKey(req.Code)
inv, ok := app.storage.GetInvitesKey(req.Code)
if ok {
app.storage.DeleteInvitesKey(req.Code)
app.info.Printf("%s: Invite deleted", 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("%s: Deletion failed: Invalid code", req.Code)
app.err.Printf(lm.FailedDeleteInvite, req.Code, "invalid code")
respond(400, "Code doesn't exist", gc)
}

202
api-jellyseerr.go Normal file
View File

@@ -0,0 +1,202 @@
package main
import (
"fmt"
"net/url"
"strconv"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/common"
"github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
)
// @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.FailedGetUser, strconv.FormatInt(jellyseerrID, 10), 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
if !profile.Jellyseerr.Enabled {
return
}
err = js.ApplyTemplateToUser(jellyfinID, profile.Jellyseerr.User)
if err != nil {
err = fmt.Errorf(lm.FailedApplyTemplate, "user", lm.Jellyseerr, jellyfinID, err)
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) SetContactMethods(jellyfinID string, email *string, discord *DiscordUser, telegram *TelegramUser, contactPrefs *common.ContactPreferences) (err error) {
_, err = js.MustGetUser(jellyfinID)
if err != nil {
return
}
if contactPrefs == nil {
contactPrefs = &common.ContactPreferences{
Email: nil,
Discord: nil,
Telegram: nil,
Matrix: nil,
}
}
contactMethods := map[jellyseerr.NotificationsField]any{}
if emailEnabled {
if contactPrefs.Email != nil {
contactMethods[jellyseerr.FieldEmailEnabled] = *(contactPrefs.Email)
} else if email != nil && *email != "" {
contactMethods[jellyseerr.FieldEmailEnabled] = true
}
if email != nil {
err = js.ModifyMainUserSettings(jellyfinID, jellyseerr.MainUserSettings{Email: *email})
if err != nil {
// FIXME: This is a little ugly, considering all other errors are unformatted
err = fmt.Errorf(lm.FailedSetEmailAddress, lm.Jellyseerr, jellyfinID, err)
return
}
}
}
if discordEnabled {
if contactPrefs.Discord != nil {
contactMethods[jellyseerr.FieldDiscordEnabled] = *(contactPrefs.Discord)
} else if discord != nil && discord.ID != "" {
contactMethods[jellyseerr.FieldDiscordEnabled] = true
}
if discord != nil {
contactMethods[jellyseerr.FieldDiscord] = discord.ID
// Whether this is still necessary or not, i don't know.
if discord.ID == "" {
contactMethods[jellyseerr.FieldDiscord] = jellyseerr.BogusIdentifier
}
}
}
if telegramEnabled {
if contactPrefs.Telegram != nil {
contactMethods[jellyseerr.FieldTelegramEnabled] = *(contactPrefs.Telegram)
} else if telegram != nil && telegram.ChatID != 0 {
contactMethods[jellyseerr.FieldTelegramEnabled] = true
}
if telegram != nil {
contactMethods[jellyseerr.FieldTelegram] = strconv.FormatInt(telegram.ChatID, 10)
// Whether this is still necessary or not, i don't know.
if telegram.ChatID == 0 {
contactMethods[jellyseerr.FieldTelegram] = jellyseerr.BogusIdentifier
}
}
}
if len(contactMethods) > 0 {
err = js.ModifyNotifications(jellyfinID, contactMethods)
if err != nil {
// app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
return
}
}
return
}
func (js *JellyseerrWrapper) Name() string { return lm.Jellyseerr }
func (js *JellyseerrWrapper) Enabled(app *appContext, profile *Profile) bool {
return profile != nil && profile.Jellyseerr.Enabled && app.config.Section("jellyseerr").Key("enabled").MustBool(false)
}

View File

@@ -1,10 +1,13 @@
package main
import (
"strings"
"net/url"
"time"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/common"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
)
@@ -20,23 +23,16 @@ func (app *appContext) GetCustomContent(gc *gin.Context) {
if _, ok := app.storage.lang.Email[lang]; !ok {
lang = app.storage.lang.chosenEmailLang
}
adminLang := lang
if _, ok := app.storage.lang.Admin[lang]; !ok {
adminLang = app.storage.lang.chosenAdminLang
}
list := emailListDTO{
"UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.MustGetCustomContentKey("UserCreated").Enabled},
"InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.MustGetCustomContentKey("InviteExpiry").Enabled},
"PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.MustGetCustomContentKey("PasswordReset").Enabled},
"UserDeleted": {Name: app.storage.lang.Email[lang].UserDeleted["name"], Enabled: app.storage.MustGetCustomContentKey("UserDeleted").Enabled},
"UserDisabled": {Name: app.storage.lang.Email[lang].UserDisabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserDisabled").Enabled},
"UserEnabled": {Name: app.storage.lang.Email[lang].UserEnabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserEnabled").Enabled},
"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("Login").Enabled},
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("Page").Enabled},
list := emailListDTO{}
for _, cc := range customContent {
if cc.ContentType == CustomTemplate {
continue
}
ccDescription := emailListEl{Name: cc.DisplayName(&app.storage.lang, lang), Enabled: app.storage.MustGetCustomContentKey(cc.Name).Enabled}
if cc.Description != nil {
ccDescription.Description = cc.Description(&app.storage.lang, lang)
}
list[cc.Name] = ccDescription
}
filter := gc.Query("filter")
@@ -50,39 +46,6 @@ func (app *appContext) GetCustomContent(gc *gin.Context) {
gc.JSON(200, list)
}
// No longer needed, these are stored by string keys in the database now.
/* func (app *appContext) getCustomMessage(id string) *CustomContent {
switch id {
case "Announcement":
return &CustomContent{}
case "UserCreated":
return &app.storage.customEmails.UserCreated
case "InviteExpiry":
return &app.storage.customEmails.InviteExpiry
case "PasswordReset":
return &app.storage.customEmails.PasswordReset
case "UserDeleted":
return &app.storage.customEmails.UserDeleted
case "UserDisabled":
return &app.storage.customEmails.UserDisabled
case "UserEnabled":
return &app.storage.customEmails.UserEnabled
case "InviteEmail":
return &app.storage.customEmails.InviteEmail
case "WelcomeEmail":
return &app.storage.customEmails.WelcomeEmail
case "EmailConfirmation":
return &app.storage.customEmails.EmailConfirmation
case "UserExpired":
return &app.storage.customEmails.UserExpired
case "UserLogin":
return &app.storage.userPage.Login
case "UserPage":
return &app.storage.userPage.Page
}
return nil
} */
// @Summary Sets the corresponding custom content.
// @Produce json
// @Param CustomContent body CustomContent true "Content = email (in markdown)."
@@ -101,11 +64,12 @@ func (app *appContext) SetCustomMessage(gc *gin.Context) {
respondBool(400, false, gc)
return
}
message, ok := app.storage.GetCustomContentKey(id)
_, ok := customContent[id]
if !ok {
respondBool(400, false, gc)
return
}
message, ok := app.storage.GetCustomContentKey(id)
message.Content = req.Content
message.Enabled = true
app.storage.SetCustomContentKey(id, message)
@@ -151,137 +115,109 @@ func (app *appContext) SetCustomMessageState(gc *gin.Context) {
// @Security Bearer
// @tags Configuration
func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
lang := app.storage.lang.chosenEmailLang
id := gc.Param("id")
var content string
var err error
var msg *Message
var variables []string
var conditionals []string
var values map[string]interface{}
username := app.storage.lang.Email[lang].Strings.get("username")
emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress")
customMessage, ok := app.storage.GetCustomContentKey(id)
if !ok {
app.err.Printf("Failed to get custom message with ID \"%s\"", id)
contentInfo, ok := customContent[id]
// FIXME: Add announcement to customContent
if !ok && id != "Announcement" {
app.err.Printf(lm.FailedGetCustomMessage, id)
respondBool(400, false, gc)
return
}
if id == "WelcomeEmail" {
conditionals = []string{"{yourAccountWillExpire}"}
customMessage.Conditionals = conditionals
} else if id == "UserPage" {
variables = []string{"{username}"}
customMessage.Variables = variables
} else if id == "UserLogin" {
variables = []string{}
customMessage.Variables = variables
content, ok := app.storage.GetCustomContentKey(id)
if contentInfo.Variables == nil {
contentInfo.Variables = []string{}
}
content = customMessage.Content
noContent := content == ""
if !noContent {
variables = customMessage.Variables
if contentInfo.Conditionals == nil {
contentInfo.Conditionals = []string{}
}
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 "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":
values = map[string]interface{}{}
if contentInfo.Placeholders == nil {
contentInfo.Placeholders = map[string]any{}
}
if err != nil {
respondBool(500, false, gc)
return
}
if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" {
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++
}
// Generate content from real email, if the user hasn't already customised this message.
if content.Content == "" {
var msg *Message
switch id {
// FIXME: Add announcement to customContent
case "UserCreated":
msg, err = app.email.constructCreated("", "", time.Time{}, Invite{}, true)
case "InviteExpiry":
msg, err = app.email.constructExpiry(Invite{}, true)
case "PasswordReset":
msg, err = app.email.constructReset(PasswordReset{}, true)
case "UserDeleted":
msg, err = app.email.constructDeleted("", "", true)
case "UserDisabled":
msg, err = app.email.constructDisabled("", "", true)
case "UserEnabled":
msg, err = app.email.constructEnabled("", "", true)
case "UserExpiryAdjusted":
msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", true)
case "ExpiryReminder":
msg, err = app.email.constructExpiryReminder("", time.Now().AddDate(0, 0, 3), true)
case "InviteEmail":
msg, err = app.email.constructInvite(&Invite{Code: ""}, true)
case "WelcomeEmail":
msg, err = app.email.constructWelcome("", time.Time{}, true)
case "EmailConfirmation":
msg, err = app.email.constructConfirmation("", "", "", true)
case "UserExpired":
msg, err = app.email.constructUserExpired("", true)
case "Announcement":
case "UserPage":
case "UserLogin":
case "PostSignupCard":
case "PreSignupCard":
// These don't have any example content
msg = nil
}
customMessage.Variables = variables
}
if variables == nil {
variables = []string{}
}
app.storage.SetCustomContentKey(id, customMessage)
var mail *Message
if id != "UserLogin" && id != "UserPage" {
mail, err = app.email.constructTemplate("", "<div class=\"preview-content\"></div>", app)
if err != nil {
respondBool(500, false, gc)
return
}
} else {
mail = &Message{
HTML: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
Markdown: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
if msg != nil {
content.Content = msg.Text
}
}
gc.JSON(200, customEmailDTO{Content: content, Variables: variables, Conditionals: conditionals, Values: values, HTML: mail.HTML, Plaintext: mail.Text})
var mail *Message = nil
if contentInfo.ContentType == CustomMessage {
mail, err = app.email.construct(EmptyCustomContent, CustomContent{
Name: EmptyCustomContent.Name,
Enabled: true,
Content: "<div class=\"preview-content\"></div>",
}, map[string]any{})
if err != nil {
respondBool(500, false, gc)
return
}
} else if id == "PostSignupCard" {
// Specific workaround for the currently-unique "Post signup card".
// Source content from "Success Message" setting.
if content.Content == "" {
content.Content = "# " + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("successHeader") + "\n" + app.config.Section("ui").Key("success_message").String()
if app.config.Section("user_page").Key("enabled").MustBool(false) {
content.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 if contentInfo.ContentType == CustomCard {
mail = &Message{
HTML: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
}
mail.Markdown = mail.HTML
} else {
app.err.Printf("unknown custom content type %d", contentInfo.ContentType)
}
gc.JSON(200, customEmailDTO{Content: content.Content, Variables: contentInfo.Variables, Conditionals: contentInfo.Conditionals, Values: contentInfo.Placeholders, HTML: mail.HTML, Plaintext: mail.Text})
}
// @Summary Returns a new Telegram verification PIN, and the bot username.
@@ -320,21 +256,32 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
return
}
tgUser := TelegramUser{
ChatID: tgToken.ChatID,
Username: tgToken.Username,
Contact: true,
TelegramVerifiedToken: TelegramVerifiedToken{
ChatID: tgToken.ChatID,
Username: tgToken.Username,
},
Contact: true,
}
if lang, ok := app.telegram.languages[tgToken.ChatID]; ok {
tgUser.Lang = lang
}
app.storage.SetTelegramKey(req.ID, tgUser)
linkExistingOmbiDiscordTelegram(app)
for _, tps := range app.thirdPartyServices {
if err := tps.SetContactMethods(req.ID, nil, nil, &tgUser, &common.ContactPreferences{
Telegram: &tgUser.Contact,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
}
}
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
// @Summary Sets whether to notify a user through telegram/discord/matrix/email or not.
// @Produce json
// @Param SetContactMethodsDTO body SetContactMethodsDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
// @Param SetContactPreferencesDTO body SetContactPreferencesDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
// @Success 200 {object} boolResponse
// @Success 400 {object} boolResponse
// @Success 500 {object} boolResponse
@@ -342,26 +289,24 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
// @Security Bearer
// @tags Other
func (app *appContext) SetContactMethods(gc *gin.Context) {
var req SetContactMethodsDTO
var req SetContactPreferencesDTO
gc.BindJSON(&req)
if req.ID == "" {
respondBool(400, false, gc)
return
}
app.setContactMethods(req, gc)
app.setContactPreferences(req, gc)
}
func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Context) {
func (app *appContext) setContactPreferences(req SetContactPreferencesDTO, gc *gin.Context) {
contactPrefs := common.ContactPreferences{}
if tgUser, ok := app.storage.GetTelegramKey(req.ID); ok {
change := tgUser.Contact != req.Telegram
tgUser.Contact = req.Telegram
app.storage.SetTelegramKey(req.ID, tgUser)
if change {
msg := ""
if !req.Telegram {
msg = " not"
}
app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg)
app.debug.Printf(lm.SetContactPrefForService, lm.Telegram, tgUser.Username, req.Telegram)
contactPrefs.Telegram = &req.Telegram
}
}
if dcUser, ok := app.storage.GetDiscordKey(req.ID); ok {
@@ -369,11 +314,8 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
dcUser.Contact = req.Discord
app.storage.SetDiscordKey(req.ID, dcUser)
if change {
msg := ""
if !req.Discord {
msg = " not"
}
app.debug.Printf("Discord: User \"%s\" will%s be notified through Discord.", dcUser.Username, msg)
app.debug.Printf(lm.SetContactPrefForService, lm.Discord, dcUser.Username, req.Discord)
contactPrefs.Discord = &req.Discord
}
}
if mxUser, ok := app.storage.GetMatrixKey(req.ID); ok {
@@ -381,11 +323,8 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
mxUser.Contact = req.Matrix
app.storage.SetMatrixKey(req.ID, mxUser)
if change {
msg := ""
if !req.Matrix {
msg = " not"
}
app.debug.Printf("Matrix: User \"%s\" will%s be notified through Matrix.", mxUser.UserID, msg)
app.debug.Printf(lm.SetContactPrefForService, lm.Matrix, mxUser.UserID, req.Matrix)
contactPrefs.Matrix = &req.Matrix
}
}
if email, ok := app.storage.GetEmailsKey(req.ID); ok {
@@ -393,13 +332,17 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
email.Contact = req.Email
app.storage.SetEmailsKey(req.ID, email)
if change {
msg := ""
if !req.Email {
msg = " not"
}
app.debug.Printf("\"%s\" will%s be notified via Email.", email.Addr, msg)
app.debug.Printf(lm.SetContactPrefForService, lm.Email, email.Addr, req.Email)
contactPrefs.Email = &req.Email
}
}
for _, tps := range app.thirdPartyServices {
if err := tps.SetContactMethods(req.ID, nil, nil, nil, &contactPrefs); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
}
}
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -416,7 +359,7 @@ func (app *appContext) TelegramVerified(gc *gin.Context) {
respondBool(200, ok, gc)
}
// @Summary Returns true/false on whether or not a telegram PIN was verified. Requires invite code.
// @Summary Returns true/false on whether or not a telegram PIN was verified. Requires invite code. NOTE: "/invite" might have been changed in Settings > URL Paths.
// @Produce json
// @Success 200 {object} boolResponse
// @Success 401 {object} boolResponse
@@ -433,14 +376,14 @@ func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) {
pin := gc.Param("pin")
token, ok := app.telegram.TokenVerified(pin)
if ok && app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(token.Username) {
app.discord.DeleteVerifiedUser(pin)
app.discord.DeleteVerifiedToken(pin)
respondBool(400, false, gc)
return
}
respondBool(200, ok, gc)
}
// @Summary Returns true/false on whether or not a discord PIN was verified. Requires invite code.
// @Summary Returns true/false on whether or not a discord PIN was verified. Requires invite code. NOTE: "/invite" might have been changed in Settings > URL Paths.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 401 {object} boolResponse
@@ -456,7 +399,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
}
pin := gc.Param("pin")
user, ok := app.discord.UserVerified(pin)
if ok && app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(user.ID) {
if ok && app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(user.MethodID().(string)) {
delete(app.discord.verifiedTokens, pin)
respondBool(400, false, gc)
return
@@ -464,7 +407,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
respondBool(200, ok, gc)
}
// @Summary Returns a 10-minute, one-use Discord server invite
// @Summary Returns a 10-minute, one-use Discord server invite. NOTE: "/invite" might have been changed in Settings > URL Paths.
// @Produce json
// @Success 200 {object} DiscordInviteDTO
// @Failure 400 {object} boolResponse
@@ -474,7 +417,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
// @Router /invite/{invCode}/discord/invite [get]
// @tags Other
func (app *appContext) DiscordServerInvite(gc *gin.Context) {
if app.discord.inviteChannelName == "" {
if app.discord.InviteChannel.Name == "" {
respondBool(400, false, gc)
return
}
@@ -491,7 +434,7 @@ func (app *appContext) DiscordServerInvite(gc *gin.Context) {
gc.JSON(200, DiscordInviteDTO{invURL, iconURL})
}
// @Summary Generate and send a new PIN to a specified Matrix user.
// @Summary Generate and send a new PIN to a specified Matrix user. NOTE: "/invite" might have been changed in Settings > URL Paths.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 400 {object} stringResponse
@@ -530,7 +473,7 @@ func (app *appContext) MatrixSendPIN(gc *gin.Context) {
respondBool(200, true, gc)
}
// @Summary Check whether a matrix PIN is valid, and mark the token as verified if so. Requires invite code.
// @Summary Check whether a matrix PIN is valid, and mark the token as verified if so. Requires invite code. NOTE: "/invite" might have been changed in Settings > URL Paths.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 401 {object} boolResponse
@@ -542,7 +485,7 @@ func (app *appContext) MatrixSendPIN(gc *gin.Context) {
func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
code := gc.Param("invCode")
if _, ok := app.storage.GetInvitesKey(code); !ok {
app.debug.Println("Matrix: Invite code was invalid")
app.debug.Printf(lm.InvalidInviteCode, code)
respondBool(401, false, gc)
return
}
@@ -550,12 +493,12 @@ func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
pin := gc.Param("pin")
user, ok := app.matrix.tokens[pin]
if !ok {
app.debug.Println("Matrix: PIN not found")
app.debug.Printf(lm.InvalidPIN, pin)
respondBool(200, false, gc)
return
}
if user.User.UserID != userID {
app.debug.Println("Matrix: User ID of PIN didn't match")
app.debug.Printf(lm.UnauthorizedPIN, pin)
respondBool(200, false, gc)
return
}
@@ -572,6 +515,7 @@ func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
// @Failure 500 {object} boolResponse
// @Param MatrixLoginDTO body MatrixLoginDTO true "Username & password."
// @Router /matrix/login [post]
// @Security Bearer
// @tags Other
func (app *appContext) MatrixLogin(gc *gin.Context) {
var req MatrixLoginDTO
@@ -582,18 +526,18 @@ func (app *appContext) MatrixLogin(gc *gin.Context) {
}
token, err := app.matrix.generateAccessToken(req.Homeserver, req.Username, req.Password)
if err != nil {
app.err.Printf("Matrix: Failed to generate token: %v", err)
app.err.Printf(lm.FailedGenerateToken, err)
respond(401, "Unauthorized", gc)
return
}
tempConfig, _ := ini.Load(app.configPath)
tempConfig, _ := ini.ShadowLoad(app.configPath)
matrix := tempConfig.Section("matrix")
matrix.Key("enabled").SetValue("true")
matrix.Key("homeserver").SetValue(req.Homeserver)
matrix.Key("token").SetValue(token)
matrix.Key("user_id").SetValue(req.Username)
if err := tempConfig.SaveTo(app.configPath); err != nil {
app.err.Printf("Failed to save config to \"%s\": %v", app.configPath, err)
app.err.Printf(lm.FailedWriting, app.configPath, err)
respondBool(500, false, gc)
return
}
@@ -607,6 +551,7 @@ func (app *appContext) MatrixLogin(gc *gin.Context) {
// @Failure 500 {object} boolResponse
// @Param MatrixConnectUserDTO body MatrixConnectUserDTO true "User's Jellyfin ID & Matrix user ID."
// @Router /users/matrix [post]
// @Security Bearer
// @tags Other
func (app *appContext) MatrixConnect(gc *gin.Context) {
var req MatrixConnectUserDTO
@@ -614,20 +559,19 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
if app.storage.GetMatrix() == nil {
app.storage.deprecatedMatrix = matrixStore{}
}
roomID, encrypted, err := app.matrix.CreateRoom(req.UserID)
roomID, err := app.matrix.CreateRoom(req.UserID)
if err != nil {
app.err.Printf("Matrix: Failed to create room: %v", err)
app.err.Printf(lm.FailedCreateRoom, err)
respondBool(500, false, gc)
return
}
app.storage.SetMatrixKey(req.JellyfinID, MatrixUser{
UserID: req.UserID,
RoomID: string(roomID),
Lang: "en-us",
Contact: true,
Encrypted: encrypted,
UserID: req.UserID,
RoomID: string(roomID),
Lang: "en-us",
Contact: true,
})
app.matrix.isEncrypted[roomID] = encrypted
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -638,10 +582,12 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
// @Failure 500 {object} boolResponse
// @Param username path string true "username to search."
// @Router /users/discord/{username} [get]
// @Security Bearer
// @tags Other
func (app *appContext) DiscordGetUsers(gc *gin.Context) {
name := gc.Param("username")
if name == "" {
escapedName := gc.Param("username")
name, err := url.QueryUnescape(escapedName)
if err != nil || name == "" {
respondBool(400, false, gc)
return
}
@@ -664,6 +610,7 @@ func (app *appContext) DiscordGetUsers(gc *gin.Context) {
// @Failure 500 {object} boolResponse
// @Param DiscordConnectUserDTO body DiscordConnectUserDTO true "User's Jellyfin ID & Discord ID."
// @Router /users/discord [post]
// @Security Bearer
// @tags Other
func (app *appContext) DiscordConnect(gc *gin.Context) {
var req DiscordConnectUserDTO
@@ -677,8 +624,28 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
respondBool(500, false, gc)
return
}
app.storage.SetDiscordKey(req.JellyfinID, user)
for _, tps := range app.thirdPartyServices {
if err := tps.SetContactMethods(req.JellyfinID, nil, &user, nil, &common.ContactPreferences{
Discord: &user.Contact,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
}
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: req.JellyfinID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
}, gc, false)
linkExistingOmbiDiscordTelegram(app)
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -687,6 +654,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
// @Success 200 {object} boolResponse
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
// @Router /users/discord [delete]
// @Security Bearer
// @Tags Users
func (app *appContext) UnlinkDiscord(gc *gin.Context) {
var req forUserDTO
@@ -697,6 +665,27 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
return
} */
app.storage.DeleteDiscordKey(req.ID)
contact := false
for _, tps := range app.thirdPartyServices {
if err := tps.SetContactMethods(req.ID, nil, EmptyDiscordUser(), nil, &common.ContactPreferences{
Discord: &contact,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
}
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: req.ID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
}, gc, false)
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -705,6 +694,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
// @Success 200 {object} boolResponse
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
// @Router /users/telegram [delete]
// @Security Bearer
// @Tags Users
func (app *appContext) UnlinkTelegram(gc *gin.Context) {
var req forUserDTO
@@ -715,6 +705,27 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
return
} */
app.storage.DeleteTelegramKey(req.ID)
contact := false
for _, tps := range app.thirdPartyServices {
if err := tps.SetContactMethods(req.ID, nil, nil, EmptyTelegramUser(), &common.ContactPreferences{
Telegram: &contact,
}); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
}
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: req.ID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
}, gc, false)
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -723,6 +734,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
// @Success 200 {object} boolResponse
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
// @Router /users/matrix [delete]
// @Security Bearer
// @Tags Users
func (app *appContext) UnlinkMatrix(gc *gin.Context) {
var req forUserDTO
@@ -733,5 +745,16 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) {
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)
app.InvalidateWebUserCache()
respondBool(200, true, gc)
}

View File

@@ -2,23 +2,39 @@ 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"
ombiLib "github.com/hrfee/jfa-go/ombi"
"github.com/hrfee/mediabrowser"
)
func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, error) {
ombiUsers, code, err := app.ombi.GetUsers()
if err != nil || code != 200 {
return nil, code, err
}
jfUser, code, err := app.jf.UserByID(jfID, false)
if err != nil || code != 200 {
return nil, code, err
// getOmbiUser searches for an ombi user given a Jellyfin user ID. It looks for matching username or matching email address.
// If "email"=nil, an email address will be acquired from the DB instead. Passing it manually is useful when changing email address.
func (app *appContext) getOmbiUser(jfID string, email *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
if email == nil {
addr := ""
if e, ok := app.storage.GetEmailsKey(jfID); ok {
addr = e.Addr
}
email = &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 := ""
@@ -26,10 +42,36 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er
ombiAddr = a.(string)
}
if ombiUser["userName"].(string) == username || (ombiAddr == email && email != "") {
return ombiUser, code, err
return ombiUser, err
}
}
return nil, 400, fmt.Errorf("Couldn't find user")
// Gets a generic "not found" type error
return nil, common.GenericErr(404, err)
}
// Returns a user with the given name who has been imported from Jellyfin/Emby by Ombi
func (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.
@@ -40,10 +82,9 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er
// @Security Bearer
// @tags Ombi
func (app *appContext) OmbiUsers(gc *gin.Context) {
app.debug.Println("Ombi users requested")
users, status, err := app.ombi.GetUsers()
if err != nil || status != 200 {
app.err.Printf("Failed to get users from Ombi (%d): %v", status, err)
users, err := app.ombi.GetUsers()
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Ombi, err)
respond(500, "Couldn't get users", gc)
return
}
@@ -70,15 +111,16 @@ func (app *appContext) OmbiUsers(gc *gin.Context) {
func (app *appContext) SetOmbiProfile(gc *gin.Context) {
var req ombiUser
gc.BindJSON(&req)
profileName := gc.Param("profile")
escapedProfileName := gc.Param("profile")
profileName, _ := url.QueryUnescape(escapedProfileName)
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respondBool(400, false, gc)
return
}
template, code, err := app.ombi.TemplateByID(req.ID)
if err != nil || code != 200 || len(template) == 0 {
app.err.Printf("Couldn't get user from Ombi (%d): %v", code, err)
template, err := app.ombi.TemplateByID(req.ID)
if err != nil || len(template) == 0 {
app.err.Printf(lm.FailedGetUsers, lm.Ombi, err)
respond(500, "Couldn't get user", gc)
return
}
@@ -97,7 +139,8 @@ func (app *appContext) SetOmbiProfile(gc *gin.Context) {
// @Security Bearer
// @tags Ombi
func (app *appContext) DeleteOmbiProfile(gc *gin.Context) {
profileName := gc.Param("profile")
escapedProfileName := gc.Param("profile")
profileName, _ := url.QueryUnescape(escapedProfileName)
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respondBool(400, false, gc)
@@ -107,3 +150,126 @@ func (app *appContext) DeleteOmbiProfile(gc *gin.Context) {
app.storage.SetProfileKey(profileName, profile)
respondBool(204, true, gc)
}
type OmbiWrapper struct {
OmbiUserByJfID func(jfID string, email *string) (map[string]interface{}, error)
*ombiLib.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) SetContactMethods(jellyfinID string, email *string, discord *DiscordUser, telegram *TelegramUser, contactPrefs *common.ContactPreferences) (err error) {
ombiUser, err := ombi.OmbiUserByJfID(jellyfinID, email)
if err != nil {
return
}
if contactPrefs == nil {
contactPrefs = &common.ContactPreferences{
Email: nil,
Discord: nil,
Telegram: nil,
Matrix: nil,
}
}
if emailEnabled && email != nil {
ombiUser["emailAddress"] = *email
err = ombi.ModifyUser(ombiUser)
if err != nil {
// FIXME: This is a little ugly, considering all other errors are unformatted
err = fmt.Errorf(lm.FailedSetEmailAddress, lm.Ombi, jellyfinID, err)
return
}
}
data := make([]ombiLib.NotificationPref, 0, 2)
if discordEnabled {
pref := ombiLib.NotificationPref{
Agent: ombiLib.NotifAgentDiscord,
UserID: ombiUser["id"].(string),
}
valid := false
if contactPrefs.Discord != nil {
pref.Enabled = *(contactPrefs.Discord)
valid = true
} else if discord != nil && discord.ID != "" {
pref.Enabled = true
valid = true
}
if discord != nil {
pref.Value = discord.ID
valid = true
}
if valid {
data = append(data, pref)
}
}
if telegramEnabled && telegram != nil {
pref := ombiLib.NotificationPref{
Agent: ombiLib.NotifAgentTelegram,
UserID: ombiUser["id"].(string),
}
if contactPrefs.Telegram != nil {
pref.Enabled = *(contactPrefs.Telegram)
} else if telegram != nil && telegram.Username != "" {
pref.Enabled = true
}
if telegram != nil {
pref.Value = telegram.Username
}
data = append(data, pref)
}
if len(data) > 0 {
var resp string
resp, err = ombi.SetNotificationPrefs(ombiUser, data)
if err != nil {
if resp != "" {
err = fmt.Errorf("%v, %s", err, resp)
}
return
}
}
return
}
func (ombi *OmbiWrapper) Name() string { return lm.Ombi }
func (ombi *OmbiWrapper) Enabled(app *appContext, profile *Profile) bool {
return profile != nil && profile.Ombi != nil && len(profile.Ombi) != 0 && app.config.Section("ombi").Key("enabled").MustBool(false)
}

View File

@@ -1,35 +1,139 @@
package main
import (
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/timshannon/badgerhold/v4"
)
// @Summary Get a list of profiles
// @Summary Get the names of all available profile.
// @Produce json
// @Success 200 {object} getProfileNamesDTO
// @Router /profiles/names [get]
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) GetProfileNames(gc *gin.Context) {
fullProfileList := app.storage.GetProfiles()
profiles := make([]string, len(fullProfileList))
if len(profiles) != 0 {
defaultProfile := app.storage.GetDefaultProfile()
profiles[0] = defaultProfile.Name
i := 1
if len(fullProfileList) > 1 {
app.storage.db.ForEach(badgerhold.Where("Name").Ne(profiles[0]), func(p *Profile) error {
profiles[i] = p.Name
i++
return nil
})
}
}
resp := getProfileNamesDTO{
Profiles: profiles,
}
gc.JSON(200, resp)
}
// @Summary Get all available profiles, indexed by their names.
// @Produce json
// @Success 200 {object} getProfilesDTO
// @Router /profiles [get]
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) GetProfiles(gc *gin.Context) {
app.debug.Println("Profiles requested")
out := getProfilesDTO{
DefaultProfile: app.storage.GetDefaultProfile().Name,
Profiles: map[string]profileDTO{},
}
referralsEnabled := app.config.Section("user_page").Key("referrals").MustBool(false)
baseInv := Invite{}
for _, p := range app.storage.GetProfiles() {
out.Profiles[p.Name] = profileDTO{
Admin: p.Admin,
LibraryAccess: p.LibraryAccess,
FromUser: p.FromUser,
Ombi: p.Ombi != nil,
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 Get the raw values stored in a profile (Configuration, Policy, Jellyseerr/Ombi if applicable, etc.).
// @Produce json
// @Success 200 {object} ProfileDTO
// @Failure 400 {object} boolResponse
// @Param name path string true "name of profile (url encoded if necessary)"
// @Router /profiles/raw/{name} [get]
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) GetRawProfile(gc *gin.Context) {
escapedName := gc.Param("name")
name, err := url.QueryUnescape(escapedName)
if err != nil {
respondBool(400, false, gc)
return
}
if profile, ok := app.storage.GetProfileKey(name); ok {
gc.JSON(200, profile.ProfileDTO)
return
}
respondBool(400, false, gc)
}
// @Summary Update the raw data of a profile (Configuration, Policy, Jellyseerr/Ombi if applicable, etc.).
// @Produce json
// @Param ProfileDTO body ProfileDTO true "Raw profile data (all of it, do not omit anything)"
// @Success 204 {object} boolResponse
// @Success 201 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Router /profiles/raw/{name} [put]
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) ReplaceRawProfile(gc *gin.Context) {
escapedName := gc.Param("name")
name, err := url.QueryUnescape(escapedName)
if err != nil {
respondBool(400, false, gc)
return
}
existingProfile, ok := app.storage.GetProfileKey(name)
if !ok {
respondBool(400, false, gc)
return
}
var req ProfileDTO
gc.BindJSON(&req)
existingProfile.ProfileDTO = req
if req.Name == "" {
req.Name = name
}
status := http.StatusNoContent
app.storage.SetProfileKey(req.Name, existingProfile)
if req.Name != name {
// Name change
app.storage.DeleteProfileKey(name)
if discordEnabled {
app.discord.UpdateCommands()
}
status = http.StatusCreated
}
respondBool(status, true, gc)
}
// @Summary Set the default profile to use.
// @Produce json
// @Param profileChangeDTO body profileChangeDTO true "Default profile object"
@@ -41,10 +145,11 @@ func (app *appContext) GetProfiles(gc *gin.Context) {
func (app *appContext) SetDefaultProfile(gc *gin.Context) {
req := profileChangeDTO{}
gc.BindJSON(&req)
app.info.Printf("Setting default profile to \"%s\"", req.Name)
app.info.Printf(lm.SetDefaultProfile, req.Name)
if _, ok := app.storage.GetProfileKey(req.Name); !ok {
app.err.Printf("Profile not found: \"%s\"", req.Name)
respond(500, "Profile not found", gc)
msg := fmt.Sprintf(lm.FailedGetProfile, req.Name)
app.err.Println(msg)
respond(500, msg, gc)
return
}
app.storage.db.ForEach(&badgerhold.Query{}, func(profile *Profile) error {
@@ -68,31 +173,50 @@ func (app *appContext) SetDefaultProfile(gc *gin.Context) {
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) CreateProfile(gc *gin.Context) {
app.info.Println("Profile creation requested")
var req newProfileDTO
gc.BindJSON(&req)
app.jf.CacheExpiry = time.Now()
user, status, err := app.jf.UserByID(req.ID, false)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to get user from Jellyfin (%d): %v", status, err)
app.InvalidateJellyfinCache()
user, err := app.jf.UserByID(req.ID, false)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Couldn't get user", gc)
return
}
profile := Profile{
FromUser: user.Name,
Policy: user.Policy,
FromUser: user.Name,
ProfileDTO: ProfileDTO{Policy: user.Policy},
Homescreen: req.Homescreen,
}
app.debug.Printf("Creating profile from user \"%s\"", user.Name)
app.debug.Printf(lm.CreateProfileFromUser, user.Name)
if req.Homescreen {
profile.Configuration = user.Configuration
profile.Displayprefs, status, err = app.jf.GetDisplayPreferences(req.ID)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to get DisplayPrefs (%d): %v", status, err)
profile.Displayprefs, err = app.jf.GetDisplayPreferences(req.ID)
if err != nil {
app.err.Printf(lm.FailedGetJellyfinDisplayPrefs, req.ID, err)
respond(500, "Couldn't get displayprefs", gc)
return
}
}
if req.Jellyseerr && app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
user, err := app.js.MustGetUser(req.ID)
if err != nil {
app.err.Printf(lm.FailedGetUser, user.Name(), lm.Jellyseerr, err)
} else {
profile.Jellyseerr.User = user.UserTemplate
n, err := app.js.GetNotificationPreferencesByID(user.ID)
if err != nil {
app.err.Printf(lm.FailedGetJellyseerrNotificationPrefs, strconv.FormatInt(user.ID, 10), err)
} else {
profile.Jellyseerr.Notifications = n.NotificationsTemplate
profile.Jellyseerr.Enabled = true
}
}
}
app.storage.SetProfileKey(req.Name, profile)
// Refresh discord bots, profile list
if discordEnabled {
app.discord.UpdateCommands()
}
respondBool(200, true, gc)
}
@@ -110,3 +234,90 @@ func (app *appContext) DeleteProfile(gc *gin.Context) {
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) {
escapedProfileName := gc.Param("profile")
profileName, err := url.QueryUnescape(escapedProfileName)
if err != nil {
respond(400, "Invalid profile", gc)
app.err.Printf(lm.FailedGetProfile, profileName)
return
}
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) {
escapedProfileName := gc.Param("profile")
profileName, err := url.QueryUnescape(escapedProfileName)
if err != nil {
respond(400, "Invalid profile", gc)
app.err.Printf(lm.FailedGetProfile, profileName)
return
}
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)
}

View File

@@ -1,6 +1,7 @@
package main
import (
"fmt"
"net/http"
"os"
"strings"
@@ -8,6 +9,14 @@ import (
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
"github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4"
)
const (
REFERRAL_EXPIRY_DAYS = 90
)
// @Summary Returns the logged-in user's Jellyfin ID & Username, and other details.
@@ -20,9 +29,9 @@ func (app *appContext) MyDetails(gc *gin.Context) {
Id: gc.GetString("jfId"),
}
user, status, err := app.jf.UserByID(resp.Id, false)
if status != 200 || err != nil {
app.err.Printf("Failed to get Jellyfin user (%d): %+v\n", status, err)
user, err := app.jf.UserByID(resp.Id, false)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Failed to get user", gc)
return
}
@@ -74,12 +83,31 @@ func (app *appContext) MyDetails(gc *gin.Context) {
}
}
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."
// @Param SetContactPreferencesDTO body SetContactPreferencesDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
// @Success 200 {object} boolResponse
// @Success 400 {object} boolResponse
// @Success 500 {object} boolResponse
@@ -87,14 +115,14 @@ func (app *appContext) MyDetails(gc *gin.Context) {
// @Security Bearer
// @tags User Page
func (app *appContext) SetMyContactMethods(gc *gin.Context) {
var req SetContactMethodsDTO
var req SetContactPreferencesDTO
gc.BindJSON(&req)
req.ID = gc.GetString("jfId")
if req.ID == "" {
respondBool(400, false, gc)
return
}
app.setContactMethods(req, gc)
app.setContactPreferences(req, gc)
}
// @Summary Logout by deleting refresh token from cookies.
@@ -107,8 +135,9 @@ func (app *appContext) SetMyContactMethods(gc *gin.Context) {
func (app *appContext) LogoutUser(gc *gin.Context) {
cookie, err := gc.Cookie("user-refresh")
if err != nil {
app.debug.Printf("Couldn't get cookies: %s", err)
respond(500, "Couldn't fetch cookies", gc)
msg := fmt.Sprintf(lm.FailedGetCookies, "user-refresh", err)
app.debug.Println(msg)
respond(500, msg, gc)
return
}
app.invalidTokens = append(app.invalidTokens, cookie)
@@ -135,9 +164,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
var target ConfirmationTarget
var id string
fail := func() {
gcHTML(gc, 404, "404.html", gin.H{
"cssClass": app.cssClass,
"cssVersion": cssVersion,
app.gcHTML(gc, 404, "404.html", OtherPage, "en-us", gin.H{
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}
@@ -148,21 +175,21 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
}
token, err := jwt.Parse(key, checkToken)
if err != nil {
app.err.Printf("Failed to parse key: %s", err)
app.err.Printf(lm.FailedParseJWT, err)
fail()
// respond(500, "unknownError", gc)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
app.err.Printf("Failed to parse key: %s", err)
app.err.Println(lm.FailedCastJWT)
fail()
// respond(500, "unknownError", gc)
return
}
expiry := time.Unix(int64(claims["exp"].(float64)), 0)
if !(ok && token.Valid && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) {
app.err.Printf("Invalid key")
app.err.Println(lm.InvalidJWT)
fail()
// respond(400, "invalidKey", gc)
return
@@ -172,30 +199,22 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
// Perform an Action
if target == NoOp {
gc.Redirect(http.StatusSeeOther, "/my/account")
gc.Redirect(http.StatusSeeOther, MustGetNonEmptyURL(PAGES.MyAccount))
return
} else if target == UserEmailChange {
emailStore, ok := app.storage.GetEmailsKey(id)
if !ok {
emailStore = EmailAddress{
Contact: true,
}
}
emailStore.Addr = claims["email"].(string)
app.storage.SetEmailsKey(id, emailStore)
if app.config.Section("ombi").Key("enabled").MustBool(false) {
ombiUser, code, err := app.getOmbiUser(id)
if code == 200 && err == nil {
ombiUser["emailAddress"] = claims["email"].(string)
code, err = app.ombi.ModifyUser(ombiUser)
if code != 200 || err != nil {
app.err.Printf("%s: Failed to change ombi email address (%d): %v", ombiUser["userName"].(string), code, err)
}
}
}
app.modifyEmail(id, claims["email"].(string))
app.info.Println("Email list modified")
gc.Redirect(http.StatusSeeOther, "/my/account")
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: id,
SourceType: ActivityUser,
Source: id,
Value: "email",
Time: time.Now(),
}, gc, true)
app.info.Printf(lm.UserEmailAdjusted, id)
gc.Redirect(http.StatusSeeOther, MustGetNonEmptyURL(PAGES.MyAccount))
return
}
}
@@ -213,7 +232,6 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
func (app *appContext) ModifyMyEmail(gc *gin.Context) {
var req ModifyMyEmailDTO
gc.BindJSON(&req)
app.debug.Println("Email modification requested")
if !strings.ContainsRune(req.Email, '@') {
respond(400, "Invalid Email Address", gc)
return
@@ -233,26 +251,26 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET")))
if err != nil {
app.err.Printf("Failed to generate confirmation token: %v", err)
app.err.Printf(lm.FailedSignJWT, err)
respond(500, "errorUnknown", gc)
return
}
if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) {
user, status, err := app.jf.UserByID(id, false)
user, err := app.jf.UserByID(id, false)
name := ""
if status == 200 && err == nil {
if err == nil {
name = user.Name
}
app.debug.Printf("%s: Email confirmation required", id)
app.debug.Printf(lm.EmailConfirmationRequired, id)
respond(401, "confirmEmail", gc)
msg, err := app.email.constructConfirmation("", name, key, app, false)
msg, err := app.email.constructConfirmation("", name, key, false)
if err != nil {
app.err.Printf("%s: Failed to construct confirmation email: %v", name, err)
app.err.Printf(lm.FailedConstructConfirmationEmail, id, err)
} else if err := app.email.send(msg, req.Email); err != nil {
app.err.Printf("%s: Failed to send user confirmation email: %v", name, err)
app.err.Printf(lm.FailedSendConfirmationEmail, id, req.Email, err)
} else {
app.info.Printf("%s: Sent user confirmation email to \"%s\"", name, req.Email)
app.info.Printf(lm.SentConfirmationEmail, id, req.Email)
}
return
}
@@ -272,7 +290,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
// @Security Bearer
// @tags User Page
func (app *appContext) MyDiscordServerInvite(gc *gin.Context) {
if app.discord.inviteChannelName == "" {
if app.discord.InviteChannel.Name == "" {
respondBool(400, false, gc)
return
}
@@ -320,7 +338,7 @@ func (app *appContext) GetMyPIN(gc *gin.Context) {
func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
pin := gc.Param("pin")
dcUser, ok := app.discord.AssignedUserVerified(pin, gc.GetString("jfId"))
app.discord.DeleteVerifiedUser(pin)
app.discord.DeleteVerifiedToken(pin)
if !ok {
respondBool(200, false, gc)
return
@@ -335,6 +353,23 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
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)
}
@@ -359,9 +394,11 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
return
}
tgUser := TelegramUser{
ChatID: token.ChatID,
Username: token.Username,
Contact: true,
TelegramVerifiedToken: TelegramVerifiedToken{
ChatID: token.ChatID,
Username: token.Username,
},
Contact: true,
}
if lang, ok := app.telegram.languages[tgUser.ChatID]; ok {
tgUser.Lang = lang
@@ -373,6 +410,23 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
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)
}
@@ -425,12 +479,12 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
pin := gc.Param("pin")
user, ok := app.matrix.tokens[pin]
if !ok {
app.debug.Println("Matrix: PIN not found")
app.debug.Printf(lm.InvalidPIN, pin)
respondBool(200, false, gc)
return
}
if user.User.UserID != userID {
app.debug.Println("Matrix: User ID of PIN didn't match")
app.debug.Printf(lm.UnauthorizedPIN, pin)
respondBool(200, false, gc)
return
}
@@ -444,6 +498,16 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
}
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)
}
@@ -456,6 +520,23 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
// @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)
}
@@ -467,6 +548,23 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
// @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)
}
@@ -478,6 +576,16 @@ func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
// @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)
}
@@ -495,9 +603,11 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
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 == "" {
app.debug.Println("Ignoring empty request for PWR")
cancel.Stop()
respondBool(400, false, gc)
return
@@ -505,9 +615,9 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
var pwr InternalPWR
var err error
jfUser, ok := app.ReverseUserSearch(address)
jfUser, ok := app.ReverseUserSearch(address, usernameAllowed, emailAllowed, contactMethodAllowed)
if !ok {
app.debug.Printf("Ignoring PWR request: User not found")
app.debug.Printf(lm.FailedGetUsers, lm.Jellyfin, "no results")
for range timerWait {
respondBool(204, true, gc)
@@ -517,7 +627,7 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
}
pwr, err = app.GenInternalReset(jfUser.ID)
if err != nil {
app.err.Printf("Failed to get user from Jellyfin: %v", err)
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
for range timerWait {
respondBool(204, true, gc)
return
@@ -535,19 +645,19 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
Username: pwr.Username,
Expiry: pwr.Expiry,
Internal: true,
}, app, false,
}, false,
)
if err != nil {
app.err.Printf("Failed to construct password reset message for \"%s\": %v", pwr.Username, err)
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
for range timerWait {
respondBool(204, true, gc)
return
}
return
} else if err := app.sendByID(msg, jfUser.ID); err != nil {
app.err.Printf("Failed to send password reset message to \"%s\": %v", address, err)
app.err.Printf(lm.FailedSendPWRMessage, pwr.Username, "?", err)
} else {
app.info.Printf("Sent password reset message to \"%s\"", address)
app.info.Printf(lm.SentPWRMessage, pwr.Username, "?")
}
for range timerWait {
respondBool(204, true, gc)
@@ -574,42 +684,50 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
validation := app.validator.validate(req.New)
for _, val := range validation {
if !val {
app.debug.Printf("%s: Change password failed: Invalid password", gc.GetString("jfId"))
gc.JSON(400, validation)
return
}
}
user, status, err := app.jf.UserByID(gc.GetString("jfId"), false)
if status != 200 || err != nil {
app.err.Printf("Failed to change password: couldn't find user (%d): %+v", status, err)
user, err := app.jf.UserByID(gc.GetString("jfId"), false)
if err != nil {
app.err.Printf(lm.FailedGetUser, gc.GetString("jfId"), lm.Jellyfin, err)
respondBool(500, false, gc)
return
}
// Authenticate as user to confirm old password.
user, status, err = app.authJf.Authenticate(user.Name, req.Old)
if status != 200 || err != nil {
user, err = app.authJf.Authenticate(user.Name, req.Old)
if err != nil {
respondBool(401, false, gc)
return
}
status, err = app.jf.SetPassword(gc.GetString("jfId"), req.Old, req.New)
if (status != 200 && status != 204) || err != nil {
err = app.jf.SetPassword(gc.GetString("jfId"), req.Old, req.New)
if err != nil {
respondBool(500, false, gc)
return
}
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, status, err := app.getOmbiUser(gc.GetString("jfId"))
if status != 200 || err != nil {
app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", user.Name, status, err)
ombiUser, err := app.getOmbiUser(gc.GetString("jfId"), nil)
if err != nil {
app.err.Printf(lm.FailedGetUser, user.Name, lm.Ombi, err)
return
}
ombiUser["password"] = req.New
status, err = app.ombi.ModifyUser(ombiUser)
if status != 200 || err != nil {
app.err.Printf("Failed to set password for ombi user \"%s\" (%d): %v", ombiUser["userName"], status, err)
err = app.ombi.ModifyUser(ombiUser)
if err != nil {
app.err.Printf(lm.FailedChangePassword, lm.Ombi, ombiUser["userName"], err)
return
}
app.debug.Printf("Reset password for ombi user \"%s\"", ombiUser["userName"])
app.debug.Printf(lm.ChangePassword, lm.Ombi, ombiUser["userName"])
}()
}
cookie, err := gc.Cookie("user-refresh")
@@ -617,7 +735,75 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
app.invalidTokens = append(app.invalidTokens, cookie)
gc.SetCookie("refresh", "invalid", -1, "/my", gc.Request.URL.Hostname(), true, true)
} else {
app.debug.Printf("Couldn't get cookies: %s", err)
app.debug.Printf(lm.FailedGetCookies, "user-refresh", err)
}
respondBool(204, true, gc)
}
// @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)
}
app.InvalidateWebUserCache()
gc.JSON(200, GetMyReferralRespDTO{
Code: inv.Code,
RemainingUses: inv.RemainingUses,
NoLimit: inv.NoLimit,
Expiry: inv.ValidTill.Unix(),
UseExpiry: inv.UseReferralExpiry,
})
}

File diff suppressed because it is too large Load Diff

294
api.go
View File

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

22
args.go
View File

@@ -8,6 +8,8 @@ import (
"os"
"path/filepath"
"strings"
lm "github.com/hrfee/jfa-go/logmessages"
)
func (app *appContext) loadArgs(firstCall bool) {
@@ -23,10 +25,14 @@ 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")
flag.BoolVar(&NO_API_AUTH_DO_NOT_USE, "disable-api-auth-do-not-use", false, "Disables API authentication. DO NOT USE!")
flag.StringVar(&NO_API_AUTH_FORCE_JFID, "disable-api-auth-force-jf-id", "", "Assume given JFID when API auth is disabled.")
flag.Parse()
if *help {
flag.Usage()
@@ -41,6 +47,22 @@ func (app *appContext) loadArgs(firstCall bool) {
if *PPROF {
os.Setenv("PPROF", "1")
}
if *_LOADBAK != "" {
LOADBAK = *_LOADBAK
}
if NO_API_AUTH_DO_NOT_USE && *DEBUG {
NO_API_AUTH_DO_NOT_USE = false
forceJfID := NO_API_AUTH_FORCE_JFID
NO_API_AUTH_FORCE_JFID = ""
buf := bufio.NewReader(os.Stdin)
app.err.Print(lm.NoAPIAuthPrompt)
sentence, err := buf.ReadBytes('\n')
if err == nil && strings.ContainsRune(string(sentence), 'y') {
NO_API_AUTH_DO_NOT_USE = true
NO_API_AUTH_FORCE_JFID = forceJfID
}
}
}
if os.Getenv("SWAGGER") == "1" {

167
auth.go
View File

@@ -2,6 +2,7 @@ package main
import (
"encoding/base64"
"errors"
"fmt"
"os"
"strings"
@@ -9,6 +10,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
)
@@ -18,9 +20,34 @@ const (
REFRESH_TOKEN_VALIDITY_SEC = 3600 * 24
)
func (app *appContext) webAuth() gin.HandlerFunc {
return app.authenticate
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 {
if NO_API_AUTH_DO_NOT_USE {
return app.bogusAuthenticate
} else {
return app.authenticate
}
}
func (app *appContext) authLog(v any) { app.debug.PrintfCustomLevel(4, lm.FailedAuthRequest, v) }
// CreateToken returns a web token as well as a refresh token, which can be used to obtain new tokens.
func CreateToken(userId, jfId string, admin bool) (string, string, error) {
@@ -53,32 +80,26 @@ func (app *appContext) decodeValidateAuthHeader(gc *gin.Context) (claims jwt.Map
ok = false
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
if header[0] != "Bearer" {
app.debug.Println("Invalid authorization header")
app.authLog(lm.InvalidAuthHeader)
respond(401, "Unauthorized", gc)
return
}
token, err := jwt.Parse(string(header[1]), checkToken)
if err != nil {
app.debug.Printf("Auth denied: %s", err)
app.authLog(fmt.Sprintf(lm.FailedParseJWT, err))
respond(401, "Unauthorized", gc)
return
}
claims, ok = token.Claims.(jwt.MapClaims)
if !ok {
app.debug.Println("Invalid JWT")
app.authLog(lm.FailedCastJWT)
respond(401, "Unauthorized", gc)
return
}
expiryUnix := int64(claims["exp"].(float64))
if err != nil {
app.debug.Printf("Auth denied: %s", err)
respond(401, "Unauthorized", gc)
ok = false
return
}
expiry := time.Unix(expiryUnix, 0)
if !(ok && token.Valid && claims["type"].(string) == "bearer" && expiry.After(time.Now())) {
app.debug.Printf("Auth denied: Invalid token")
app.authLog(lm.InvalidJWT)
// app.debug.Printf("Expiry: %+v, OK: %t, Valid: %t, ClaimType: %s\n", expiry, ok, token.Valid, claims["type"].(string))
respond(401, "Unauthorized", gc)
ok = false
@@ -96,7 +117,7 @@ func (app *appContext) authenticate(gc *gin.Context) {
}
isAdminToken := claims["admin"].(bool)
if !isAdminToken {
app.debug.Printf("Auth denied: Token was not for admin access")
app.authLog(lm.NonAdminToken)
respond(401, "Unauthorized", gc)
return
}
@@ -111,14 +132,20 @@ func (app *appContext) authenticate(gc *gin.Context) {
}
}
if !match {
app.debug.Printf("Couldn't find user ID \"%s\"", userID)
app.authLog(fmt.Sprintf(lm.NonAdminUser, userID))
respond(401, "Unauthorized", gc)
return
}
gc.Set("jfId", jfID)
gc.Set("userId", userID)
gc.Set("userMode", false)
app.debug.Println("Auth succeeded")
gc.Next()
}
// bogusAuthenticate is for use with NO_API_AUTH_DO_NOT_USE, it sets the jfId/userId value from NO_API_AUTH_FORCE_JF_ID.
func (app *appContext) bogusAuthenticate(gc *gin.Context) {
gc.Set("jfId", NO_API_AUTH_FORCE_JFID)
gc.Set("userId", NO_API_AUTH_FORCE_JFID)
gc.Next()
}
@@ -133,7 +160,7 @@ type getTokenDTO struct {
Token string `json:"token" example:"kjsdklsfdkljfsjsdfklsdfkldsfjdfskjsdfjklsdf"` // API token for use with everything else.
}
func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, password string, ok bool) {
func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool) (username, password string, ok bool) {
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
auth, _ := base64.StdEncoding.DecodeString(header[1])
creds := strings.SplitN(string(auth), ":", 2)
@@ -141,7 +168,7 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, pas
password = creds[1]
ok = false
if username == "" || password == "" {
app.debug.Println("Auth denied: blank username/password")
app.logIpDebug(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.EmptyUserOrPass))
respond(401, "Unauthorized", gc)
return
}
@@ -149,21 +176,45 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, pas
return
}
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context) (user mediabrowser.User, ok bool) {
func (app *appContext) canAccessAdminPage(user mediabrowser.User, emailStore EmailAddress) bool {
// 1. "Allow all" is enabled, so simply being a user implies access.
if app.config.Section("ui").Key("allow_all").MustBool(false) && user.ID != "" {
return true
}
// 2. You've been made an "accounts admin" from the accounts tab.
if emailStore.Admin {
return true
}
// 3. (Jellyfin) "Admins only" is enabled, and you're one.
if app.config.Section("ui").Key("admin_only").MustBool(true) && user.ID != "" && user.Policy.IsAdministrator {
return true
}
return false
}
func (app *appContext) canAccessAdminPageByID(jfID string) bool {
user, err := app.jf.UserByID(jfID, false)
if err != nil {
return false
}
emailStore, _ := app.storage.GetEmailsKey(jfID)
return app.canAccessAdminPage(user, emailStore)
}
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context, userpage bool) (user mediabrowser.User, ok bool) {
ok = false
user, status, err := app.authJf.Authenticate(username, password)
if status != 200 || err != nil {
if status == 401 || status == 400 {
app.info.Println("Auth denied: Invalid username/password (Jellyfin)")
user, err := app.authJf.Authenticate(username, password)
if err != nil {
if errors.As(err, &mediabrowser.ErrUnauthorized{}) {
app.logIpInfo(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.InvalidUserOrPass))
respond(401, "Unauthorized", gc)
return
}
if status == 403 {
app.info.Println("Auth denied: Jellyfin account disabled")
} else if errors.As(err, &mediabrowser.ErrForbidden{}) {
app.logIpInfo(gc, userpage, fmt.Sprintf(lm.FailedAuthRequest, lm.UserDisabled))
respond(403, "yourAccountWasDisabled", gc)
return
}
app.err.Printf("Auth failed: Couldn't authenticate with Jellyfin (%d/%s)", status, err)
app.authLog(fmt.Sprintf(lm.FailedAuthJellyfin, app.jf.Server, 0, err))
respond(500, "Jellyfin error", gc)
return
}
@@ -180,8 +231,8 @@ func (app *appContext) validateJellyfinCredentials(username, password string, gc
// @tags Auth
// @Security getTokenAuth
func (app *appContext) getTokenLogin(gc *gin.Context) {
app.info.Println("Token requested (login attempt)")
username, password, ok := app.decodeValidateLoginHeader(gc)
app.logIpInfo(gc, false, fmt.Sprintf(lm.RequestingToken, lm.TokenLoginAttempt))
username, password, ok := app.decodeValidateLoginHeader(gc, false)
if !ok {
return
}
@@ -190,50 +241,46 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
for _, user := range app.adminUsers {
if user.Username == username && user.Password == password {
match = true
app.debug.Println("Found existing user")
userID = user.UserID
break
}
}
if !app.jellyfinLogin && !match {
app.info.Println("Auth denied: Invalid username/password")
app.logIpInfo(gc, false, fmt.Sprintf(lm.FailedAuthRequest, lm.InvalidUserOrPass))
respond(401, "Unauthorized", gc)
return
}
if !match {
user, ok := app.validateJellyfinCredentials(username, password, gc)
user, ok := app.validateJellyfinCredentials(username, password, gc, false)
if !ok {
return
}
jfID = user.ID
if !app.config.Section("ui").Key("allow_all").MustBool(false) {
accountsAdmin := false
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
if emailStore, ok := app.storage.GetEmailsKey(jfID); ok {
accountsAdmin = emailStore.Admin
}
accountsAdmin = accountsAdmin || (adminOnly && user.Policy.IsAdministrator)
if !accountsAdmin {
app.debug.Printf("Auth denied: Users \"%s\" isn't admin", username)
respond(401, "Unauthorized", gc)
return
}
emailStore, _ := app.storage.GetEmailsKey(jfID)
accountsAdmin := app.canAccessAdminPage(user, emailStore)
if !accountsAdmin {
app.authLog(fmt.Sprintf(lm.NonAdminUser, username))
respond(401, "Unauthorized", gc)
return
}
// New users are only added when using jellyfinLogin.
userID = shortuuid.New()
newUser := User{
UserID: userID,
}
app.debug.Printf("Token generated for user \"%s\"", username)
app.debug.Printf(lm.GenerateToken, username)
app.adminUsers = append(app.adminUsers, newUser)
}
token, refresh, err := CreateToken(userID, jfID, true)
if err != nil {
app.err.Printf("getToken failed: Couldn't generate token (%s)", err)
app.err.Printf(lm.FailedGenerateToken, err)
respond(500, "Couldn't generate token", gc)
return
}
gc.SetCookie("refresh", refresh, (3600 * 24), "/", gc.Request.URL.Hostname(), true, true)
host := app.ExternalDomainNoPort(gc)
// Before you think this is broken: the first "true" arg is for "secure", i.e. only HTTPS!
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
gc.JSON(200, getTokenDTO{token})
}
@@ -241,35 +288,29 @@ func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName s
ok = false
cookie, err := gc.Cookie(cookieName)
if err != nil || cookie == "" {
app.debug.Printf("getTokenRefresh denied: Couldn't get token: %s", err)
app.authLog(fmt.Sprintf(lm.FailedGetCookies, cookieName, err))
respond(400, "Couldn't get token", gc)
return
}
for _, token := range app.invalidTokens {
if cookie == token {
app.debug.Println("getTokenRefresh: Invalid token")
respond(401, "Invalid token", gc)
app.authLog(lm.LocallyInvalidatedJWT)
respond(401, lm.InvalidJWT, gc)
return
}
}
token, err := jwt.Parse(cookie, checkToken)
if err != nil {
app.debug.Println("getTokenRefresh: Invalid token")
respond(400, "Invalid token", gc)
app.authLog(fmt.Sprintf(lm.FailedParseJWT, err))
respond(400, lm.InvalidJWT, gc)
return
}
claims, ok = token.Claims.(jwt.MapClaims)
expiryUnix := int64(claims["exp"].(float64))
if err != nil {
app.debug.Printf("getTokenRefresh: Invalid token expiry: %s", err)
respond(401, "Invalid token", gc)
ok = false
return
}
expiry := time.Unix(expiryUnix, 0)
if !(ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now())) {
app.debug.Printf("getTokenRefresh: Invalid token: %+v", err)
respond(401, "Invalid token", gc)
app.authLog(lm.InvalidJWT)
respond(401, lm.InvalidJWT, gc)
ok = false
return
}
@@ -284,7 +325,7 @@ func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName s
// @Router /token/refresh [get]
// @tags Auth
func (app *appContext) getTokenRefresh(gc *gin.Context) {
app.debug.Println("Token requested (refresh token)")
app.logIpInfo(gc, false, fmt.Sprintf(lm.RequestingToken, lm.TokenRefresh))
claims, ok := app.decodeValidateRefreshCookie(gc, "refresh")
if !ok {
return
@@ -293,10 +334,12 @@ func (app *appContext) getTokenRefresh(gc *gin.Context) {
jfID := claims["jfid"].(string)
jwt, refresh, err := CreateToken(userID, jfID, true)
if err != nil {
app.err.Printf("getTokenRefresh failed: Couldn't generate token (%s)", err)
app.err.Printf(lm.FailedGenerateToken, err)
respond(500, "Couldn't generate token", gc)
return
}
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", gc.Request.URL.Hostname(), true, true)
// host := gc.Request.URL.Hostname()
host := app.ExternalDomainNoPort(gc)
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
gc.JSON(200, getTokenDTO{jwt})
}

View File

@@ -1,3 +1,4 @@
//go:build tray
// +build tray
package main
@@ -8,7 +9,7 @@ import (
"path/filepath"
"github.com/emersion/go-autostart"
"github.com/getlantern/systray"
"github.com/lutischan-ferenc/systray"
)
type Autostart struct {
@@ -48,8 +49,8 @@ func NewAutostart(name, displayname, trayName, trayTooltip string) *Autostart {
return a
}
func (a *Autostart) HandleCheck() {
for range a.menuitem.ClickedCh {
func (a *Autostart) Register() {
a.menuitem.Click(func() {
if !a.menuitem.Checked() {
if err := a.as.Enable(); err != nil {
log.Printf("Failed to enable autostart on login: %v", err)
@@ -65,5 +66,5 @@ func (a *Autostart) HandleCheck() {
log.Printf("Disabled autostart")
}
}
}
})
}

304
backups.go Normal file
View File

@@ -0,0 +1,304 @@
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_PREFIX_OLD = "jfa-go-db-"
BACKUP_COMMIT_PREFIX = "-c-"
BACKUP_DATE_PREFIX = "-d-"
BACKUP_UPLOAD_PREFIX = "upload-"
BACKUP_DATEFMT = "2006-01-02T15-04-05"
BACKUP_SUFFIX = ".bak"
)
type Backup struct {
Date time.Time
Commit string
Upload bool
}
func (b Backup) IsZero() bool { return b.Date.IsZero() && b.Commit == "" && b.Upload == false }
func (b Backup) Equals(a Backup) bool {
return a.Date.Equal(b.Date) && a.Commit == b.Commit && a.Upload == b.Upload
}
// Pre 21/03/25 format: "{BACKUP_PREFIX_OLD}{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-2006-01-02T15-04-05.bak"
// Post 21/03/25 format: "{BACKUP_PREFIX}-c-{commit}-d-{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-c-0b92060-d-2006-01-02T15-04-05.bak"
func (b Backup) String() string {
t := b.Date
if t.IsZero() {
t = time.Now()
}
out := BACKUP_PREFIX
if b.Upload {
out = BACKUP_UPLOAD_PREFIX + out
}
if b.Commit != "" {
out += BACKUP_COMMIT_PREFIX + b.Commit
}
out += BACKUP_DATE_PREFIX + t.Local().Format(BACKUP_DATEFMT) + BACKUP_SUFFIX
return out
}
func (b *Backup) FromString(f string) error {
of := f
if strings.HasPrefix(f, BACKUP_UPLOAD_PREFIX) {
b.Upload = true
f = f[len(BACKUP_UPLOAD_PREFIX):]
}
if !strings.HasPrefix(f, BACKUP_PREFIX) {
return fmt.Errorf("file doesn't have correct prefix (\"%s\")", BACKUP_PREFIX)
}
f = f[len(BACKUP_PREFIX):]
if !strings.HasSuffix(f, BACKUP_SUFFIX) {
return fmt.Errorf("file doesn't have correct suffix (\"%s\")", BACKUP_SUFFIX)
}
for range 2 {
if strings.HasPrefix(f, BACKUP_COMMIT_PREFIX) {
f = f[len(BACKUP_COMMIT_PREFIX):]
commitEnd := strings.Index(f, BACKUP_DATE_PREFIX)
if commitEnd == -1 {
commitEnd = strings.Index(f, BACKUP_SUFFIX)
}
if commitEnd == -1 {
return fmt.Errorf("end of commit (\"%s\" or \"%s\") not found in \"%s\"", BACKUP_DATE_PREFIX, BACKUP_PREFIX, f)
}
b.Commit = f[:commitEnd]
f = f[commitEnd:]
} else if strings.HasPrefix(f, BACKUP_DATE_PREFIX) {
f = f[len(BACKUP_DATE_PREFIX):]
dateEnd := strings.Index(f, BACKUP_COMMIT_PREFIX)
if dateEnd == -1 {
dateEnd = strings.Index(f, BACKUP_SUFFIX)
}
if dateEnd == -1 {
return fmt.Errorf("end of date (\"%s\" or \"%s\") not found in \"%s\"", BACKUP_COMMIT_PREFIX, BACKUP_PREFIX, f)
}
t, err := time.Parse(BACKUP_DATEFMT, f[:dateEnd])
if err != nil {
return err
}
b.Date = t
f = f[dateEnd:]
}
}
if b.Date.IsZero() {
return b.FromOldString(of)
}
return nil
}
func (b *Backup) FromOldString(f string) error {
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(f, BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX+"-"), BACKUP_SUFFIX))
if err != nil {
return fmt.Errorf(lm.FailedParseTime, err)
}
b.Date = t
return nil
}
type BackupList struct {
files []os.DirEntry
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("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)
if err := app.storage.Connect(app.config); err != nil {
app.err.Fatalf(lm.FailedConnectDB, app.storage.db_path, err)
}
defer app.storage.Close()
f, err := os.Open(LOADBAK)
if err != nil {
app.err.Fatalf(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_OLD + "2023-12-21T21-08-00" + BACKUP_SUFFIX
A1 := Backup{}
A1.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
testBackupParse(Q1, A1, t)
}
func TestBackupParserOldUpload(t *testing.T) {
Q2 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX_OLD + "2023-12-21T21-08-00" + BACKUP_SUFFIX
A2 := Backup{
Upload: true,
}
A2.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
testBackupParse(Q2, A2, t)
}
func TestBackupParserUploadDate(t *testing.T) {
Q3 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX + BACKUP_DATE_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX
A3 := Backup{
Upload: true,
}
A3.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
testBackupParse(Q3, A3, t)
}
func TestBackupParserUploadCommitDate(t *testing.T) {
Q4 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX + BACKUP_COMMIT_PREFIX + "testcommit" + BACKUP_DATE_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX
A4 := Backup{
Commit: "testcommit",
Upload: true,
}
A4.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
testBackupParse(Q4, A4, t)
}
func TestBackupParserDateCommit(t *testing.T) {
Q5 := BACKUP_PREFIX + BACKUP_DATE_PREFIX + "2023-12-21T21-08-00" + BACKUP_COMMIT_PREFIX + "testcommit" + BACKUP_SUFFIX
A5 := Backup{
Commit: "testcommit",
}
A5.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
testBackupParse(Q5, A5, t)
}

9
biome.json Normal file
View File

@@ -0,0 +1,9 @@
{
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 4,
"formatWithErrors": false,
"lineWidth": 120
}
}

View File

@@ -1,10 +1,31 @@
package common
import (
"bytes"
"compress/gzip"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
lm "github.com/hrfee/jfa-go/logmessages"
)
const (
BogusIdentifier = "123412341234123456"
)
// ContactPreferences holds whether or not a user should be contacted through each of the available
// methods. If nil, leave setting alone.
type ContactPreferences struct {
Email, Discord, Telegram, Matrix *bool
}
// TimeoutHandler recovers from an http timeout or panic.
type TimeoutHandler func()
@@ -12,7 +33,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 +42,135 @@ func NewTimeoutHandler(name, addr string, noFail bool) TimeoutHandler {
}
}
}
// most 404 errors are from UserNotFound, so this generic error doesn't really need any detail.
type ErrNotFound error
type ErrUnauthorized struct{}
func (err ErrUnauthorized) Error() string {
return lm.Unauthorized
}
type ErrForbidden struct{}
func (err ErrForbidden) Error() string {
return lm.Forbidden
}
var (
NotFound ErrNotFound = errors.New(lm.NotFound)
)
type ErrUnknown struct {
code int
}
func (err ErrUnknown) Error() string {
msg := fmt.Sprintf(lm.FailedGenericWithCode, err.code)
return msg
}
// GenericErr returns an error appropriate to the given HTTP status (or actual error, if given).
func GenericErr(status int, err error) error {
if err != nil {
return err
}
switch status {
case 200, 204, 201:
return nil
case 401, 400:
return ErrUnauthorized{}
case 404:
return NotFound
case 403:
return ErrForbidden{}
default:
return ErrUnknown{code: status}
}
}
func GenericErrFromResponse(resp *http.Response, err error) error {
if resp == nil {
return ErrUnknown{code: -2}
}
return GenericErr(resp.StatusCode, err)
}
type ConfigurableTransport interface {
// SetTransport sets the http.Transport to use for requests. Can be used to set a proxy.
SetTransport(t *http.Transport)
}
// Stripped down-ish version of rough http request function used in most of the API clients.
func Req(httpClient *http.Client, timeoutHandler TimeoutHandler, mode string, uri string, data any, queryParams url.Values, headers map[string]string, response bool) (string, int, error) {
var params []byte
if data != nil {
params, _ = json.Marshal(data)
}
if qp := queryParams.Encode(); qp != "" {
uri += "?" + qp
}
var req *http.Request
if data != nil {
req, _ = http.NewRequest(mode, uri, bytes.NewBuffer(params))
} else {
req, _ = http.NewRequest(mode, uri, nil)
}
req.Header.Add("Content-Type", "application/json")
for name, value := range headers {
req.Header.Add(name, value)
}
resp, err := httpClient.Do(req)
if resp == nil {
return "", 0, err
}
err = GenericErr(resp.StatusCode, err)
if timeoutHandler != nil {
defer timeoutHandler()
}
var responseText string
defer resp.Body.Close()
if response || err != nil {
responseText, err = decodeResp(resp)
if err != nil {
return responseText, resp.StatusCode, err
}
}
if err != nil {
var msg any
err = json.Unmarshal([]byte(responseText), &msg)
if err != nil {
return responseText, resp.StatusCode, err
}
if msg != nil {
err = fmt.Errorf("got %d: %+v", resp.StatusCode, msg)
}
return responseText, resp.StatusCode, err
}
return responseText, resp.StatusCode, err
}
func decodeResp(resp *http.Response) (string, error) {
var out io.Reader
switch resp.Header.Get("Content-Encoding") {
case "gzip":
out, _ = gzip.NewReader(resp.Body)
default:
out = resp.Body
}
buf := new(strings.Builder)
_, err := io.Copy(buf, out)
if err != nil {
return "", err
}
return buf.String(), nil
}
// MustAuthenticateOptions is used to control the behaviour of the MustAuthenticate-like methods.
type MustAuthenticateOptions struct {
RetryCount int // Number of Retries before failure.
RetryGap time.Duration // Duration to wait between tries.
LogFailures bool // Whether or not to print failures to the log.
Counter int // The current retry count.
}

81
common/config.go Normal file
View File

@@ -0,0 +1,81 @@
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"`
Aliases []string `json:"aliases,omitempty" yaml:"aliases,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"`
Aliases []string `json:"aliases,omitempty" yaml:"aliases,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"`
}
// Member is a member of a group, and can either reference a Section or another Group, hence the two fields.
type Member struct {
Group string `json:"group,omitempty", yaml:"group,omitempty"`
Section string `json:"section,omitempty", yaml:"section,omitempty"`
}
type Group struct {
Group string `json:"group" yaml:"group" example:"messaging_providers"`
Name string `json:"name" yaml:"name" example:"Messaging Providers"`
Description string `json:"description" yaml:"description" example:"Options for setting up messaging providers."`
Members []Member `json:"members" yaml:"members"`
}
type Config struct {
Sections []Section `json:"sections" yaml:"sections"`
Groups []Group `json:"groups" yaml:"groups"`
// Optional order, which can interleave sections and groups.
// If unset, falls back to sections in order, then groups in order.
Order []Member `json:"order,omitempty" yaml:"order,omitempty"`
}
func (c *Config) removeSection(section string) {
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

547
config.go
View File

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

View File

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

1
config/README.txt Normal file
View File

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

File diff suppressed because it is too large Load Diff

1763
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

@@ -15,6 +15,11 @@
--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 {
@@ -23,14 +28,15 @@
.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: #fff;
background-color: var(--bg-light);
}
.dark select, .dark option, .dark input {
@@ -59,21 +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) {
:root {
font-size: 0.9rem;
}
@media screen and (max-width: 1024px) {
.table-responsive table {
min-width: 800px;
}
@@ -103,48 +95,6 @@ div.card:contains(section.banner.footer) {
padding-bottom: 0px;
}
.tab-button {
font-size: 2rem;
}
.al {
text-align: left;
}
.ar {
text-align: right;
}
.ac {
text-align: center;
}
.w-100 {
width: 100%;
}
.h-100 {
height: 100%;
}
.inline-block {
display: inline-block;
}
.align-top {
align-items: top;
}
.flex-expand {
display: flex;
justify-content: space-between;
}
.flex-row-group {
display: block;
flex-grow: 1;
}
.row {
display: flex;
flex-wrap: wrap;
@@ -169,23 +119,7 @@ span.sm:not(.heading) {
margin: .25rem;
}
.flex-col {
display: flex;
flex-direction: column;
}
.flex-form {
display: flex;
flex-direction: column;
}
@media screen and (min-width: 768px) {
.flex-form {
flex: 1;
margin: 0.5rem;
}
}
/* Who knows for half of these to be honest */
@media screen and (max-width: 400px) {
.row {
flex-direction: column;
@@ -216,69 +150,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 */
}
@@ -297,10 +168,6 @@ sup.\~critical, .text-critical {
width: 100%;
}
.flex-auto {
flex: auto;
}
.center {
justify-content: center;
}
@@ -309,14 +176,6 @@ sup.\~critical, .text-critical {
align-items: center;
}
.no-lp {
padding-left: 0px;
}
.block {
display: block;
}
.focused {
display: block;
}
@@ -345,9 +204,9 @@ sup.\~critical, .text-critical {
font-size: 1rem;
padding-top: 0.1rem;
padding-bottom: 0.1rem;
margin-left: 0.5rem;
margin-right: 1rem;
max-width: 75%;
margin-inline-start: 0.5rem;
margin-inline-end: 1rem;
width: 5rem;;
}
.stealth-input-hidden {
@@ -359,15 +218,8 @@ sup.\~critical, .text-critical {
padding-bottom: 0.1rem;
}
.settings-section-button {
width: 100%;
height: 2.5rem;
}
.settings-section-button:hover, .settings-section-button:focus {
box-sizing: border-box;
width: 100%;
height: 2.5rem;
background-color: var(--color-neutral-normal-fill);
filter: brightness(var(--settings-section-button-filter)) !important;
}
@@ -380,7 +232,7 @@ sup.\~critical, .text-critical {
margin-bottom: 0.25rem;
}
.textarea {
.textarea:not(code-input *) {
resize: vertical;
}
@@ -392,7 +244,7 @@ sup.\~critical, .text-critical {
overflow-y: visible;
}
select, textarea {
select, textarea:not(code-input *) {
color: inherit;
border: 0 solid var(--color-neutral-300);
appearance: none;
@@ -400,7 +252,7 @@ select, textarea {
-moz-appearance: none;
}
html.dark textarea {
html.dark textarea:not(code-input *) {
background-color: #202020
}
@@ -413,17 +265,25 @@ 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;
}
td:dir(rtl), th:dir(rtl) {
text-align: right;
}
p.top {
margin-top: 0px;
}
.table-responsive {
overflow-x: auto;
font-size: 0.9rem;
}
#notification-box {
position: fixed;
right: 1rem;
@@ -449,7 +309,7 @@ p.top {
bottom: 115%;
}
pre {
pre:not(code-input *) {
white-space: pre-wrap; /* css-3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */
@@ -572,7 +432,6 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
cursor: pointer;
}
.g-recaptcha {
overflow: hidden;
width: 296px;
@@ -584,3 +443,52 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
.g-recaptcha iframe {
margin: -2px 0px 0px -4px;
}
.dropdown-manual-toggle {
margin-bottom: -0.5rem;
padding-bottom: 0.5rem;
}
section.section:not(.\~neutral) {
background-color: inherit;
}
@layer components {
.switch {
@apply flex flex-row gap-2 items-center;
}
}
:root {
/* seems to be the sweet spot */
--inside-input-base: -2.1rem;
/* 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-inline-start: calc(var(--inside-input-base) - 1.0*var(--tailwind-spacing));
}
.gap-2 > .button.inside-input {
margin-inline-start: calc(var(--inside-input-base) - 2.0*var(--tailwind-spacing));
}
.force-ltr {
direction: ltr !important;
}
.content ul, .content ol {
margin-left: unset;
margin-inline-start: 2rem;
}
.content li {
text-align: start;
}
.input {
font-size: 16px !important;
}

18
css/colors.js Normal file
View File

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

View File

@@ -22,7 +22,7 @@
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+ *
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 */

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,8 +10,26 @@
background-color: rgba(0,0,0,40%);
}
.wall {
position: fixed;
z-index: 11;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: auto;
}
html.dark .wall {
background-color: var(--bg-dark);
}
html:not(.dark) .wall {
background-color: var(--bg-light);
}
.modal-close {
float: right;
float: inline-end;
color: #aaa;
font-weight: normal;
}

View File

@@ -5,7 +5,8 @@
.tooltip .content {
visibility: hidden;
max-width: 10rem;
opacity: 0;
max-width: 16rem;
min-width: 6rem;
background-color: rgba(0, 0, 0, 0.6);
color: #fff;
@@ -13,24 +14,58 @@
border-radius: 6px;
overflow-wrap: break-word;
text-align: center;
transition: opacity 100ms;
position: absolute;
z-index: 1;
top: -1rem;
}
.tooltip.below .content {
top: calc(100% + 0.125rem);
left: 50%;
right: 0;
transform: translateX(-50%);
}
.tooltip.above .content {
top: unset;
bottom: calc(100% + 0.125rem);
left: 50%;
right: 0;
transform: translateX(-50%);
}
.tooltip.darker .content {
background-color: rgba(0, 0, 0, 0.8);
}
.tooltip.right .content {
left: 120%;
}
.tooltip.right:dir(rtl):not(.force-ltr) .content {
right: 120%;
left: unset;
}
.tooltip.left .content {
right: 120%;
}
.tooltip.left:dir(rtl):not(.force-ltr) .content {
left: 120%;
right: unset;
}
.tooltip .content.sm {
font-size: 0.8rem;
}
.tooltip:hover .content {
.tooltip:hover .content,
.tooltip:focus .content,
.tooltip:focus-within .content
{
visibility: visible;
opacity: 1;
}

427
customcontent.go Normal file
View File

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

135
daemon.go
View File

@@ -1,135 +0,0 @@
package main
import "time"
// 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("Housekeeping: removing unused email addresses")
emails := app.storage.GetEmails()
for _, email := range emails {
_, status, err := app.jf.UserByID(email.JellyfinID, false)
if status == 200 && err != nil {
continue
}
app.storage.DeleteEmailsKey(email.JellyfinID)
}
}
// clearDiscord does the same as clearEmails, but for Discord Users.
func (app *appContext) clearDiscord() {
app.debug.Println("Housekeeping: removing unused Discord IDs")
discordUsers := app.storage.GetDiscord()
for _, discordUser := range discordUsers {
_, status, err := app.jf.UserByID(discordUser.JellyfinID, false)
if status == 200 && err != nil {
continue
}
app.storage.DeleteDiscordKey(discordUser.JellyfinID)
}
}
// clearMatrix does the same as clearEmails, but for Matrix Users.
func (app *appContext) clearMatrix() {
app.debug.Println("Housekeeping: removing unused Matrix IDs")
matrixUsers := app.storage.GetMatrix()
for _, matrixUser := range matrixUsers {
_, status, err := app.jf.UserByID(matrixUser.JellyfinID, false)
if status == 200 && err != nil {
continue
}
app.storage.DeleteMatrixKey(matrixUser.JellyfinID)
}
}
// clearTelegram does the same as clearEmails, but for Telegram Users.
func (app *appContext) clearTelegram() {
app.debug.Println("Housekeeping: removing unused Telegram IDs")
telegramUsers := app.storage.GetTelegram()
for _, telegramUser := range telegramUsers {
_, status, err := app.jf.UserByID(telegramUser.JellyfinID, false)
if status == 200 && err != nil {
continue
}
app.storage.DeleteTelegramKey(telegramUser.JellyfinID)
}
}
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
type housekeepingDaemon struct {
Stopped bool
ShutdownChannel chan string
Interval time.Duration
period time.Duration
jobs []func(app *appContext)
app *appContext
}
func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemon {
daemon := housekeepingDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
}
daemon.jobs = []func(app *appContext){func(app *appContext) {
app.debug.Println("Housekeeping: Checking for expired invites")
app.checkInvites()
}}
clearEmail := app.config.Section("email").Key("require_unique").MustBool(false)
clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false)
clearTelegram := app.config.Section("telegram").Key("require_unique").MustBool(false)
clearMatrix := app.config.Section("matrix").Key("require_unique").MustBool(false)
if clearEmail || clearDiscord || clearTelegram || clearMatrix {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.jf.CacheExpiry = time.Now() })
}
if clearEmail {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearEmails() })
}
if clearDiscord {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearDiscord() })
}
if clearTelegram {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearTelegram() })
}
if clearMatrix {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearMatrix() })
}
return &daemon
}
func (rt *housekeepingDaemon) run() {
rt.app.info.Println("Invite daemon started")
for {
select {
case <-rt.ShutdownChannel:
rt.ShutdownChannel <- "Down"
return
case <-time.After(rt.period):
break
}
started := time.Now()
for _, job := range rt.jobs {
job(rt.app)
}
finished := time.Now()
duration := finished.Sub(started)
rt.period = rt.Interval - duration
}
}
func (rt *housekeepingDaemon) Shutdown() {
rt.Stopped = true
rt.ShutdownChannel <- "Down"
<-rt.ShutdownChannel
close(rt.ShutdownChannel)
}

View File

@@ -1,29 +1,46 @@
package main
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
dg "github.com/bwmarrin/discordgo"
"github.com/hrfee/jfa-go/common"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/timshannon/badgerhold/v4"
)
type DiscordDaemon struct {
Stopped bool
ShutdownChannel chan string
bot *dg.Session
username string
tokens map[string]VerifToken // Map of pins to tokens.
verifiedTokens map[string]DiscordUser // Map of token pins to discord users.
channelID, channelName, inviteChannelID, inviteChannelName string
guildID string
serverChannelName, serverName string
users map[string]DiscordUser // Map of user IDs to users. Added to on first interaction, and loaded from app.storage.discord on start.
roleID string
app *appContext
commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string)
commandIDs []string
Stopped bool
ShutdownChannel chan string
bot *dg.Session
username string
tokens map[string]VerifToken // Map of pins to tokens.
verifiedTokens map[string]DiscordUser // Map of token pins to discord users.
Channel, InviteChannel struct{ ID, Name string }
guildID string
serverChannelName, serverName string
users map[string]DiscordUser // Map of user IDs to users. Added to on first interaction, and loaded from app.storage.discord on start.
roleID string
app *appContext
commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string)
commandIDs []string
commandDescriptions []*dg.ApplicationCommand
retryOpts *common.MustAuthenticateOptions
}
func EmptyDiscordUser() *DiscordUser {
return &DiscordUser{
ID: "",
Username: "",
Discriminator: "",
Lang: "",
Contact: false,
JellyfinID: "",
}
}
func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
@@ -50,13 +67,29 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
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
}
dd.retryOpts = &common.MustAuthenticateOptions{
RetryCount: app.config.Section("advanced").Key("auth_retry_count").MustInt(6),
RetryGap: time.Duration(app.config.Section("advanced").Key("auth_retry_gap").MustInt(10)) * time.Second,
LogFailures: true,
}
dd.bot.AddHandler(dd.commandHandler)
dd.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers | dg.IntentsGuildInvites
return dd, nil
}
// SetTransport sets the http.Transport to use for requests. Can be used to set a proxy.
func (d *DiscordDaemon) SetTransport(t *http.Transport) {
d.bot.Client.Transport = t
}
// NewAuthToken generates an 8-character pin in the form "A1-2B-CD".
func (d *DiscordDaemon) NewAuthToken() string {
pin := genAuthToken()
@@ -89,15 +122,27 @@ func (d *DiscordDaemon) MustGetUser(channelID, userID, discrim, username string)
return d.NewUnknownUser(channelID, userID, discrim, username)
}
func (d *DiscordDaemon) run() {
d.bot.AddHandler(d.messageHandler)
func (d *DiscordDaemon) Run() {
ro := common.MustAuthenticateOptions{}
ro = *d.retryOpts
ro.Counter = 0
d.run(&ro)
}
d.bot.AddHandler(d.commandHandler)
d.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers | dg.IntentsGuildInvites
func (d *DiscordDaemon) run(retry *common.MustAuthenticateOptions) {
if err := d.bot.Open(); err != nil {
d.app.err.Printf("Discord: Failed to start daemon: %v", err)
return
if retry == nil || retry.LogFailures {
d.app.err.Printf(lm.FailedStartDaemon, lm.Discord, err)
}
if retry != nil {
retry.Counter += 1
if retry.Counter >= retry.RetryCount {
return
}
time.Sleep(retry.RetryGap)
d.run(retry)
return
}
}
// Wait for everything to populate, it's slow sometimes.
for d.bot.State == nil {
@@ -114,27 +159,31 @@ func (d *DiscordDaemon) run() {
d.guildID = d.bot.State.Guilds[len(d.bot.State.Guilds)-1].ID
guild, err := d.bot.Guild(d.guildID)
if err != nil {
d.app.err.Printf("Discord: Failed to get guild: %v", err)
d.app.err.Printf(lm.FailedGetDiscordGuild, err)
}
d.serverChannelName = guild.Name
d.serverName = guild.Name
if channel := d.app.config.Section("discord").Key("channel").String(); channel != "" {
d.channelName = channel
d.Channel.Name = channel
d.serverChannelName += "/" + channel
}
if d.app.config.Section("discord").Key("provide_invite").MustBool(false) {
if invChannel := d.app.config.Section("discord").Key("invite_channel").String(); invChannel != "" {
d.inviteChannelName = invChannel
d.InviteChannel.Name = invChannel
}
}
d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start"))
defer d.deregisterCommands()
defer d.bot.Close()
go d.registerCommands()
ro := common.MustAuthenticateOptions{}
ro = *(d.retryOpts)
ro.Counter = 0
go d.registerCommands(&ro)
<-d.ShutdownChannel
d.ShutdownChannel <- "Down"
return
}
// ListRoles returns a list of available (excluding bot and @everyone) roles in a guild as a list of containing an array of the guild ID and its name.
@@ -142,7 +191,7 @@ func (d *DiscordDaemon) ListRoles() (roles [][2]string, err error) {
var r []*dg.Role
r, err = d.bot.GuildRoles(d.guildID)
if err != nil {
d.app.err.Printf("Discord: Failed to get roles: %v", err)
d.app.err.Printf(lm.FailedGetDiscordRoles, err)
return
}
for _, role := range r {
@@ -165,44 +214,62 @@ func (d *DiscordDaemon) ApplyRole(userID string) error {
return d.bot.GuildMemberRoleAdd(d.guildID, userID, d.roleID)
}
// RemoveRole removes the member role to the given user if set.
func (d *DiscordDaemon) RemoveRole(userID string) error {
if d.roleID == "" {
return nil
}
return d.bot.GuildMemberRoleRemove(d.guildID, userID, d.roleID)
}
// SetRoleDisabled removes the role if "disabled", and applies if "!disabled".
func (d *DiscordDaemon) SetRoleDisabled(userID string, disabled bool) (err error) {
if disabled {
err = d.RemoveRole(userID)
} else {
err = d.ApplyRole(userID)
}
return
}
// NewTempInvite creates an invite link, and returns the invite URL, as well as the URL for the server icon.
func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconURL string) {
var inv *dg.Invite
var err error
if d.inviteChannelName == "" {
d.app.err.Println("Discord: Cannot create invite without channel specified in settings.")
if d.InviteChannel.Name == "" {
d.app.err.Printf(lm.FailedCreateDiscordInviteChannel, lm.InviteChannelEmpty)
return
}
if d.inviteChannelID == "" {
if d.InviteChannel.ID == "" {
channels, err := d.bot.GuildChannels(d.guildID)
if err != nil {
d.app.err.Printf("Discord: Couldn't get channel list: %v", err)
d.app.err.Printf(lm.FailedGetDiscordChannels, err)
return
}
found := false
for _, channel := range channels {
// channel, err := d.bot.Channel(ch.ID)
// if err != nil {
// d.app.err.Printf("Discord: Couldn't get channel: %v", err)
// d.app.err.Printf(lm.FailedGetDiscordChannel, ch.ID, err)
// return
// }
if channel.Name == d.inviteChannelName {
d.inviteChannelID = channel.ID
if channel.Name == d.InviteChannel.Name {
d.InviteChannel.ID = channel.ID
found = true
break
}
}
if !found {
d.app.err.Printf("Discord: Couldn't find invite channel \"%s\"", d.inviteChannelName)
d.app.err.Printf(lm.FailedGetDiscordChannel, d.InviteChannel.Name, lm.NotFound)
return
}
}
// channel, err := d.bot.Channel(d.inviteChannelID)
// if err != nil {
// d.app.err.Printf("Discord: Couldn't get invite channel: %v", err)
// d.app.err.Printf(lm.FailedGetDiscordChannel, d.inviteChannelID, err)
// return
// }
inv, err = d.bot.ChannelInviteCreate(d.inviteChannelID, dg.Invite{
inv, err = d.bot.ChannelInviteCreate(d.InviteChannel.ID, dg.Invite{
// Guild: d.bot.State.Guilds[len(d.bot.State.Guilds)-1],
// Channel: channel,
// Inviter: d.bot.State.User,
@@ -211,18 +278,16 @@ 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
}
// FIXME: Fix CSS, and handle no icon
iconURL = guild.IconURL("256")
fmt.Println("GOT ICON", iconURL)
return
}
@@ -254,7 +319,7 @@ func (d *DiscordDaemon) GetUsers(username string) []*dg.Member {
1000,
)
if err != nil {
d.app.err.Printf("Discord: Failed to get members: %v", err)
d.app.err.Printf(lm.FailedGetDiscordGuildMembers, err)
return nil
}
hasDiscriminator := strings.Contains(username, "#")
@@ -284,7 +349,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
@@ -293,7 +358,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
@@ -308,8 +373,8 @@ func (d *DiscordDaemon) Shutdown() {
close(d.ShutdownChannel)
}
func (d *DiscordDaemon) registerCommands() {
commands := []*dg.ApplicationCommand{
func (d *DiscordDaemon) registerCommands(retry *common.MustAuthenticateOptions) {
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.",
@@ -339,64 +404,152 @@ func (d *DiscordDaemon) registerCommands() {
},
},
},
{
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,
},
},
},
}
commands[1].Options[0].Choices = make([]*dg.ApplicationCommandOptionChoice, len(d.app.storage.lang.Telegram))
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("Registering choice \"%s\":\"%s\"\n", d.app.storage.lang.Telegram[code].Meta.Name, code)
commands[1].Options[0].Choices[i] = &dg.ApplicationCommandOptionChoice{
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(commands))
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 commands {
cCommands, err := d.bot.ApplicationCommandBulkOverwrite(d.bot.State.User.ID, d.guildID, d.commandDescriptions)
if err != nil {
if retry == nil || retry.LogFailures {
d.app.err.Printf(lm.FailedRegisterDiscordCommand, "*", err)
}
if retry != nil {
retry.Counter += 1
if retry.Counter >= retry.RetryCount {
return
}
time.Sleep(retry.RetryGap)
d.registerCommands(retry)
}
} else {
for i := range len(d.commandDescriptions) {
d.commandIDs[i] = cCommands[i].ID
}
d.app.debug.Printf(lm.RegisterDiscordCommand, "*")
}
/* for i, cmd := range d.commandDescriptions {
command, err := d.bot.ApplicationCommandCreate(d.bot.State.User.ID, d.guildID, cmd)
if err != nil {
d.app.err.Printf("Discord: Cannot create command \"%s\": %v", cmd.Name, err)
d.app.err.Printf(lm.FailedRegisterDiscordCommand, cmd.Name, err)
} else {
d.app.debug.Printf("Discord: registered command \"%s\"", cmd.Name)
d.app.debug.Printf(lm.RegisterDiscordCommand, cmd.Name)
d.commandIDs[i] = command.ID
}
}
} */
}
func (d *DiscordDaemon) deregisterCommands() {
existingCommands, err := d.bot.ApplicationCommands(d.bot.State.User.ID, d.guildID)
if err != nil {
d.app.err.Printf("Discord: Failed to get commands: %v", err)
d.app.err.Printf(lm.FailedGetDiscordCommands, err)
return
}
for _, cmd := range existingCommands {
if err := d.bot.ApplicationCommandDelete(d.bot.State.User.ID, "", cmd.ID); err != nil {
d.app.err.Printf("Failed to deregister command: %v", err)
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.channelName != "" {
if d.channelID == "" {
if i.GuildID != "" && d.Channel.Name != "" {
if d.Channel.ID == "" {
channel, err := s.Channel(i.ChannelID)
if err != nil {
d.app.err.Printf("Discord: Couldn't get channel, will monitor all: %v", err)
d.channelName = ""
d.app.err.Printf(lm.FailedGetDiscordChannel, i.ChannelID, err)
d.app.err.Println(lm.MonitorAllDiscordChannels)
d.Channel.Name = ""
}
if channel.Name == d.channelName {
d.channelID = channel.ID
if channel.Name == d.Channel.Name {
d.Channel.ID = channel.ID
}
}
if d.channelID != i.ChannelID {
d.app.debug.Printf("Discord: Ignoring message as not in specified channel")
if d.Channel.ID != i.ChannelID {
d.app.debug.Printf(lm.IgnoreOutOfChannelMessage, lm.Discord)
return
}
}
@@ -418,7 +571,7 @@ func (d *DiscordDaemon) commandHandler(s *dg.Session, i *dg.InteractionCreate) {
func (d *DiscordDaemon) cmdStart(s *dg.Session, i *dg.InteractionCreate, lang string) {
channel, err := s.UserChannelCreate(i.Interaction.Member.User.ID)
if err != nil {
d.app.err.Printf("Discord: Failed to create private channel with \"%s\": %v", i.Interaction.Member.User.Username, err)
d.app.err.Printf(lm.FailedCreateDiscordDMChannel, i.Interaction.Member.User.ID, err)
return
}
user := d.MustGetUser(channel.ID, i.Interaction.Member.User.ID, i.Interaction.Member.User.Discriminator, i.Interaction.Member.User.Username)
@@ -435,7 +588,7 @@ func (d *DiscordDaemon) cmdStart(s *dg.Session, i *dg.InteractionCreate, lang st
},
})
if err != nil {
d.app.err.Printf("Discord: Failed to send reply: %v", err)
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
return
}
}
@@ -453,7 +606,7 @@ func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang stri
},
})
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", i.Interaction.Member.User.Username, err)
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
}
delete(d.tokens, pin)
return
@@ -467,7 +620,7 @@ func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang stri
},
})
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", i.Interaction.Member.User.Username, err)
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
}
dcUser := d.users[i.Interaction.Member.User.ID]
dcUser.JellyfinID = user.JellyfinID
@@ -498,144 +651,129 @@ func (d *DiscordDaemon) cmdLang(s *dg.Session, i *dg.InteractionCreate, lang str
},
})
if err != nil {
d.app.err.Printf("Discord: Failed to send reply: %v", err)
d.app.err.Printf(lm.FailedReply, lm.Discord, i.Interaction.Member.User.ID, err)
return
}
}
}
func (d *DiscordDaemon) messageHandler(s *dg.Session, m *dg.MessageCreate) {
if m.GuildID != "" && d.channelName != "" {
if d.channelID == "" {
channel, err := s.Channel(m.ChannelID)
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(lm.FailedCreateDiscordDMChannel, i.Interaction.Member.User.ID, err)
return
}
requester := d.MustGetUser(channel.ID, i.Interaction.Member.User.ID, i.Interaction.Member.User.Discriminator, i.Interaction.Member.User.Username)
d.users[i.Interaction.Member.User.ID] = requester
recipient := i.ApplicationCommandData().Options[0].UserValue(s)
// We don't reveal much in the message response itself so we can re-use this easily.
sendResponse := func(langKey string) {
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
Type: dg.InteractionResponseChannelMessageWithSource,
Data: &dg.InteractionResponseData{
Content: d.app.storage.lang.Telegram[lang].Strings.get(langKey),
Flags: 64, // Ephemeral
},
})
if err != nil {
d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
}
}
// d.app.debug.Println(invuser)
//label := i.ApplicationCommandData().Options[2].StringValue()
//profile := i.ApplicationCommandData().Options[3].StringValue()
//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))
sendResponse("noPermission")
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 {
err = nil
var invname *dg.Member = nil
invname, err = d.bot.GuildMember(d.guildID, recipient.ID)
if err == nil && !(d.app.config.Section("invite_emails").Key("enabled").MustBool(false)) {
err = errors.New(lm.InviteMessagesDisabled)
}
var msg *Message
if err == nil {
msg, err = d.app.email.constructInvite(&invite, false)
if err != nil {
d.app.err.Printf("Discord: Couldn't get channel, will monitor all: %v", err)
d.channelName = ""
}
if channel.Name == d.channelName {
d.channelID = channel.ID
// Print extra message, ideally we'd just print this, or get rid of it though.
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
Address: invname.User.Username,
Reason: CheckLogs,
})
d.app.err.Printf(lm.FailedConstructInviteMessage, invite.Code, err)
}
}
if d.channelID != m.ChannelID {
d.app.debug.Printf("Discord: Ignoring message as not in specified channel")
return
if err == nil {
err = d.app.discord.SendDM(msg, recipient.ID)
}
}
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
if err == nil {
d.app.info.Printf(lm.SentInviteMessage, invite.Code, RenderDiscordUsername(recipient))
invite.SentTo.Success = append(invite.SentTo.Success, invname.User.Username)
sendResponse("sentInvite")
}
}
switch msg := sects[0]; msg {
case "!" + d.app.config.Section("discord").Key("start_command").MustString("start"):
d.msgStart(s, m, lang)
case "!lang":
d.msgLang(s, m, sects, lang)
default:
d.msgPIN(s, m, sects, lang)
}
}
func (d *DiscordDaemon) msgStart(s *dg.Session, m *dg.MessageCreate, lang string) {
channel, err := s.UserChannelCreate(m.Author.ID)
if err != nil {
d.app.err.Printf("Discord: Failed to create private channel with \"%s\": %v", m.Author.Username, err)
return
}
user := d.MustGetUser(channel.ID, m.Author.ID, m.Author.Discriminator, m.Author.Username)
d.users[m.Author.ID] = user
_, err = d.bot.ChannelMessageSendReply(m.ChannelID, d.app.storage.lang.Telegram[lang].Strings.get("discordDMs"), m.Reference())
if err != nil {
d.app.err.Printf("Discord: Failed to send reply to \"%s\": %v", m.Author.Username, err)
return
}
content := d.app.storage.lang.Telegram[lang].Strings.get("startMessage") + "\n"
content += d.app.storage.lang.Telegram[lang].Strings.template("languageMessage", tmpl{"command": "!lang"})
_, err = s.ChannelMessageSend(channel.ID, content)
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
return
}
}
func (d *DiscordDaemon) msgLang(s *dg.Session, m *dg.MessageCreate, sects []string, lang string) {
if len(sects) == 1 {
list := "!lang <lang>\n"
for code := range d.app.storage.lang.Telegram {
list += fmt.Sprintf("%s: %s\n", code, d.app.storage.lang.Telegram[code].Meta.Name)
}
_, err := s.ChannelMessageSendReply(
m.ChannelID,
list,
m.Reference(),
)
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, RenderDiscordUsername(recipient), err)
sendResponse("sentInviteFailure")
}
return
}
if _, ok := d.app.storage.lang.Telegram[sects[1]]; ok {
var user DiscordUser
for _, u := range d.app.storage.GetDiscord() {
if u.ID == m.Author.ID {
u.Lang = sects[1]
d.app.storage.SetDiscordKey(u.JellyfinID, u)
user = u
break
}
}
d.users[m.Author.ID] = user
}
}
func (d *DiscordDaemon) msgPIN(s *dg.Session, m *dg.MessageCreate, sects []string, lang string) {
if _, ok := d.users[m.Author.ID]; ok {
channel, err := s.Channel(m.ChannelID)
if err != nil {
d.app.err.Printf("Discord: Failed to get channel: %v", err)
return
}
if channel.Type != dg.ChannelTypeDM {
d.app.debug.Println("Discord: Ignoring message as not a DM")
return
}
} else {
d.app.debug.Println("Discord: Ignoring message as user was not found")
return
}
user, ok := d.tokens[sects[0]]
if !ok || time.Now().After(user.Expiry) {
_, err := s.ChannelMessageSend(
m.ChannelID,
d.app.storage.lang.Telegram[lang].Strings.get("invalidPIN"),
)
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
}
delete(d.tokens, sects[0])
return
}
_, err := s.ChannelMessageSend(
m.ChannelID,
d.app.storage.lang.Telegram[lang].Strings.get("pinSuccess"),
)
if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
}
dcUser := d.users[m.Author.ID]
dcUser.JellyfinID = user.JellyfinID
d.verifiedTokens[sects[0]] = dcUser
delete(d.tokens, sects[0])
//if profile != "" {
d.app.storage.SetInvitesKey(invite.Code, invite)
}
func (d *DiscordDaemon) SendDM(message *Message, userID ...string) error {
@@ -691,10 +829,10 @@ func (d *DiscordDaemon) Send(message *Message, channelID ...string) error {
}
// UserVerified returns whether or not a token with the given PIN has been verified, and the user itself.
func (d *DiscordDaemon) UserVerified(pin string) (user DiscordUser, ok bool) {
user, ok = d.verifiedTokens[pin]
func (d *DiscordDaemon) UserVerified(pin string) (ContactMethodUser, bool) {
u, ok := d.verifiedTokens[pin]
// delete(d.verifiedTokens, pin)
return
return &u, ok
}
// AssignedUserVerified returns whether or not a user with the given PIN has been verified, and the token itself.
@@ -714,7 +852,44 @@ func (d *DiscordDaemon) UserExists(id string) bool {
return err != nil || c > 0
}
// DeleteVerifiedUser removes the token with the given PIN.
func (d *DiscordDaemon) DeleteVerifiedUser(pin string) {
delete(d.verifiedTokens, pin)
// Exists returns whether or not the given user exists.
func (d *DiscordDaemon) Exists(user ContactMethodUser) bool {
return d.UserExists(user.MethodID().(string))
}
// DeleteVerifiedToken removes the token with the given PIN.
func (d *DiscordDaemon) DeleteVerifiedToken(PIN string) {
delete(d.verifiedTokens, PIN)
}
func (d *DiscordDaemon) PIN(req newUserDTO) string { return req.DiscordPIN }
func (d *DiscordDaemon) Name() string { return lm.Discord }
func (d *DiscordDaemon) Required() bool {
return d.app.config.Section("discord").Key("required").MustBool(false)
}
func (d *DiscordDaemon) UniqueRequired() bool {
return d.app.config.Section("discord").Key("require_unique").MustBool(false)
}
func (d *DiscordDaemon) PostVerificationTasks(PIN string, u ContactMethodUser) error {
err := d.ApplyRole(u.MethodID().(string))
if err != nil {
return fmt.Errorf(lm.FailedSetDiscordMemberRole, err)
}
return err
}
func (d *DiscordUser) Name() string { return RenderDiscordUsername(*d) }
func (d *DiscordUser) SetMethodID(id any) { d.ID = id.(string) }
func (d *DiscordUser) MethodID() any { return d.ID }
func (d *DiscordUser) SetJellyfin(id string) { d.JellyfinID = id }
func (d *DiscordUser) Jellyfin() string { return d.JellyfinID }
func (d *DiscordUser) SetAllowContactFromDTO(req newUserDTO) { d.Contact = req.DiscordContact }
func (d *DiscordUser) SetAllowContact(contact bool) { d.Contact = contact }
func (d *DiscordUser) AllowContact() bool { return d.Contact }
func (d *DiscordUser) Store(st *Storage) {
st.SetDiscordKey(d.Jellyfin(), *d)
}

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.24.0
require golang.org/x/net v0.47.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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=

870
email.go

File diff suppressed because it is too large Load Diff

491
email_test.go Normal file
View File

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

View File

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

29
fs.go Normal file
View File

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

77
generic-d.go Normal file
View File

@@ -0,0 +1,77 @@
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
TriggerChannel chan bool
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),
TriggerChannel: make(chan bool),
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 <-d.TriggerChannel:
break
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) Trigger() {
d.TriggerChannel <- true
}
func (d *GenericDaemon) Shutdown() {
d.Stopped = true
d.ShutdownChannel <- "Down"
<-d.ShutdownChannel
close(d.ShutdownChannel)
}

189
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/hrfee/jfa-go
go 1.20
go 1.24.0
replace github.com/hrfee/jfa-go/docs => ./docs
@@ -10,120 +10,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
// replace github.com/hrfee/mediabrowser => ../mediabrowser
require (
github.com/bwmarrin/discordgo v0.27.1
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a
github.com/fatih/color v1.15.0
github.com/fsnotify/fsnotify v1.6.0
github.com/getlantern/systray v1.2.2
github.com/gin-contrib/pprof v1.4.0
github.com/gin-contrib/static v0.0.1
github.com/gin-gonic/gin v1.9.1
github.com/bwmarrin/discordgo v0.29.0
github.com/dgraph-io/badger/v4 v4.8.0
github.com/emersion/go-autostart v0.0.0-20250403115856-34830d6457d2
github.com/fatih/color v1.18.0
github.com/fsnotify/fsnotify v1.9.0
github.com/gin-contrib/pprof v1.5.3
github.com/gin-gonic/gin v1.11.0
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
github.com/goccy/go-yaml v1.18.0
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a
github.com/hrfee/jfa-go/common v0.0.0-20230421170108-d800b97f69b6
github.com/hrfee/jfa-go/docs v0.0.0-20230421170108-d800b97f69b6
github.com/hrfee/jfa-go/linecache v0.0.0-20230421170108-d800b97f69b6
github.com/hrfee/jfa-go/logger v0.0.0-20230421170108-d800b97f69b6
github.com/hrfee/jfa-go/ombi v0.0.0-20230421170108-d800b97f69b6
github.com/hrfee/mediabrowser v0.3.8
github.com/itchyny/timefmt-go v0.1.5
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a
github.com/hrfee/jfa-go/common v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/docs v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/easyproxy v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/jellyseerr v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/linecache v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/logger v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/logmessages v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/ombi v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/mediabrowser v0.3.35
github.com/hrfee/simple-template v1.1.0
github.com/itchyny/timefmt-go v0.1.7
github.com/lithammer/shortuuid/v3 v3.0.7
github.com/mailgun/mailgun-go/v4 v4.9.0
github.com/lutischan-ferenc/systray v1.2.1
github.com/mailgun/mailgun-go/v4 v4.23.0
github.com/mattn/go-sqlite3 v1.14.32
github.com/robert-nix/ansihtml v1.0.1
github.com/steambap/captcha v1.4.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/gin-swagger v1.6.1
github.com/timshannon/badgerhold/v4 v4.0.3
github.com/writeas/go-strip-markdown v2.0.1+incompatible
github.com/xhit/go-simple-mail/v2 v2.13.0
github.com/xhit/go-simple-mail/v2 v2.16.0
gopkg.in/ini.v1 v1.67.0
maunium.net/go/mautrix v0.15.2
maunium.net/go/mautrix v0.26.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.1.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dgraph-io/badger/v3 v3.2103.1 // indirect
github.com/dgraph-io/ristretto v0.1.0 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect
github.com/getlantern/errors v1.0.3 // indirect
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 // indirect
github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc // indirect
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 // indirect
github.com/getlantern/ops v0.0.0-20230519221840-1283e026181c // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.2.4 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-chi/chi/v5 v5.2.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/spec v0.20.9 // indirect
github.com/go-openapi/swag v0.22.4 // indirect
github.com/go-openapi/jsonpointer v0.22.3 // indirect
github.com/go-openapi/jsonreference v0.21.3 // indirect
github.com/go-openapi/spec v0.22.1 // indirect
github.com/go-openapi/swag/conv v0.25.3 // indirect
github.com/go-openapi/swag/jsonname v0.25.3 // indirect
github.com/go-openapi/swag/jsonutils v0.25.3 // indirect
github.com/go-openapi/swag/loading v0.25.3 // indirect
github.com/go-openapi/swag/stringutils v0.25.3 // indirect
github.com/go-openapi/swag/typeutils v0.25.3 // indirect
github.com/go-openapi/swag/yamlutils v0.25.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.1 // indirect
github.com/go-stack/stack v1.8.1 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/go-test/deep v1.1.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/godbus/dbus/v5 v5.2.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/glog v0.0.0-20210429001901-424d2337a529 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/flatbuffers v2.0.0+incompatible // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/mux v1.8.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/google/flatbuffers v25.9.23+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.13.1 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b // indirect
github.com/mailgun/errors v0.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rs/zerolog v1.29.1 // indirect
github.com/swaggo/swag v1.16.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.0 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/swaggo/swag v1.16.6 // indirect
github.com/technoweenie/multipartstreamer v1.0.1 // indirect
github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/timshannon/badgerhold/v4 v4.0.2 // indirect
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
go.opencensus.io v0.23.0 // indirect
go.opentelemetry.io/otel v1.16.0 // indirect
go.opentelemetry.io/otel/metric v1.16.0 // indirect
go.opentelemetry.io/otel/trace v1.16.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect
golang.org/x/image v0.7.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
golang.org/x/tools v0.9.3 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/maulogger/v2 v2.4.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mau.fi/util v0.9.3 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/image v0.33.0 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/tools v0.39.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

472
go.sum
View File

@@ -1,30 +1,33 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY=
github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/cespare/xxhash/v2 v2.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.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
@@ -32,149 +35,129 @@ github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v3 v3.2103.1 h1:zaX53IRg7ycxVlkd5pYdCeFp1FynD6qBGQoQql3R3Hk=
github.com/dgraph-io/badger/v3 v3.2103.1/go.mod h1:dULbq6ehJ5K0cGW/1TQ9iSfUk0gbSiToDWmWmTsJ53E=
github.com/dgraph-io/ristretto v0.1.0 h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/LuerPI=
github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug=
github.com/dgraph-io/badger/v4 v4.1.0/go.mod h1:P50u28d39ibBRmIJuQC/NSdBOg46HnHw7al2SW5QRHg=
github.com/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs=
github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w=
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/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/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-20250403115856-34830d6457d2 h1:CgF8+TNFvlnxEbplSgS70ZI4IUFEzVkY+ICNqTVE/AM=
github.com/emersion/go-autostart v0.0.0-20250403115856-34830d6457d2/go.mod h1:buzQsO8HHkZX2Q45fdfGH1xejPjuDQaXH8btcYMFzPM=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 h1:0JZ+dUmQeA8IIVUMzysrX4/AKuQwWhV2dYQuPZdvdSQ=
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A=
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 h1:E2s37DuLxFhQDg5gKsWoLBOB0n+ZW8s599zru8FJ2/Y=
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 h1:oEZYEpZo28Wdx+5FZo4aU7JFXu0WG/4wJWese5reQSA=
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201/go.mod h1:Y9WZUHEb+mpra02CbQ/QczLUe6f0Dezxaw5DCJlJQGo=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/errors v1.0.1/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/errors v1.0.3 h1:Ne4Ycj7NI1BtSyAfVeAT/DNoxz7/S2BUc3L2Ht1YSHE=
github.com/getlantern/errors v1.0.3/go.mod h1:m8C7H1qmouvsGpwQqk/6NUpIVMpfzUPn608aBZDYV04=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 h1:NlQedYmPI3pRAXJb+hLVVDGqfvvXGRPV8vp7XOjKAZ0=
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65/go.mod h1:+ZU1h+iOVqWReBpky6d5Y2WL0sF2Llxu+QcxJFs2+OU=
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-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-20220713155959-1315d978fff7/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/ops v0.0.0-20230519221840-1283e026181c h1:qcPAzA1ZDnwx618jAgQmxo6UvJkw2SkM1L4ofncmEhI=
github.com/getlantern/ops v0.0.0-20230519221840-1283e026181c/go.mod h1:g2ueCncOwWenlAr56Fh90FwsACkelqqtFUDLAHg1mng=
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/gzip v0.0.1/go.mod h1:fGBJBCdt6qCZuCAOwWuFhBB4OOq9EFqlo5dEaFhhu5w=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/pprof v1.4.0 h1:XxiBSf5jWZ5i16lNOPbMTVdgHBdhfGRD5PZ1LWazzvg=
github.com/gin-contrib/pprof v1.4.0/go.mod h1:RrehPJasUVBPK6yTUwOl8/NP6i0vbUgmxtis+Z5KE90=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/pprof v1.5.3 h1:Bj5SxJ3kQDVez/s/+f9+meedJIqLS+xlkIVDe/lcvgM=
github.com/gin-contrib/pprof v1.5.3/go.mod h1:0+LQSZ4SLO0B6+2n6JBzaEygpTBxe/nI+YEYpfQQ6xY=
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-contrib/static v0.0.1 h1:JVxuvHPuUfkoul12N7dtQw7KRn/pSMq7Ue1Va9Swm1U=
github.com/gin-contrib/static v0.0.1/go.mod h1:CSxeF+wep05e0kCOsqWdAWbSszmc31zTIbD8TvWl7Hs=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y=
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M=
github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I=
github.com/go-openapi/jsonreference v0.19.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I=
github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc=
github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8=
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
github.com/go-openapi/spec v0.19.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI=
github.com/go-openapi/spec v0.19.4/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo=
github.com/go-openapi/spec v0.20.9 h1:xnlYNQAwKd2VQRRfwTEI0DcK+2cbuvI/0c7jx3gA8/8=
github.com/go-openapi/spec v0.20.9/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg=
github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU=
github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag/conv v0.25.3 h1:PcB18wwfba7MN5BVlBIV+VxvUUeC2kEuCEyJ2/t2X7E=
github.com/go-openapi/swag/conv v0.25.3/go.mod h1:n4Ibfwhn8NJnPXNRhBO5Cqb9ez7alBR40JS4rbASUPU=
github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A=
github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.3 h1:kV7wer79KXUM4Ea4tBdAVTU842Rg6tWstX3QbM4fGdw=
github.com/go-openapi/swag/jsonutils v0.25.3/go.mod h1:ILcKqe4HC1VEZmJx51cVuZQ6MF8QvdfXsQfiaCs0z9o=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3 h1:/i3E9hBujtXfHy91rjtwJ7Fgv5TuDHgnSrYjhFxwxOw=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3/go.mod h1:8kYfCR2rHyOj25HVvxL5Nm8wkfzggddgjZm6RgjT8Ao=
github.com/go-openapi/swag/loading v0.25.3 h1:Nn65Zlzf4854MY6Ft0JdNrtnHh2bdcS/tXckpSnOb2Y=
github.com/go-openapi/swag/loading v0.25.3/go.mod h1:xajJ5P4Ang+cwM5gKFrHBgkEDWfLcsAKepIuzTmOb/c=
github.com/go-openapi/swag/stringutils v0.25.3 h1:nAmWq1fUTWl/XiaEPwALjp/8BPZJun70iDHRNq/sH6w=
github.com/go-openapi/swag/stringutils v0.25.3/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/typeutils v0.25.3 h1:2w4mEEo7DQt3V4veWMZw0yTPQibiL3ri2fdDV4t2TQc=
github.com/go-openapi/swag/typeutils v0.25.3/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/yamlutils v0.25.3 h1:LKTJjCn/W1ZfMec0XDL4Vxh8kyAnv1orH5F2OREDUrg=
github.com/go-openapi/swag/yamlutils v0.25.3/go.mod h1:Y7QN6Wc5DOBXK14/xeo1cQlq0EA0wvLoSv13gDQoCao=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/go-playground/validator/v10 v10.14.1 h1:9c50NUPC30zyuKprjL3vNZ0m5oG+jU0zvx4AqHGnv4k=
github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-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-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-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/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8=
github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
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 v0.0.0-20210429001901-424d2337a529 h1:2voWjNECnrZRbfwXxHB1/j8wa6xdKn85B5NzgVL/pTU=
github.com/golang/glog v0.0.0-20210429001901-424d2337a529/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.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
@@ -183,16 +166,15 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a h1:AWZzzFrqyjYlRloN6edwTLTUbKxf5flLXNuTBDm3Ews=
github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/flatbuffers v1.12.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v2.0.0+incompatible h1:dicJ2oXwypfwUGnB2/TYWYEKiuk9eYQlQO/AnOHl5mI=
github.com/google/flatbuffers v2.0.0+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v23.5.9+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v25.9.23+incompatible h1:rGZKv+wOb6QPzIdkM2KxhBZCDrA0DeN6DNmRDrqIsQU=
github.com/google/flatbuffers v25.9.23+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -201,117 +183,113 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
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.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hrfee/mediabrowser v0.3.8 h1:y0iBCb6jE3QKcsiCJSYva2fFPHRn4UA+sGRzoPuJ/Dk=
github.com/hrfee/mediabrowser v0.3.8/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/hrfee/mediabrowser v0.3.33 h1:kjUFZc46hNhbOEU4xZNyhGVNjfZ5lENmX95Md1thxiA=
github.com/hrfee/mediabrowser v0.3.33/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/hrfee/mediabrowser v0.3.34 h1:AKnd1V9wt+KWZmHDjj1GMkCgcgcpBKxPw5iUcYgD6Tg=
github.com/hrfee/mediabrowser v0.3.34/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/hrfee/mediabrowser v0.3.35 h1:xEq4cL96Di0G+S3ONBH1HHeQJU6IfUMZiaeGeuJSFS8=
github.com/hrfee/mediabrowser v0.3.35/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/hrfee/simple-template v1.1.0 h1:PNQDTgc2H0s19/pWuhRh4bncuNJjPrW0fIX77YtY78M=
github.com/hrfee/simple-template v1.1.0/go.mod h1:s9a5QgfqbmT7j9WCC3GD5JuEqvihBEohyr+oYZmr4bA=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.13.1 h1:wXr2uRxZTJXHLly6qhJabee5JqIhTRoLBhDOA74hDEQ=
github.com/klauspost/compress v1.13.1/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/lutischan-ferenc/systray v1.2.1 h1:gPNrEpmg4hMwXyKNSlrkuuXqvxgqCYPjF5H/pG9I1+c=
github.com/lutischan-ferenc/systray v1.2.1/go.mod h1:YYaJ28AVuhMrlI5JfqrMsYMIl3Aa4Q02bpXXCl9caqo=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mailgun/mailgun-go/v4 v4.9.0 h1:wRbxvVQ5QObFewLxc1uVvipA16D8gxeiO+cBOca51Iw=
github.com/mailgun/mailgun-go/v4 v4.9.0/go.mod h1:FJlF9rI5cQT+mrwujtJjPMbIVy3Ebor9bKTVsJ0QU40=
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.23.0 h1:jPEMJzzin2s7lvehcfv/0UkyBu18GvcURPr2+xtZRbk=
github.com/mailgun/mailgun-go/v4 v4.23.0/go.mod h1:imTtizoFtpfZqPqGP8vltVBB6q9yWcv6llBhfFeElZU=
github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
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.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a h1:VweslR2akb/ARhXfqSfRbj1vpWwYXf3eeAUyw/ndms0=
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/robert-nix/ansihtml v1.0.1 h1:VTiyQ6/+AxSJoSSLsMecnkh8i0ZqOEdiRl/odOc64fc=
github.com/robert-nix/ansihtml v1.0.1/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yDXcNeHHKZINE=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc=
github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
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/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
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=
@@ -324,6 +302,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -332,111 +311,101 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.2.0/go.mod h1:qlH2+W7zXGZkczuL+r2nEBR2JTT+/lX05Nn6vPhc7OI=
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.5.1/go.mod h1:1Bl9F/ZBpVWh22nY0zmYyASPO1lI/zIwRDrpZU+tv8Y=
github.com/swaggo/swag v1.6.7/go.mod h1:xDhTyuFIujYiN3DKWC/H/83xcfHp+UE/IzWWampG7Zc=
github.com/swaggo/swag v1.16.1 h1:fTNRhKstPKxcnoKsytm4sahr8FaYzUcT7i1/3nd/fBg=
github.com/swaggo/swag v1.16.1/go.mod h1:9/LMvHycG3NFHfR6LwvikHv5iFvmPADQ359cKikGxto=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c h1:coVla7zpsycc+kA9NXpcvv2E4I7+ii6L5hZO2S6C3kw=
github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K07Tzg=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
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/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/timshannon/badgerhold/v3 v3.0.0-20210909134927-2b6764d68c1e/go.mod h1:/Seq5xGNo8jLhSbDX3jdbeZrp4yFIpQ6/7n4TjziEWs=
github.com/timshannon/badgerhold/v4 v4.0.2 h1:83OLY/NFnEaMnHEPd84bYtkLipVkjTsMbzQRYbk47g4=
github.com/timshannon/badgerhold/v4 v4.0.2/go.mod h1:rh6RyXLQFsvrvcKondPQQFZnNovpRzu+gS0FlLxYuHY=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM=
github.com/timshannon/badgerhold/v4 v4.0.3 h1:W6pd2qckoXw2cl8eH0ZCV/9CXNaXvaM26tzFi5Tj+v8=
github.com/timshannon/badgerhold/v4 v4.0.3/go.mod h1:IkZIr0kcZLMdD7YJfW/G6epb6ZXHD/h0XR2BTk/VZg8=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA=
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/ugorji/go v1.1.5-pre/go.mod h1:FwP/aQVg39TXzItUBMwnWp9T9gPQnXw4Poh4/oBQZ/0=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
github.com/ugorji/go/codec v0.0.0-20181022190402-e5e69e061d4f/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v1.1.5-pre/go.mod h1:tULtS6Gy1AE1yCENaw4Vb//HLH5njI2tfCQDUqRd8fI=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE=
github.com/xhit/go-simple-mail/v2 v2.13.0 h1:OANWU9jHZrVfBkNkvLf8Ww0fexwpQVF/v/5f96fFTLI=
github.com/xhit/go-simple-mail/v2 v2.13.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA=
github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
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.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.9.3 h1:aqNF8KDIN8bFpFbybSk+mEBil7IHeBwlujfyTnvP0uU=
go.mau.fi/util v0.9.3/go.mod h1:krWWfBM1jWTb5f8NCa2TLqWMQuM81X7TGQjhMjBeXmQ=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
go.opentelemetry.io/otel v1.9.0/go.mod h1:np4EoPGzoPs3O67xUVNoPPcmSvsfOxNlNA4F4AC+0Eo=
go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s=
go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4=
go.opentelemetry.io/otel/metric v1.16.0 h1:RbrpwVG1Hfv85LgnZ7+txXioPDoh6EdbZHo26Q3hqOo=
go.opentelemetry.io/otel/metric v1.16.0/go.mod h1:QE47cpOmkwipPiefDwo2wDzwJrlfxxNYodqc4xnGCo4=
go.opentelemetry.io/otel/trace v1.9.0/go.mod h1:2737Q0MuG8q1uILYm2YYVkAyLtOofiTNGg6VODnOiPo=
go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs=
go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw=
golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg=
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
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.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.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -453,13 +422,12 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -468,9 +436,10 @@ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJ
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/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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -483,36 +452,34 @@ golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7w
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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/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 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -524,11 +491,10 @@ golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgw
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
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.9.3 h1:Gn1I8+64MsuTb/HpH+LmQtNas23LhUVr3rYZ0eKuaMM=
golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -556,36 +522,24 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
maunium.net/go/mautrix v0.15.2 h1:fUiVajeoOR92uJoSShHbCvh7uG6lDY4ZO4Mvt90LbjU=
maunium.net/go/mautrix v0.15.2/go.mod h1:h4NwfKqE4YxGTLSgn/gawKzXAb2sF4qx8agL6QEFtGg=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
maunium.net/go/mautrix v0.26.0 h1:valc2VmZF+oIY4bMq4Cd5H9cEKMRe8eP4FM7iiaYLxI=
maunium.net/go/mautrix v0.26.0/go.mod h1:NWMv+243NX/gDrLofJ2nNXJPrG8vzoM+WUCWph85S6Q=

164
housekeeping-d.go Normal file
View File

@@ -0,0 +1,164 @@
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()
removeRoleOnDisable := app.config.Section("discord").Key("disable_enable_role").MustBool(false)
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 removeRoleOnDisable && 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.InvalidateJellyfinCache() })
}
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 }}">
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/{{ .cssVersion }}bundle.css">
{{ template "header.html" . }}
<title>404 - jfa-go</title>
{{ template "header.txt" . }}
</head>
<body class="section">
<div class="page-container">
<div class="page-container m-2 lg:my-20 lg:mx-64">
<div class="card">
<h1 class="heading">Page not found.</h1>
<p class="content">

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,45 +1,42 @@
<!doctype html>
<html lang="en">
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}">
<head>
<link inline rel="stylesheet" type="text/css" href="bundle.css">
{{ template "header.html" . }}
<!--- This CSS is inlined so we should keep this here! -->
<link inline rel="stylesheet" type="text/css" href="web/css/v0.6.0bundle.css">
{{ template "header.txt" . }}
<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">
<section class="section ~critical flex flex-col gap-2">
<span class="heading">Crash report for jfa-go</span>
{{ if .Err }}
<div class="font-mono bg-inherit pre-line mt-4 mb-4">
<div class="font-mono bg-inherit pre-line">
Error: {{ .Err }}
</div>
{{ end }}
<a class="button ~critical mb-4" target="_blank" href="https://github.com/hrfee/jfa-go/issues/new/choose">Create an Issue</a>
<a class="button ~critical w-full center" 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">
<span class="subheading">Full Log</span>
<span class="button ~urge ml-4" id="copy-log">Copy</span>
<section class="section ~neutral @low flex flex-col gap-4">
<div class="flex flex-row justify-between gap-4">
<span class="subheading font-medium">Full Log</span>
<span class="button ~urge" id="copy-log">Copy</span>
</div>
<div class="row mb-4">
<label class="col mr-4">
<span class="button ~neutral @high supra full-width center" id="button-log-normal">Normal</span>
</label>
<label class="col mr-4">
<span class="button ~neutral @low supra full-width center" id="button-log-sanitized">Sanitized</span>
</label>
<div class="flex flex-row gap-2 justify-between">
<button class="button ~neutral @high supra w-full center" id="button-log-normal">Normal</button>
<button class="button ~neutral @low supra w-full center" id="button-log-sanitized">Sanitized</button>
</div>
<div id="log-normal">
<pre class="font-mono bg-inherit pre-line">{{ .Log }}</pre>
<pre class="card font-mono bg-inherit pre-line">{{ .Log }}</pre>
</div>
<div id="log-sanitized" class="unfocused">
<p class="subheading">An attempt has been made to remove sensitive info, but make sure to check yourself.</p>
<pre class="font-mono bg-inherit pre-line">{{ .SanitizedLog }}</pre>
<div id="log-sanitized" class="flex flex-col gap-2 unfocused">
<p class="support subheading">An attempt has been made to remove sensitive info, but make sure to check yourself.</p>
<pre class="card font-mono bg-inherit pre-line">{{ .SanitizedLog }}</pre>
</div>
</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 }}">
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="{{ .urlBase }}/css/{{ .cssVersion }}bundle.css">
{{ template "header.html" . }}
{{ template "header.txt" . }}
<title>{{ .strings.successHeader }} - jfa-go</title>
</head>
<body class="section">
<div class="page-container">
<div class="page-container m-2 lg:my-20 lg:mx-64">
<div class="card ~neutral @low mb-4">
<span class="heading mb-4">{{ .strings.successHeader }}</span>
<p class="content my-4">{{ .successMessage }}</p>

View File

@@ -3,7 +3,6 @@
window.usernameEnabled = {{ .username }};
window.validationStrings = JSON.parse({{ .validationStrings }});
window.invalidPassword = "{{ .strings.reEnterPasswordInvalid }}";
window.URLBase = "{{ .urlBase }}";
window.code = "{{ .code }}";
window.language = "{{ .langName }}";
window.messages = JSON.parse({{ .notifications }});
@@ -14,16 +13,13 @@
window.userExpiryHours = {{ .userExpiryHours }};
window.userExpiryMinutes = {{ .userExpiryMinutes }};
window.userExpiryMessage = {{ .userExpiryMessage }};
window.telegramEnabled = {{ .telegramEnabled }};
window.telegramRequired = {{ .telegramRequired }};
window.telegramPIN = "{{ .telegramPIN }}";
window.emailRequired = {{ .emailRequired }};
window.discordEnabled = {{ .discordEnabled }};
window.discordRequired = {{ .discordRequired }};
window.discordPIN = "{{ .discordPIN }}";
window.discordInviteLink = {{ .discordInviteLink }};
window.discordServerName = "{{ .discordServerName }}";
window.matrixEnabled = {{ .matrixEnabled }};
window.matrixRequired = {{ .matrixRequired }};
window.matrixUserID = "{{ .matrixUser }}";
window.captcha = {{ .captcha }};
@@ -31,11 +27,21 @@
window.reCAPTCHASiteKey = "{{ .reCAPTCHASiteKey }}";
window.userPageEnabled = {{ .userPageEnabled }};
window.userPageAddress = "{{ .userPageAddress }}";
window.collectEmail = {{ .collectEmail }};
{{ if index . "customSuccessCard" }}
window.customSuccessCard = {{ .customSuccessCard }};
{{ else }}
window.customSuccessCard = false;
{{ end }}
</script>
{{ if .passwordReset }}
<script src="js/pwr.js" type="module"></script>
<script>
window.pwrPIN = "{{ .pwrPIN }}";
</script>
{{ else }}
<script src="js/form.js" type="module"></script>
{{ end }}
{{ if .reCAPTCHA }}
<script>
var reCAPTCHACallback = () => {
@@ -49,4 +55,3 @@
<script src="https://www.google.com/recaptcha/api.js?onload=reCAPTCHACallback&render=explicit" async defer></script>
{{ end }}
{{ end }}
{{ end }}

View File

@@ -1,8 +1,7 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/{{ .cssVersion }}bundle.css">
{{ template "header.html" . }}
{{ template "header.txt" . }}
{{ if .passwordReset }}
<title>{{ .strings.passwordReset }}</title>
{{ else }}
@@ -14,12 +13,19 @@
</head>
<body class="max-w-full overflow-x-hidden section">
<div id="modal-success" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ if .passwordReset }}{{ .strings.passwordReset }}{{ else }}{{ .strings.successHeader }}{{ end }}</span>
<p class="content mb-4">{{ if .passwordReset }}{{ .strings.youCanLoginPassword }}{{ else }}{{ .successMessage }}{{ end }}</p>
{{ if .userPageEnabled }}<p class="content mb-4" id="modal-success-user-page-area" my-account-term="{{ .strings.myAccount }}">{{ .strings.userPageSuccessMessage }}</p>{{ end }}
<a class="button ~urge @low full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a>
</div>
{{ if .customSuccessCard }}
<div class="card @low dark:~d_neutral content break-words relative mx-auto my-[10%] w-4/5 lg:w-1/3">
{{ .customSuccessCardContent }}
<a class="button ~urge @low full-width center supra submit my-2" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a>
</div>
{{ else }}
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ if .passwordReset }}{{ .strings.passwordReset }}{{ else }}{{ .strings.successHeader }}{{ end }}</span>
<p class="content mb-4">{{ if .passwordReset }}{{ .strings.youCanLoginPassword }}{{ else }}{{ .successMessage }}{{ end }}</p>
{{ if .userPageEnabled }}<p class="content mb-4" id="modal-success-user-page-area" my-account-term="{{ .strings.myAccount }}">{{ .strings.userPageSuccessMessage }}</p>{{ end }}
<a class="button ~urge @low full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a>
</div>
{{ end }}
</div>
<div id="modal-confirmation" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
@@ -28,23 +34,14 @@
</div>
</div>
{{ template "account-linking.html" . }}
<div class="top-4 left-4 absolute">
<span class="dropdown" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button">
<i class="ri-global-line"></i>
<span class="ml-2 chev"></span>
</span>
<div class="dropdown-display">
<div class="card ~neutral @low" id="lang-list">
</div>
</div>
</span>
</div>
<div id="notification-box"></div>
<div class="page-container">
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4">
<div class="top-2 inset-x-2 lg:absolute flex flex-row justify-between">
{{ template "lang-select.html" . }}
</div>
<div class="card dark:~d_neutral @low">
<div class="flex flex-col md:flex-row gap-3 inline align-baseline">
<span class="heading mr-5">
<div class="flex flex-col md:flex-row gap-3 items-baseline mb-2">
<span class="heading">
{{ if .passwordReset }}
{{ .strings.passwordReset }}
{{ else }}
@@ -53,9 +50,9 @@
</span>
<span class="subheading">
{{ if .passwordReset }}
{{ .strings.enterYourPassword }}
{{ .strings.enterYourPassword }}
{{ else }}
{{ .helpMessage }}
{{ .helpMessage }}
{{ end }}
</span>
</div>
@@ -71,8 +68,10 @@
<input type="text" class="input ~neutral @high mt-2 mb-4" placeholder="{{ .strings.username }}" id="create-username" aria-label="{{ .strings.username }}">
</label>
<label class="label supra" for="create-email">{{ .strings.emailAddress }}</label>
<input type="email" class="input ~neutral @high mt-2 mb-4" placeholder="{{ .strings.emailAddress }}" id="create-email" aria-label="{{ .strings.emailAddress }}" value="{{ .email }}">
<div>
<label class="label supra" for="create-email">{{ .strings.emailAddress }}</label>
<input type="email" class="input ~neutral @high mt-2 mb-4" placeholder="{{ .strings.emailAddress }}" id="create-email" aria-label="{{ .strings.emailAddress }}" value="{{ .email }}">
</div>
{{ if .telegramEnabled }}
<span class="button ~info @low full-width center mb-4" id="link-telegram">{{ .strings.linkTelegram }} {{ if .telegramRequired }}({{ .strings.required }}){{ end }}</span>
{{ end }}
@@ -83,23 +82,23 @@
<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 unfocused">
<input type="checkbox" name="contact-via" value="email" id="contact-via-email" class="mr-2"><span>Contact through Email</span>
<div id="contact-via" class="unfocused flex flex-col gap-2">
<label class="flex flex-row gap-2 switch unfocused">
<input type="checkbox" name="contact-via" value="email" id="contact-via-email"><span>Contact through Email</span>
</label>
{{ if .telegramEnabled }}
<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 class="flex flex-row gap-2 switch unfocused">
<input type="checkbox" name="contact-via" value="telegram" id="contact-via-telegram"><span>Contact through Telegram</span>
</label>
{{ end }}
{{ if .discordEnabled }}
<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 class="flex flex-row gap-2 switch unfocused">
<input type="checkbox" name="contact-via" value="discord" id="contact-via-discord"><span>Contact through Discord</span>
</label>
{{ end }}
{{ if .matrixEnabled }}
<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 class="flex flex-row gap-2 switch unfocused">
<input type="checkbox" name="contact-via" value="matrix" id="contact-via-matrix"><span>Contact through Matrix</span>
</label>
{{ end }}
</div>
@@ -123,6 +122,14 @@
</form>
</div>
<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 }}
{{ if .preSignupCard }}
<div class="card @low dark:~d_neutral break-words content">
{{ .preSignupCardContent }}
</div>
{{ end }}
<div class="card ~neutral @low mb-4">
<span class="label supra">{{ .strings.passwordRequirementsHeader }}</span>
<ul>
@@ -134,16 +141,24 @@
</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>
<div class="card ~neutral @low mb-4 flex flex-col gap-2">
<div class="flex flex-row justify-between gap-2">
<span class="label supra">CAPTCHA</span>
{{ if not .reCAPTCHA }}
<div class="flex flex-row gap-2">
<button id="captcha-regen" aria-label="{{ .strings.refresh }}" title="{{ .strings.refresh }}" class="badge lg @low ~info"><i class="ri-refresh-line"></i></button>
<span id="captcha-success" class="badge lg @low ~critical"><i class="ri-close-line"></i></span>
</div>
{{ end }}
</div>
<div id="captcha-img" class="{{ if .reCAPTCHA }}g-recaptcha{{ end }}"></div>
{{ if not .reCAPTCHA }}
<input class="field ~neutral @low" id="captcha-input" class="mt-2" placeholder="CAPTCHA">
<input class="field ~neutral @low" id="captcha-input" 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 +0,0 @@
<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">
<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">
<meta name="msapplication-TileColor" content="#603cba">
<meta name="theme-color" content="#ffffff">

36
html/header.txt Normal file
View File

@@ -0,0 +1,36 @@
<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="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#101010" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ffffff">
<meta name="robots" content="noindex">
<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">
<script>
window.pages = {
"Base": "{{ .pages.Base }}",
"TrueBase": "{{ .pages.TrueBase }}",
"ExternalURI": "{{ .pages.ExternalURI }}",
"Current": "{{ .pages.Current }}",
"Admin": "{{ .pages.Admin }}",
"MyAccount": "{{ .pages.MyAccount }}",
"Form": "{{ .pages.Form }}"
};
window.emailEnabled = {{ .emailEnabled }};
window.discordEnabled = {{ .discordEnabled }};
window.telegramEnabled = {{ .telegramEnabled }};
window.matrixEnabled = {{ .matrixEnabled }};
window.notificationsEnabled = {{ .notifications }};
window.ombiEnabled = {{ .ombiEnabled }};
window.jellyseerrEnabled = {{ .jellyseerrEnabled }};
window.referralsEnabled = {{ .referralsEnabled }};
window.pwrEnabled = {{ .pwrEnabled }};
</script>

View File

@@ -1,12 +1,11 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="{{ .urlBase }}/css/{{ .cssVersion }}bundle.css">
{{ template "header.html" . }}
{{ template "header.txt" . }}
<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 flex flex-row gap-2 h-full" title="{{ .strings.language }}" aria-label="{{ .strings.language }}">
<i class="icon ri-global-line"></i>
<i class="icon ri-arrow-down-s-line"></i>
</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" class="flex flex-col gap-2"></div>
</div>
</div>
</span>

View File

@@ -1,25 +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-[40%] lg:w-[60%]">
<div class="my-[10%] row items-stretch relative mx-auto w-11/12 sm:w-4/5 lg:w-1/2">
{{ $hasTwoCards := 0 }}
{{ if index . "LoginMessageEnabled" }}
{{ if .LoginMessageEnabled }}
<div class="card mx-2 flex-initial w-[100%] xl:w-[35%] mb-4 xl:mb-0 dark:~d_neutral @low content">
{{ $hasTwoCards = 1 }}
<div class="card mx-2 flex-initial w-full lg:w-[35%] mb-4 lg:mb-0 dark:~d_neutral @low content">
{{ .LoginMessageContent }}
</div>
{{ end }}
{{ end }}
<form class="card mx-2 flex-auto form-login w-[100%] xl:w-[55%] mb-0" href="">
{{ 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 flex flex-row gap-2" href="{{ .pages.Base }}{{ .pages.MyAccount }}"><i class="ri-account-circle-fill"></i>{{ .strings.myAccount }}</a>
</div>
{{ end }}
{{ end }}
<form class="card mx-2 form-login w-full flex flex-col gap-2 {{ 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">
<input type="text" class="field input ~neutral @high" placeholder="{{ .strings.username }}" id="login-user">
<input type="password" class="field input ~neutral @high" 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>
{{ if index . "pwrEnabled" }}
{{ if .pwrEnabled }}
<span class="button ~info @low full-width center supra" id="modal-login-pwr">{{ .strings.resetPassword }}</span>
{{ end }}
{{ end }}
</form>
</div>
</div>

View File

@@ -1,8 +1,7 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/{{ .cssVersion }}bundle.css">
{{ template "header.html" . }}
{{ template "header.txt" . }}
<title>{{ .strings.passwordReset }} - jfa-go</title>
</head>
<body class="section">
@@ -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,270 +1,340 @@
<!DOCTYPE html>
<html lang="en" class="light">
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="light">
<head>
<link rel="stylesheet" type="text/css" href="css/{{ .cssVersion }}bundle.css">
{{ template "header.html" . }}
{{ template "header.txt" . }}
<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">
<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 }} ({{ .lang.Strings.required }})</span>
<input type="text" class="input ~neutral @low" id="ui-jfa_url" placeholder="https://jellyf.in/mysubfolder">
<p class="support">{{ .lang.General.externalURLNotice }}</p>
</label>
<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" checked><span>{{ .lang.Login.adminOnly }}</span>
</label>
<label class="row switch pl-4 pb-2">
<input type="checkbox" class="mr-2" id="ui-allow_all"><span>{{ .lang.Login.allowAll }}</span>
</label>
<p class="support pb-4 pl-4 mt-1" id="description-ui-allow_all">{{ .lang.Login.allowAllDescription }}</p>
<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 flex flex-row gap-2"><input type="radio" name="email-24h" value="true" checked><span>{{ .lang.Strings.time24h }}</span></div>
</label>
<label class="label flex flex-col gap-2">
<div class="switch flex flex-row gap-2"><input type="radio" 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>
@@ -272,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>
@@ -499,4 +574,3 @@
<script src="js/setup.js" type="module"></script>
</body>
</html>

View File

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

View File

@@ -1,70 +1,66 @@
<html lang="en" class="light">
<!DOCTYPE html>
<html lang="{{ .shortLang }}" dir="{{ .pageDirection }}" class="light">
<head>
<link rel="stylesheet" type="text/css" href="{{ .urlBase }}/css/{{ .cssVersion }}bundle.css">
<script>
window.URLBase = "{{ .urlBase }}";
window.notificationsEnabled = {{ .notifications }};
window.ombiEnabled = {{ .ombiEnabled }};
window.langFile = JSON.parse({{ .language }});
window.pwrEnabled = {{ .pwrEnabled }};
window.linkResetEnabled = {{ .linkResetEnabled }};
window.language = "{{ .langName }}";
window.telegramEnabled = {{ .telegramEnabled }};
window.telegramRequired = {{ .telegramRequired }};
window.telegramUsername = {{ .telegramUsername }};
window.telegramURL = {{ .telegramURL }};
window.emailEnabled = {{ .emailEnabled }};
window.emailRequired = {{ .emailRequired }};
window.discordEnabled = {{ .discordEnabled }};
window.discordRequired = {{ .discordRequired }};
window.discordServerName = "{{ .discordServerName }}";
window.discordInviteLink = {{ .discordInviteLink }};
window.discordSendPINMessage = "{{ .discordSendPINMessage }}";
window.matrixEnabled = {{ .matrixEnabled }};
window.matrixRequired = {{ .matrixRequired }};
window.matrixUserID = "{{ .matrixUser }}";
window.validationStrings = JSON.parse({{ .validationStrings }});
</script>
{{ template "header.html" . }}
{{ template "header.txt" . }}
<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 class="flex flex-col gap-2">
<span class="heading"></span>
<label class="label flex flex-col gap-2">
<span class="supra">{{ .strings.emailAddress }}</span>
<input type="email" class="field ~neutral @low input" id="modal-email-input" placeholder="{{ .strings.emailAddress }}">
</label>
<button class="button ~urge @low supra full-width center lg 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 class="confirmation-required unfocused flex flex-col gap-2">
<span class="heading">{{ .strings.confirmationRequired }} <span class="modal-close">&times;</span></span>
<p class="content">{{ .strings.confirmationRequiredMessage }}</p>
</div>
</div>
</div>
{{ if .pwrEnabled }}
<div id="modal-pwr" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low flex flex-col gap-2">
<span class="heading">{{ .strings.resetPassword }}</span>
<p class="content my-2">
{{ if .linkResetEnabled }}
{{ .strings.resetPasswordThroughLink }}
{{ 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 class="content">
{{ if .linkResetEnabled }}
<p>{{ .strings.resetPasswordThroughLinkStart }}</p>
<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>
<p>{{ .strings.resetPasswordThroughLinkEnd }}</p>
{{ else }}
<p>{{ .strings.resetPasswordThroughJellyfin }}</p>
{{ end }}
</div>
<input type="text" class="col sm field ~neutral @low input" id="pwr-address" placeholder="username | example@example.com | user#1234 | @user:host | @username">
{{ if .linkResetEnabled }}
<span class="button ~info @low full-width center mt-4" id="pwr-submit">
<span class="button ~info @low full-width center" id="pwr-submit">
{{ .strings.submit }}
</span>
{{ else }}
<a class="button ~info @low full-width center mt-4" href="{{ .jfLink }}" target="_blank">{{ .strings.continue }}</a>
<a class="button ~info @low full-width center" href="{{ .jfLink }}" target="_blank">{{ .strings.continue }}</a>
{{ end }}
</div>
</div>
@@ -72,53 +68,35 @@
{{ template "login-modal.html" . }}
{{ template "account-linking.html" . }}
<div id="notification-box"></div>
<div class="top-4 left-4 absolute">
<span class="dropdown" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button">
<i class="ri-global-line"></i>
<span class="ml-2 chev"></span>
</span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<label class="switch pb-4">
<input type="radio" name="lang-time" id="lang-12h">
<span>{{ .strings.time12h }}</span>
</label>
<label class="switch pb-4">
<input type="radio" name="lang-time" id="lang-24h">
<span>{{ .strings.time24h }}</span>
</label>
<div id="lang-list"></div>
</div>
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4 unfocused">
<div class="top-2 inset-x-2 lg:absolute flex flex-row justify-between">
<div class="flex flex-row gap-2">
{{ template "lang-select.html" . }}
<span class="button ~warning h-min" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
<span class="button ~critical @low unfocused" id="logout-button">{{ .strings.logout }}</span>
</div>
</span>
<span class="button ~warning" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
<span class="button ~critical @low mb-4 unfocused" id="logout-button">{{ .strings.logout }}</span>
</div>
<div class="top-4 right-4 absolute">
<a class="button ~info unfocused" href="/" id="admin-back-button"><i class="ri-arrow-left-fill mr-2"></i>{{ .strings.admin }}</a>
</div>
<div class="page-container unfocused">
<div class="card @low dark:~d_neutral mb-4" id="card-user">
<span class="heading mb-2"></span>
<a class="button ~info unfocused h-min flex flex-row gap-2" href="/" id="admin-back-button"><i class="ri-arrow-left-fill"></i>{{ .strings.admin }}</a>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="card @low dark:~d_neutral" id="card-user">
<span class="heading flex flex-row gap-4"></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" id="card-message">
<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="card @low dark:~d_neutral flex flex-col gap-2" id="card-contact">
<span class="heading">{{ .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">
<div class="card @low dark:~d_neutral flex flex-col gap-2" id="card-password">
<span class="heading">{{ .strings.changePassword }}</span>
<div class="flex flex-col gap-2">
<div class="content">
<span class="label supra row">{{ .strings.passwordRequirementsHeader }}</span>
<ul>
{{ range $key, $value := .requirements }}
@@ -128,15 +106,15 @@
{{ end }}
</ul>
</div>
<div class="my-2">
<div class="flex flex-col gap-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 }}">
<input type="password" class="input ~neutral @low" 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 }}">
<input type="password" class="input ~neutral @low" 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">
<input type="password" class="input ~neutral @low" placeholder="{{ .strings.password }}" id="user-reenter-new-password" aria-label="{{ .strings.reEnterPassword }}">
<span class="button ~info @low full-width center" id="user-password-submit">
{{ .strings.changePassword }}
</span>
</div>
@@ -144,15 +122,29 @@
</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="card @low dark:~d_neutral unfocused flex flex-col gap-2" id="card-status">
<span class="heading">{{ .strings.expiry }}</span>
<aside class="aside ~warning user-expiry"></aside>
<div class="user-expiry-countdown"></div>
</div>
</div>
{{ if .referralsEnabled }}
<div>
<div class="card @low dark:~d_neutral unfocused flex flex-col gap-2" id="card-referrals">
<span class="heading">{{ .strings.referrals }}</span>
<aside class="aside ~neutral col user-referrals-description"></aside>
<div class="flex flex-row justify-between gap-2">
<div class="user-referrals-info flex flex-col gap-2"></div>
<div class="grid">
<button type="button" class="user-referrals-button button ~info dark:~d_info @low flex flex-row gap-2" title="Copy">{{ .strings.copyReferral }}<i class="ri-file-copy-line"></i></button>
</div>
</div>
</div>
</div>
{{ end }}
</div>
</div>
<script src="{{ .urlBase }}/js/user.js" type="module"></script>
<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: 45 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: 52 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 59 KiB

After

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

File diff suppressed because one or more lines are too long

After

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

110
jellyseerr-d.go Normal file
View File

@@ -0,0 +1,110 @@
package main
import (
"strconv"
"strings"
"time"
"github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
)
type JellyseerrInitialSyncStatus struct {
Done bool
}
// Ensure the Jellyseerr cache is up to date before calling.
func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
user, imported, err := app.js.GetOrImportUser(jfID, true)
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 {
if strings.Contains(err.Error(), "INVALID_EMAIL") {
app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, jfID, err.Error()+"\""+email.Addr+"\"")
} else {
app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, jfID, err)
}
} else {
contactMethods[jellyseerr.FieldEmailEnabled] = email.Contact
}
}
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] = strconv.FormatInt(u.ChatID, 10)
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() {
jsSync := JellyseerrInitialSyncStatus{}
app.storage.db.Get("jellyseerr_inital_sync_status", &jsSync)
if jsSync.Done {
return
}
users, err := app.jf.GetUsers(false)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
return
}
app.js.ReloadCache()
// I'm sure Jellyseerr can handle it,
// but past issues with the Jellyfin db scare me from
// running these concurrently. W/e, its a bg task anyway.
for _, user := range users {
app.SynchronizeJellyseerrUser(user.ID)
}
// Don't run again until this flag is unset
// Stored in the DB as it's not something the user needs to see.
app.storage.db.Upsert("jellyseerr_inital_sync_status", JellyseerrInitialSyncStatus{true})
}
// Not really a normal daemon, since it'll only fire once when the feature is enabled.
func newJellyseerrDaemon(interval time.Duration, app *appContext) *GenericDaemon {
d := NewGenericDaemon(interval, app,
func(app *appContext) {
app.SynchronizeJellyseerrUsers()
},
)
d.Name("Jellyseerr import")
jsSync := JellyseerrInitialSyncStatus{}
app.storage.db.Get("jellyseerr_inital_sync_status", &jsSync)
if jsSync.Done {
return nil
}
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=

475
jellyseerr/jellyseerr.go Normal file
View File

@@ -0,0 +1,475 @@
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
jsToJfID map[int64]string // Map of jellyseerr IDs to jellyfin IDs
invalidatedUsers map[int64]bool // Map of jellyseerr IDs needing a re-caching
cacheExpiry time.Time
cacheLength time.Duration
timeoutHandler co.TimeoutHandler
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{},
jsToJfID: map[int64]string{},
invalidatedUsers: map[int64]bool{},
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 {
var decodeErr error
responseText, decodeErr = js.decodeResp(resp)
if decodeErr != 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
js.jsToJfID[u.ID] = u.JellyfinUserID
}
}
return data, err
}
func (js *Jellyseerr) getUsers() error {
if js.cacheExpiry.After(time.Now()) {
return nil
if len(js.invalidatedUsers) != 0 {
return js.getInvalidatedUsers()
}
}
js.cacheExpiry = time.Now().Add(js.cacheLength)
userCache := map[string]User{}
jsToJfID := map[int64]string{}
pageCount := 1
pageIndex := 0
for {
res, err := js.getUserPage(pageIndex)
if err != nil {
return err
}
for _, u := range res.Results {
if u.JellyfinUserID == "" {
continue
}
userCache[u.JellyfinUserID] = u
jsToJfID[u.ID] = u.JellyfinUserID
}
pageCount = res.Page.Pages
pageIndex++
if pageIndex >= pageCount {
break
}
}
js.userCache = userCache
js.jsToJfID = jsToJfID
js.invalidatedUsers = map[int64]bool{}
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, false)
return u, err
}
// GetImportedUser provides the same function as ImportFromJellyfin, but will always return the user,
// even if they already existed. Also returns whether the user was imported or not,
func (js *Jellyseerr) GetOrImportUser(jfID string, fixedCache bool) (u User, imported bool, err error) {
imported = false
u, err = js.GetExistingUser(jfID, fixedCache)
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, fixedCache bool) (u User, err error) {
js.getUsers()
ok := false
err = nil
u, ok = js.userCache[jfID]
_, invalidated := js.invalidatedUsers[u.ID]
if ok && !invalidated {
return
}
if invalidated {
err = js.getInvalidatedUsers()
if err != nil {
return
}
} else if !fixedCache {
js.cacheExpiry = time.Now()
js.getUsers()
}
if u, ok = js.userCache[jfID]; ok {
err = nil
return
}
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, false)
}
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) getInvalidatedUsers() error {
// FIXME: Collect errors and return
for jellyseerrID, _ := range js.invalidatedUsers {
jfID, ok := js.jsToJfID[jellyseerrID]
if !ok {
continue
}
user, err := js.UserByID(jellyseerrID)
if err != nil {
continue
}
js.userCache[jfID] = user
js.jsToJfID[jellyseerrID] = jfID
delete(js.invalidatedUsers, jellyseerrID)
}
return nil
}
func (js *Jellyseerr) GetPermissions(jfID string) (Permissions, error) {
data := permissionsDTO{Permissions: -1}
u, err := js.getUser(jfID)
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
js.jsToJfID[u.ID] = jfID
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
js.jsToJfID[u.ID] = jfID
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
}
js.invalidatedUsers[u.ID] = true
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
}
return js.ModifyMainUserSettingsByID(u.ID, conf)
}
func (js *Jellyseerr) ModifyMainUserSettingsByID(jellyseerrID int64, conf MainUserSettings) error {
_, _, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/main", jellyseerrID), conf, false)
if err != nil {
return err
}
js.invalidatedUsers[jellyseerrID] = true
return nil
}
func (js *Jellyseerr) ReloadCache() error {
js.cacheExpiry = time.Now()
return js.getUsers()
}

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

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