diff --git a/.goreleaser.yml b/.goreleaser.yml
index afc3531..9c5b5d7 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -74,9 +74,9 @@ builds:
- amd64
archives:
- id: windows-tray
- builds:
+ ids:
- windows-tray
- format: zip
+ formats: [ "zip" ]
name_template: >-
{{ .ProjectName }}_{{ .Version }}_TrayIcon_
{{- if eq .Os "darwin" }}macOS
@@ -84,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
@@ -94,9 +94,9 @@ 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
@@ -104,9 +104,9 @@ archives:
{{- if eq .Arch "amd64" }}x86_64
{{- else }}{{ .Arch }}{{ end }}
- id: notray-e2ee
- builds:
+ ids:
- notray-e2ee
- format: zip
+ formats: [ "zip" ]
name_template: >-
{{ .ProjectName }}_{{ .Version }}_MatrixE2EE_
{{- if eq .Os "darwin" }}macOS
@@ -133,7 +133,7 @@ nfpms:
license: MIT
vendor: hrfee.dev
version_metadata: git
- builds:
+ ids:
- notray-e2ee
contents:
- src: ./LICENSE
@@ -161,7 +161,7 @@ nfpms:
license: MIT
vendor: hrfee.dev
version_metadata: git
- builds:
+ ids:
- linux-tray
contents:
- src: ./LICENSE
@@ -177,13 +177,10 @@ nfpms:
replaces:
- jfa-go
dependencies:
- - libayatana-appindicator
- libolm-dev
rpm:
dependencies:
- - libappindicator-gtk3
- libolm
apk:
dependencies:
- - libayatana-appindicator
- olm
diff --git a/.woodpecker/git-docker.yaml b/.woodpecker/git-docker.yaml
deleted file mode 100644
index a0b9f4a..0000000
--- a/.woodpecker/git-docker.yaml
+++ /dev/null
@@ -1,29 +0,0 @@
-when:
- - event: push
- branch: main
-
-steps:
- - name: build
- image: docker.io/woodpeckerci/plugin-docker-buildx
- secrets: [ BUILT_BY ]
- settings:
- username:
- from_secret: DOCKER_USERNAME
- password:
- from_secret: DOCKER_TOKEN
- repo: docker.io/hrfee/jfa-go
- tags: unstable
- registry: docker.io
- platforms: linux/amd64,linux/arm64,linux/arm/v7
- build_args:
- - BUILT_BY: $BUILT_BY
- - name: buildrone
- image: docker.io/python
- environment:
- BUILDRONE_KEY:
- from_secret: BUILDRONE_KEY
- commands:
- - wget https://builds.hrfee.pw/upload.py
- - pip install requests
- - python upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-unstable=true
-
diff --git a/.woodpecker/stable-binary.yaml b/.woodpecker/stable-binary.yaml
deleted file mode 100644
index e805958..0000000
--- a/.woodpecker/stable-binary.yaml
+++ /dev/null
@@ -1,41 +0,0 @@
-when:
- - event: tag
- branch: main
-
-clone:
- git:
- image: woodpeckerci/plugin-git
- settings:
- tags: true
- partial: false
- depth: 0
-
-steps:
- - name: build
- image: docker.io/hrfee/jfa-go-build-docker:latest
- environment:
- JFA_GO_BUILT_BY:
- from_secret: BUILT_BY
- commands:
- - curl -sfL https://goreleaser.com/static/run > ../goreleaser
- - chmod +x ../goreleaser
- - ./scripts/version.sh ../goreleaser
- - name: deb-repo
- image: docker.io/hrfee/jfa-go-build-docker:latest
- environment:
- REPO_SSH_ID:
- from_secret: REPO_SSH_ID
- commands:
- - sh -c "echo \"$REPO_SSH_ID\" > /tmp/id_repo && chmod 600 /tmp/id_repo"
- - bash -c 'sftp -P 2022 -i /tmp/id_repo -o StrictHostKeyChecking=no root@apt.hrfee.dev:/repo/incoming <<< $"put dist/*.deb"'
- - bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty"'
- - bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty-unstable"'
- - bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "rm -f /repo/incoming/*.deb"'
- - name: buildrone
- image: docker.io/hrfee/jfa-go-build-docker:latest
- environment:
- BUILDRONE_KEY:
- from_secret: BUILDRONE_KEY
- commands:
- - wget https://builds.hrfee.pw/upload.py
- - bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag internal=true'
diff --git a/.woodpecker/stable-docker.yaml b/.woodpecker/stable-docker.yaml
deleted file mode 100644
index f5e59af..0000000
--- a/.woodpecker/stable-docker.yaml
+++ /dev/null
@@ -1,29 +0,0 @@
-when:
- - event: tag
- branch: main
-
-steps:
- - name: build
- image: docker.io/woodpeckerci/plugin-docker-buildx
- secrets: [ BUILT_BY ]
- settings:
- username:
- from_secret: DOCKER_USERNAME
- password:
- from_secret: DOCKER_TOKEN
- repo: docker.io/hrfee/jfa-go
- tags: latest
- registry: docker.io
- platforms: linux/amd64,linux/arm64,linux/arm/v7
- build_args:
- - BUILT_BY: $BUILT_BY
- - name: buildrone
- image: docker.io/python
- environment:
- BUILDRONE_KEY:
- from_secret: BUILDRONE_KEY
- commands:
- - wget https://builds.hrfee.pw/upload.py
- - pip install requests
- - python upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-stable=true
-
diff --git a/.woodpecker/stable.yaml b/.woodpecker/stable.yaml
new file mode 100644
index 0000000..4fb2988
--- /dev/null
+++ b/.woodpecker/stable.yaml
@@ -0,0 +1,87 @@
+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
+ commands:
+ - npm i
+ - make precompile
+ - go mod download
+ - name: test
+ image: docker.io/hrfee/jfa-go-build-docker:latest
+ environment:
+ JFA_GO_SNAPSHOT: y
+ JFA_GO_BUILT_BY:
+ from_secret: BUILT_BY
+ commands:
+ - make test
+ - name: build
+ image: docker.io/hrfee/jfa-go-build-docker:latest
+ environment:
+ JFA_GO_BUILT_BY:
+ from_secret: BUILT_BY
+ GITHUB_TOKEN:
+ from_secret: GITHUB_TOKEN
+ commands:
+ - curl -sfL https://goreleaser.com/static/run > ../goreleaser
+ - chmod +x ../goreleaser
+ - ./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
+ 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
diff --git a/.woodpecker/git-binary.yaml b/.woodpecker/unstable.yaml
similarity index 52%
rename from .woodpecker/git-binary.yaml
rename to .woodpecker/unstable.yaml
index c9534c5..adbb6b7 100644
--- a/.woodpecker/git-binary.yaml
+++ b/.woodpecker/unstable.yaml
@@ -12,6 +12,24 @@ clone:
depth: 0
steps:
+ - name: precompile
+ image: docker.io/hrfee/jfa-go-build-docker:latest
+ environment:
+ JFA_GO_SNAPSHOT: y
+ JFA_GO_BUILT_BY:
+ from_secret: BUILT_BY
+ commands:
+ - npm i
+ - make precompile
+ - go mod download
+ - name: test
+ image: docker.io/hrfee/jfa-go-build-docker:latest
+ environment:
+ JFA_GO_SNAPSHOT: y
+ JFA_GO_BUILT_BY:
+ from_secret: BUILT_BY
+ commands:
+ - make test
- name: build
image: docker.io/hrfee/jfa-go-build-docker:latest
environment:
@@ -22,6 +40,14 @@ steps:
- curl -sfL https://goreleaser.com/static/run > goreleaser
- chmod +x goreleaser
- ./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:
@@ -41,11 +67,39 @@ steps:
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty-unstable"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "repo-process-deb trusty"'
- bash -c 'ssh -i /tmp/id_repo root@apt.hrfee.dev -p 2022 "rm -f /repo/incoming/*.deb"'
- - name: buildrone
+ - 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
+ 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
- - bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --upload ./dist/*.zip ./dist/*.rpm ./dist/*.apk --tag internal-git=true'
+ - python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-unstable=true
+
+
diff --git a/Dockerfile.ci b/Dockerfile.ci
new file mode 100644
index 0000000..dc98bfe
--- /dev/null
+++ b/Dockerfile.ci
@@ -0,0 +1,12 @@
+FROM golang:bookworm AS final
+ARG TARGETARCH
+
+COPY ./dist/notray-e2ee_linux_${TARGETARCH}* /opt/jfa-go
+COPY ./build/data /opt/jfa-go/data
+
+RUN apt-get update -y && apt-get install libolm-dev -y
+
+EXPOSE 8056
+EXPOSE 8057
+
+CMD [ "/opt/jfa-go/jfa-go", "-data", "/data" ]
diff --git a/LICENSE b/LICENSE
index a0a01f0..3069b00 100644
--- a/LICENSE
+++ b/LICENSE
@@ -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
diff --git a/Makefile b/Makefile
index 6657649..8d05fa1 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: configuration email typescript swagger copy compile compress inline-css variants-html install clean npm config-description config-default precompile
+.PHONY: configuration email typescript swagger copy compile compress inline-css variants-html install clean npm config-description config-default precompile test
.DEFAULT_GOAL := all
GOESBUILD ?= off
@@ -9,7 +9,7 @@ else
endif
GOBINARY ?= go
-CSSVERSION ?= v3
+CSSVERSION ?= $(shell git describe --tags --abbrev=0)
CSS_BUNDLE = $(DATA)/web/css/$(CSSVERSION)bundle.css
VERSION ?= $(shell git describe --exact-match HEAD 2> /dev/null || echo vgit)
@@ -33,7 +33,7 @@ E2EE ?= on
TAGS := -tags "
ifeq ($(INTERNAL), on)
- DATA := data
+ DATA := build/data
COMPDEPS := $(BUILDDEPS)
else
DATA := build/data
@@ -97,11 +97,18 @@ else
endif
ifeq (, $(shell which swag))
- SWAGINSTALL := $(GOBINARY) install github.com/swaggo/swag/cmd/swag@latest
+ SWAGINSTALL := $(GOBINARY) install github.com/swaggo/swag/cmd/swag@v1.16.4
else
SWAGINSTALL :=
endif
+# FLAG HASHING: To rebuild on flag change.
+# credit for idea to https://bnikolic.co.uk/blog/sh/make/unix/2021/07/08/makefile.html
+rebuildFlags := GOESBUILD GOBINARY VERSION COMMIT UPDATER INTERNAL TRAY E2EE TAGS DEBUG RACE
+rebuildVals := $(foreach v,$(rebuildFlags),$(v)=$($(v)))
+rebuildHash := $(strip $(shell echo $(rebuildVals) | sha256sum | cut -d " " -f1))
+rebuildHashFile := $(DATA)/buildhash-$(rebuildHash).txt
+
CONFIG_BASE = config/config-base.yaml
# CONFIG_DESCRIPTION = $(DATA)/config-base.json
@@ -148,7 +155,7 @@ SWAGGER_SRC = $(wildcard api*.go) $(wildcard *auth.go) views.go
SWAGGER_TARGET = docs/docs.go
$(SWAGGER_TARGET): $(SWAGGER_SRC)
$(SWAGINSTALL)
- swag init -g main.go
+ swag init --parseDependency --parseInternal -g main.go
VARIANTS_SRC = $(wildcard html/*.html)
VARIANTS_TARGET = $(DATA)/html/admin.html
@@ -160,16 +167,26 @@ $(VARIANTS_TARGET): $(VARIANTS_SRC)
ICON_SRC = node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2
ICON_TARGET = $(ICON_SRC:node_modules/remixicon/fonts/%=$(DATA)/web/css/%)
+SYNTAX_LIGHT_SRC = node_modules/highlight.js/styles/base16/atelier-sulphurpool-light.min.css
+SYNTAX_LIGHT_TARGET = $(DATA)/web/css/$(CSSVERSION)highlightjs-light.css
+SYNTAX_DARK_SRC = node_modules/highlight.js/styles/base16/circus.min.css
+SYNTAX_DARK_TARGET = $(DATA)/web/css/$(CSSVERSION)highlightjs-dark.css
+CODEINPUT_SRC = node_modules/@webcoder49/code-input/code-input.min.css
+CODEINPUT_TARGET = $(DATA)/web/css/$(CSSVERSION)code-input.css
CSS_SRC = $(wildcard css/*.css)
CSS_TARGET = $(DATA)/web/css/part-bundle.css
CSS_FULLTARGET = $(CSS_BUNDLE)
-ALL_CSS_SRC = $(ICON_SRC) $(CSS_SRC)
+ALL_CSS_SRC = $(ICON_SRC) $(CSS_SRC) $(SYNTAX_LIGHT_SRC) $(SYNTAX_DARK_SRC)
ALL_CSS_TARGET = $(ICON_TARGET)
$(CSS_FULLTARGET): $(TYPESCRIPT_TARGET) $(VARIANTS_TARGET) $(ALL_CSS_SRC) $(wildcard html/*.html)
$(info copying fonts)
cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 $(DATA)/web/css/
+ cp -r $(SYNTAX_LIGHT_SRC) $(SYNTAX_LIGHT_TARGET)
+ cp -r $(SYNTAX_DARK_SRC) $(SYNTAX_DARK_TARGET)
+ cp -r $(CODEINPUT_SRC) $(CODEINPUT_TARGET)
$(info bundling css)
+ rm -f $(CSS_TARGET) $(CSS_FULLTARGET)
$(ESBUILD) --bundle css/base.css --outfile=$(CSS_TARGET) --external:remixicon.css --external:../fonts/hanken* --minify
npx tailwindcss -i $(CSS_TARGET) -o $(CSS_FULLTARGET) $(TAILWIND)
@@ -192,9 +209,9 @@ 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)
+$(COPY_TARGET): $(INLINE_TARGET) $(STATIC_SRC) $(LANG_SRC) $(CONFIG_BASE)
$(info copying $(CONFIG_BASE))
- cp $(CONFIG_BASE) $(DATA)/
+ go run scripts/yaml/main.go -in $(CONFIG_BASE) -out $(DATA)/$(shell basename $(CONFIG_BASE))
$(info copying crash page)
cp $(DATA)/crash.html $(DATA)/html/
$(info copying static data)
@@ -206,24 +223,32 @@ $(COPY_TARGET): $(INLINE_TARGET) $(STATIC_SRC) $(LANG_SRC)
cp -r lang $(DATA)/
cp LICENSE $(DATA)/
-BUILDDEPS := $(DATA) $(CONFIG_DEFAULT) $(EMAIL_TARGET) $(COPY_TARGET) $(SWAGGER_TARGET)
+BUILDDEPS := $(DATA) $(CONFIG_DEFAULT) $(EMAIL_TARGET) $(COPY_TARGET) $(SWAGGER_TARGET) $(INLINE_TARGET) $(CSS_FULLTARGET) $(TYPESCRIPT_TARGET)
precompile: $(BUILDDEPS)
-COMPDEPS =
+COMPDEPS = $(rebuildHashFile)
ifeq ($(INTERNAL), on)
- COMPDEPS = $(BUILDDEPS)
+ COMPDEPS = $(BUILDDEPS) $(rebuildHashFile)
endif
+$(rebuildHashFile):
+ $(info recording new flags $(rebuildVals))
+ rm -f $(DATA)/buildhash-*.txt
+ touch $(rebuildHashFile)
+
GO_SRC = $(shell find ./ -name "*.go")
-GO_TARGET = build/jfa-go
+GO_TARGET = build/jfa-go
$(GO_TARGET): $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
$(info Downloading deps)
$(GOBINARY) mod download
$(info Building)
mkdir -p build
- $(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o $(GO_TARGET)
+ $(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o $(GO_TARGET)
-all: $(BUILDDEPS) $(GO_TARGET)
+test: $(BUILDDEPS) $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
+ $(GOBINARY) test -ldflags="$(LDFLAGS)" $(TAGS) -p 1
+
+all: $(BUILDDEPS) $(GO_TARGET) $(rebuildHashFile)
compress:
upx --lzma $(GO_TARGET)
diff --git a/README.md b/README.md
index bfd678a..3994bbf 100644
--- a/README.md
+++ b/README.md
@@ -13,39 +13,28 @@
Studies mean I can't work on this project a lot outside of breaks, however I hope i'll be able to fit in general support and things like bug fixes into my time. New features and such will likely come in short bursts throughout the year (if they do at all).
#### Does/Will it still work?
-jfa-go currently works on Jellyfin 10.9.8, the latest version as of 31/07/2024. I should be able to maintain compatability in the future, unless any big changes occur.
+jfa-go currently works on Jellyfin 10.11.0, the latest version as of 21/10/25. I should be able to maintain 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.
- * [jfa-go now integrates with Jellyseerr, much like Ombi, but better.](https://github.com/hrfee/jfa-go/pull/351)
-* [Organizr](https://github.com/causefx/Organizr) doesn't focus on Jellyfin, but allows putting self-hosted services into "tabs" on a central page, and allows creating users, which lets one control who can access what.
---
-jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) (and [Emby](https://emby.media/) as 2nd class) that provides invite-based account creation as well as other features that make one's instance much easier to manage.
+
+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, discord, telegram or matrix
- * Granular control over invites: Validity period as well as number of uses can be specified.
- * Account profiles: Assign settings profiles to invites so new users have your predefined permissions, homescreen layout, etc. applied to their account on creation.
- * Password validation: Ensure users choose a strong password.
- * CAPTCHAs and contact method verificatoin can be enabled to avoid bots.
-* ⌛ User expiry: Specify a validity period, and new users accounts will be disabled/deleted after it. The period can be manually extended too.
-* 🔗 Ombi/Jellyseerr Integration: Automatically creates and synchronizes details for new accounts. Supports setting permissions with the Profiles feature. **Ombi integration use is risky, see [wiki](https://wiki.jfa-go.com/docs/ombi/)**.
-* Account management: Bulk or individually; apply settings, delete, disable/enable, send messages and much more.
-* 📣 Announcements: Bulk message your users with announcements about your server.
-* Telegram/Discord/Matrix Integration: Verify users via a chat bot, and send Password Resets, Announcements, etc. through it.
-* "My Account" Page: Allows users to reset their password, manage contact details, view their account expiry date, and send referrals. Can be customized with markdown.
-* Referrals: Users can be given special invites to send to their friends and families, similar to some invite-only services like Bluesky.
-* 🔑 Password resets: When users forget their passwords and request a change in Jellyfin, jfa-go reads the PIN from the created file and sends it straight to them via email/telegram.
- * Can also be done through the "My Account" page if enabled.
-* Admin Notifications: Get notified when someone creates an account, or an invite expires.
-* 🌓 Customizations
- * Customize emails with variables and markdown
- * Specify contact and help messages to appear in emails and pages
- * 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
@@ -57,7 +46,7 @@ jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jelly
#### 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.
diff --git a/activitysort.go b/activitysort.go
new file mode 100644
index 0000000..63d27c5
--- /dev/null
+++ b/activitysort.go
@@ -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:" 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
+}
diff --git a/api-activities.go b/api-activities.go
index f4b2581..df797ab 100644
--- a/api-activities.go
+++ b/api-activities.go
@@ -6,32 +6,6 @@ import (
"github.com/timshannon/badgerhold/v4"
)
-func stringToActivityType(v string) ActivityType {
- switch v {
- case "creation":
- return ActivityCreation
- case "deletion":
- return ActivityDeletion
- case "disabled":
- return ActivityDisabled
- case "enabled":
- return ActivityEnabled
- case "contactLinked":
- return ActivityContactLinked
- case "contactUnlinked":
- return ActivityContactUnlinked
- case "changePassword":
- return ActivityChangePassword
- case "resetPassword":
- return ActivityResetPassword
- case "createInvite":
- return ActivityCreateInvite
- case "deleteInvite":
- return ActivityDeleteInvite
- }
- return ActivityUnknown
-}
-
func activityTypeToString(v ActivityType) string {
switch v {
case ActivityCreation:
@@ -58,6 +32,32 @@ func activityTypeToString(v ActivityType) string {
return "unknown"
}
+func stringToActivityType(v string) ActivityType {
+ switch v {
+ case "creation":
+ return ActivityCreation
+ case "deletion":
+ return ActivityDeletion
+ case "disabled":
+ return ActivityDisabled
+ case "enabled":
+ return ActivityEnabled
+ case "contactLinked":
+ return ActivityContactLinked
+ case "contactUnlinked":
+ return ActivityContactUnlinked
+ case "changePassword":
+ return ActivityChangePassword
+ case "resetPassword":
+ return ActivityResetPassword
+ case "createInvite":
+ return ActivityCreateInvite
+ case "deleteInvite":
+ return ActivityDeleteInvite
+ }
+ return ActivityUnknown
+}
+
func stringToActivitySource(v string) ActivitySource {
switch v {
case "user":
@@ -86,73 +86,82 @@ func activitySourceToString(v ActivitySource) string {
return "anon"
}
+// generateActivitiesQuery generates a badgerhold query from QueryDTOs and search terms, which can then be searched, counted, or whatever you want.
+func (app *appContext) generateActivitiesQuery(req ServerFilterReqDTO) *badgerhold.Query {
+
+ var query *badgerhold.Query
+ if len(req.SearchTerms) != 0 {
+ query = ActivityMatchesSearchAsDBBaseQuery(req.SearchTerms)
+ } else {
+ query = nil
+ }
+
+ for _, q := range req.Queries {
+ nq := q.AsDBQuery(query)
+ if nq == nil {
+ nq = ActivityDBQueryFromSpecialField(app.jf, query, q)
+ }
+ query = nq
+ }
+
+ if query == nil {
+ query = &badgerhold.Query{}
+ }
+ return query
+}
+
// @Summary Get the requested set of activities, Paginated, filtered and sorted. Is a POST because of some issues I was having, ideally should be a GET.
// @Produce json
-// @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters"
+// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search parameters"
// @Success 200 {object} GetActivitiesRespDTO
// @Router /activity [post]
// @Security Bearer
-// @tags Activity
+// @tags Activity,Statistics
func (app *appContext) GetActivities(gc *gin.Context) {
- req := GetActivitiesDTO{}
+ req := ServerSearchReqDTO{}
gc.BindJSON(&req)
- query := &badgerhold.Query{}
- activityTypes := make([]interface{}, len(req.Type))
- for i, v := range req.Type {
- activityTypes[i] = stringToActivityType(v)
- }
- if len(activityTypes) != 0 {
- query = badgerhold.Where("Type").In(activityTypes...)
+ if req.SortByField == "" {
+ req.SortByField = USER_DEFAULT_SORT_FIELD
+ } else {
+ req.SortByField = activityDTONameToField(req.SortByField)
}
+ query := app.generateActivitiesQuery(req.ServerFilterReqDTO)
+
+ query = query.SortBy(req.SortByField)
if !req.Ascending {
query = query.Reverse()
}
- query = query.SortBy("Time")
-
- if req.Limit == 0 {
- req.Limit = 10
- }
-
query = query.Skip(req.Page * req.Limit).Limit(req.Limit)
var results []Activity
err := app.storage.db.Find(&results, query)
-
if err != nil {
app.err.Printf(lm.FailedDBReadActivities, err)
}
resp := GetActivitiesRespDTO{
Activities: make([]ActivityDTO, len(results)),
- LastPage: len(results) != req.Limit,
}
-
+ resp.LastPage = len(results) != req.Limit
for i, act := range results {
resp.Activities[i] = ActivityDTO{
- ID: act.ID,
- Type: activityTypeToString(act.Type),
- UserID: act.UserID,
- SourceType: activitySourceToString(act.SourceType),
- Source: act.Source,
- InviteCode: act.InviteCode,
- Value: act.Value,
- Time: act.Time.Unix(),
- IP: act.IP,
+ ID: act.ID,
+ Type: activityTypeToString(act.Type),
+ UserID: act.UserID,
+ SourceType: activitySourceToString(act.SourceType),
+ Source: act.Source,
+ InviteCode: act.InviteCode,
+ Value: act.Value,
+ Time: act.Time.Unix(),
+ IP: act.IP,
+ Username: act.MustGetUsername(app.jf),
+ SourceUsername: act.MustGetSourceUsername(app.jf),
}
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
- resp.Activities[i].Username = act.Value
+ // Username would've been in here, clear it to avoid confusion to the consumer
resp.Activities[i].Value = ""
- } else if user, err := app.jf.UserByID(act.UserID, false); err == nil {
- resp.Activities[i].Username = user.Name
- }
-
- if (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != "" {
- user, err := app.jf.UserByID(act.Source, false)
- if err == nil {
- resp.Activities[i].SourceUsername = user.Name
- }
}
}
@@ -173,12 +182,12 @@ func (app *appContext) DeleteActivity(gc *gin.Context) {
// @Summary Returns the total number of activities stored in the database.
// @Produce json
-// @Success 200 {object} GetActivityCountDTO
+// @Success 200 {object} PageCountDTO
// @Router /activity/count [get]
// @Security Bearer
-// @tags Activity
+// @tags Activity,Statistics
func (app *appContext) GetActivityCount(gc *gin.Context) {
- resp := GetActivityCountDTO{}
+ resp := PageCountDTO{}
var err error
resp.Count, err = app.storage.db.Count(&Activity{}, &badgerhold.Query{})
if err != nil {
@@ -186,3 +195,26 @@ func (app *appContext) GetActivityCount(gc *gin.Context) {
}
gc.JSON(200, resp)
}
+
+// @Summary Returns the total number of activities matching the given filtering. Fails silently.
+// @Produce json
+// @Param ServerFilterReqDTO body ServerFilterReqDTO true "search parameters"
+// @Success 200 {object} PageCountDTO
+// @Router /activity/count [post]
+// @Security Bearer
+// @tags Activity,Statistics
+func (app *appContext) GetFilteredActivityCount(gc *gin.Context) {
+ resp := PageCountDTO{}
+ req := ServerFilterReqDTO{}
+ gc.BindJSON(&req)
+
+ query := app.generateActivitiesQuery(req)
+
+ var err error
+ resp.Count, err = app.storage.db.Count(&Activity{}, query)
+ if err != nil {
+ // app.err.Printf(lm.FailedDBReadActivities, err)
+ resp.Count = 0
+ }
+ gc.JSON(200, resp)
+}
diff --git a/api-backups.go b/api-backups.go
index 109082e..3a4c6f3 100644
--- a/api-backups.go
+++ b/api-backups.go
@@ -1,11 +1,10 @@
package main
import (
+ "net/url"
"os"
"path/filepath"
"sort"
- "strings"
- "time"
"github.com/gin-gonic/gin"
lm "github.com/hrfee/jfa-go/logmessages"
@@ -31,11 +30,16 @@ func (app *appContext) CreateBackup(gc *gin.Context) {
// @Security Bearer
// @tags Backups
func (app *appContext) GetBackup(gc *gin.Context) {
- fname := gc.Param("fname")
+ 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.
- ok := (strings.HasPrefix(fname, BACKUP_PREFIX) || strings.HasPrefix(fname, BACKUP_UPLOAD_PREFIX+BACKUP_PREFIX)) && strings.HasSuffix(fname, BACKUP_SUFFIX)
- t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(fname, BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX), BACKUP_SUFFIX))
- if !ok || err != nil || t.IsZero() {
+ b := Backup{}
+ err = b.FromString(fname)
+ if err != nil || b.Date.IsZero() {
app.debug.Printf(lm.IgnoreInvalidFilename, fname, err)
respondBool(400, false, gc)
return
@@ -62,7 +66,8 @@ func (app *appContext) GetBackups(gc *gin.Context) {
resp.Backups[i].Name = item.Name()
fullpath := filepath.Join(path, item.Name())
resp.Backups[i].Path = fullpath
- resp.Backups[i].Date = backups.dates[i].Unix()
+ resp.Backups[i].Date = backups.info[i].Date.Unix()
+ resp.Backups[i].Commit = backups.info[i].Commit
fstat, err := os.Stat(fullpath)
if err == nil {
resp.Backups[i].Size = fileSize(fstat.Size())
@@ -81,9 +86,9 @@ func (app *appContext) GetBackups(gc *gin.Context) {
func (app *appContext) RestoreLocalBackup(gc *gin.Context) {
fname := gc.Param("fname")
// Hopefully this is enough to ensure the path isn't malicious. Hidden behind bearer auth anyway so shouldn't matter too much I guess.
- ok := strings.HasPrefix(fname, BACKUP_PREFIX) && strings.HasSuffix(fname, BACKUP_SUFFIX)
- t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(fname, BACKUP_PREFIX), BACKUP_SUFFIX))
- if !ok || err != nil || t.IsZero() {
+ b := Backup{}
+ err := b.FromString(fname)
+ if err != nil || b.Date.IsZero() {
app.debug.Printf(lm.IgnoreInvalidFilename, fname, err)
respondBool(400, false, gc)
return
@@ -110,7 +115,8 @@ func (app *appContext) RestoreBackup(gc *gin.Context) {
}
app.debug.Printf(lm.GetUpload, file.Filename)
path := app.config.Section("backups").Key("path").String()
- fullpath := filepath.Join(path, BACKUP_UPLOAD_PREFIX+BACKUP_PREFIX+time.Now().Local().Format(BACKUP_DATEFMT)+BACKUP_SUFFIX)
+ b := Backup{Upload: true}
+ fullpath := filepath.Join(path, b.String())
gc.SaveUploadedFile(file, fullpath)
app.debug.Printf(lm.Write, fullpath)
LOADBAK = fullpath
diff --git a/api-invites.go b/api-invites.go
index 34bbea7..0544b03 100644
--- a/api-invites.go
+++ b/api-invites.go
@@ -1,6 +1,7 @@
package main
import (
+ "errors"
"fmt"
"strconv"
"strings"
@@ -11,6 +12,7 @@ import (
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/itchyny/timefmt-go"
"github.com/lithammer/shortuuid/v3"
+ "github.com/timshannon/badgerhold/v4"
)
const (
@@ -102,6 +104,7 @@ func (app *appContext) deleteExpiredInvite(data Invite) {
if ok {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(data.ReferrerJellyfinID, user)
+ app.InvalidateWebUserCache()
}
}
wait := app.sendAdminExpiryNotification(data)
@@ -122,7 +125,7 @@ func (app *appContext) deleteExpiredInvite(data Invite) {
func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup {
notify := data.Notify
- if !emailEnabled || !app.config.Section("notifications").Key("enabled").MustBool(false) || len(notify) != 0 {
+ if !emailEnabled || !app.config.Section("notifications").Key("enabled").MustBool(false) || len(notify) == 0 {
return nil
}
var wait sync.WaitGroup
@@ -133,7 +136,7 @@ func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup
wait.Add(1)
go func(addr string) {
defer wait.Done()
- msg, err := app.email.constructExpiry(data.Code, data, app, false)
+ msg, err := app.email.constructExpiry(data, false)
if err != nil {
app.err.Printf(lm.FailedConstructExpiryAdmin, data.Code, err)
} else {
@@ -154,6 +157,184 @@ func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup
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"
@@ -194,45 +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 := ""
- if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
- users := app.discord.GetUsers(req.SendTo)
- if len(users) == 0 {
- invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipNoUser, req.SendTo)
- } else if len(users) > 1 {
- invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipMultiUser, req.SendTo)
- } else {
- invite.SendTo = req.SendTo
- addressValid = true
- discord = users[0].User.ID
- }
- } else if emailEnabled {
- addressValid = true
- invite.SendTo = req.SendTo
- }
- if addressValid {
- msg, err := app.email.constructInvite(invite.Code, invite, app, false)
- if err != nil {
- // Slight misuse of the template
- invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, req.SendTo, err)
-
- app.err.Printf(lm.FailedConstructInviteMessage, invite.Code, err)
- } else {
- var err error
- if discord != "" {
- err = app.discord.SendDM(msg, discord)
- } else {
- err = app.email.send(msg, req.SendTo)
- }
- if err != nil {
- invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, err)
- app.err.Println(invite.SendTo)
- } else {
- app.info.Printf(lm.SentInviteMessage, invite.Code, req.SendTo)
- }
- }
+ if req.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 != "" {
@@ -258,38 +406,81 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
respondBool(200, true, gc)
}
+// @Summary Get the number of invites stored in the database.
+// @Produce json
+// @Success 200 {object} PageCountDTO
+// @Router /invites/count [get]
+// @Security Bearer
+// @tags Invites,Statistics
+func (app *appContext) GetInviteCount(gc *gin.Context) {
+ resp := PageCountDTO{}
+ var err error
+ resp.Count, err = app.storage.db.Count(&Invite{}, badgerhold.Where("IsReferral").Eq(false))
+ if err != nil {
+ resp.Count = 0
+ }
+ gc.JSON(200, resp)
+}
+
+// @Summary Get the number of invites stored in the database that have been used (but are still valid).
+// @Produce json
+// @Success 200 {object} PageCountDTO
+// @Router /invites/count/used [get]
+// @Security Bearer
+// @tags Invites,Statistics
+func (app *appContext) GetInviteUsedCount(gc *gin.Context) {
+ resp := PageCountDTO{}
+ var err error
+ resp.Count, err = app.storage.db.Count(&Invite{}, badgerhold.Where("IsReferral").Eq(false).And("UsedBy").MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
+ field := ra.Field()
+ switch usedBy := field.(type) {
+ case [][]string:
+ return len(usedBy) > 0, nil
+ default:
+ return false, nil
+ }
+ }))
+ if err != nil {
+ resp.Count = 0
+ }
+ gc.JSON(200, resp)
+}
+
// @Summary Get invites.
// @Produce json
// @Success 200 {object} getInvitesDTO
// @Router /invites [get]
// @Security Bearer
-// @tags Invites
+// @tags Invites,Statistics
func (app *appContext) GetInvites(gc *gin.Context) {
- currentTime := time.Now()
+ // currentTime := time.Now()
app.checkInvites()
var invites []inviteDTO
for _, inv := range app.storage.GetInvites() {
if inv.IsReferral {
continue
}
- years, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
- months += years * 12
+ // years, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
+ // months += years * 12
invite := inviteDTO{
- Code: inv.Code,
- Months: months,
- Days: days,
- Hours: hours,
- Minutes: minutes,
- UserExpiry: inv.UserExpiry,
- UserMonths: inv.UserMonths,
- UserDays: inv.UserDays,
- UserHours: inv.UserHours,
- UserMinutes: inv.UserMinutes,
- Created: inv.Created.Unix(),
- Profile: inv.Profile,
- NoLimit: inv.NoLimit,
- Label: inv.Label,
- UserLabel: inv.UserLabel,
+ 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{}
@@ -297,7 +488,7 @@ func (app *appContext) GetInvites(gc *gin.Context) {
// These used to be stored formatted instead of as a unix timestamp.
unix, err := strconv.ParseInt(pair[1], 10, 64)
if err != nil {
- date, err := timefmt.Parse(pair[1], app.datePattern+" "+app.timePattern)
+ date, err := timefmt.Parse(pair[1], datePattern+" "+timePattern)
if err != nil {
app.err.Printf(lm.FailedParseTime, err)
}
@@ -310,6 +501,9 @@ 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
}
@@ -322,10 +516,12 @@ 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 = ¬ifyExpiry
}
if _, ok = inv.Notify[addressOrID]["notify-creation"]; ok {
- invite.NotifyCreation = inv.Notify[addressOrID]["notify-creation"]
+ notifyCreation := inv.Notify[addressOrID]["notify-creation"]
+ invite.NotifyCreation = ¬ifyCreation
}
}
}
@@ -337,82 +533,54 @@ func (app *appContext) GetInvites(gc *gin.Context) {
gc.JSON(200, resp)
}
-// @Summary Set profile for an invite
-// @Produce json
-// @Param inviteProfileDTO body inviteProfileDTO true "Invite profile object"
-// @Success 200 {object} boolResponse
-// @Failure 500 {object} stringResponse
-// @Router /invites/profile [post]
-// @Security Bearer
-// @tags Invites
-func (app *appContext) SetProfile(gc *gin.Context) {
- var req inviteProfileDTO
- gc.BindJSON(&req)
+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(lm.FailedGetProfile, 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 {
- invite, ok := app.storage.GetInvitesKey(code)
- if !ok {
- msg := fmt.Sprintf(lm.InvalidInviteCode, code)
- app.err.Println(msg)
- respond(400, msg, 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(lm.FailedGetContactMethod, gc.GetString("jfId"))
- respond(500, fmt.Sprintf(lm.FailedGetContactMethod, "admin"), gc)
- return
- }
- address = gc.GetString("jfId")
- } else {
- address = app.config.Section("ui").Key("email").String()
- }
- if invite.Notify == nil {
- invite.Notify = map[string]map[string]bool{}
- }
- if _, ok := invite.Notify[address]; !ok {
- invite.Notify[address] = map[string]bool{}
- } /*else {
- if _, ok := invite.Notify[address]["notify-expiry"]; !ok {
- */
- for _, notifyType := range []string{"notify-expiry", "notify-creation"} {
- if _, ok := settings[notifyType]; ok && invite.Notify[address][notifyType] != settings[notifyType] {
- invite.Notify[address][notifyType] = settings[notifyType]
- app.debug.Printf(lm.SetAdminNotify, notifyType, settings[notifyType], address)
- changed = true
- }
- }
- if changed {
- app.storage.SetInvitesKey(code, invite)
+ 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
}
}
+ ok = true
+ return
}
// @Summary Delete an invite.
diff --git a/api-jellyseerr.go b/api-jellyseerr.go
index 232b6e5..283ad28 100644
--- a/api-jellyseerr.go
+++ b/api-jellyseerr.go
@@ -6,6 +6,7 @@ import (
"strconv"
"github.com/gin-gonic/gin"
+ "github.com/hrfee/jfa-go/common"
"github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages"
)
@@ -61,7 +62,7 @@ func (app *appContext) SetJellyseerrProfile(gc *gin.Context) {
}
u, err := app.js.UserByID(jellyseerrID)
if err != nil {
- app.err.Printf(lm.FailedGetUsers, lm.Jellyseerr, err)
+ app.err.Printf(lm.FailedGetUser, strconv.FormatInt(jellyseerrID, 10), lm.Jellyseerr, err)
respond(500, "Couldn't get user", gc)
return
}
@@ -111,6 +112,9 @@ func (js *JellyseerrWrapper) ImportUser(jellyfinID string, req newUserDTO, profi
return
}
ok = true
+ if !profile.Jellyseerr.Enabled {
+ return
+ }
err = js.ApplyTemplateToUser(jellyfinID, profile.Jellyseerr.User)
if err != nil {
err = fmt.Errorf(lm.FailedApplyTemplate, "user", lm.Jellyseerr, jellyfinID, err)
@@ -124,29 +128,62 @@ func (js *JellyseerrWrapper) ImportUser(jellyfinID string, req newUserDTO, profi
return
}
-func (js *JellyseerrWrapper) AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (err error) {
+func (js *JellyseerrWrapper) SetContactMethods(jellyfinID string, email *string, discord *DiscordUser, telegram *TelegramUser, contactPrefs *common.ContactPreferences) (err error) {
_, err = js.MustGetUser(jellyfinID)
if err != nil {
return
}
- contactMethods := map[jellyseerr.NotificationsField]any{}
- if emailEnabled {
- err = js.ModifyMainUserSettings(jellyfinID, jellyseerr.MainUserSettings{Email: req.Email})
- if err != nil {
- // FIXME: This is a little ugly, considering all other errors are unformatted
- err = fmt.Errorf(lm.FailedSetEmailAddress, lm.Jellyseerr, jellyfinID, err)
- return
- } else {
- contactMethods[jellyseerr.FieldEmailEnabled] = req.EmailContact
+ if contactPrefs == nil {
+ contactPrefs = &common.ContactPreferences{
+ Email: nil,
+ Discord: nil,
+ Telegram: nil,
+ Matrix: nil,
}
}
- if discordEnabled && discord != nil {
- contactMethods[jellyseerr.FieldDiscord] = discord.ID
- contactMethods[jellyseerr.FieldDiscordEnabled] = req.DiscordContact
+ contactMethods := map[jellyseerr.NotificationsField]any{}
+ if emailEnabled {
+ if contactPrefs.Email != nil {
+ contactMethods[jellyseerr.FieldEmailEnabled] = *(contactPrefs.Email)
+ } else if email != nil && *email != "" {
+ contactMethods[jellyseerr.FieldEmailEnabled] = true
+ }
+ if email != nil {
+ err = js.ModifyMainUserSettings(jellyfinID, jellyseerr.MainUserSettings{Email: *email})
+ if err != nil {
+ // FIXME: This is a little ugly, considering all other errors are unformatted
+ err = fmt.Errorf(lm.FailedSetEmailAddress, lm.Jellyseerr, jellyfinID, err)
+ return
+ }
+ }
}
- if telegramEnabled && discord != nil {
- contactMethods[jellyseerr.FieldTelegram] = telegram.ChatID
- contactMethods[jellyseerr.FieldTelegramEnabled] = req.TelegramContact
+ if discordEnabled {
+ if contactPrefs.Discord != nil {
+ contactMethods[jellyseerr.FieldDiscordEnabled] = *(contactPrefs.Discord)
+ } else if discord != nil && discord.ID != "" {
+ contactMethods[jellyseerr.FieldDiscordEnabled] = true
+ }
+ if discord != nil {
+ contactMethods[jellyseerr.FieldDiscord] = discord.ID
+ // Whether this is still necessary or not, i don't know.
+ if discord.ID == "" {
+ contactMethods[jellyseerr.FieldDiscord] = jellyseerr.BogusIdentifier
+ }
+ }
+ }
+ if telegramEnabled {
+ if contactPrefs.Telegram != nil {
+ contactMethods[jellyseerr.FieldTelegramEnabled] = *(contactPrefs.Telegram)
+ } else if telegram != nil && telegram.ChatID != 0 {
+ contactMethods[jellyseerr.FieldTelegramEnabled] = true
+ }
+ if telegram != nil {
+ contactMethods[jellyseerr.FieldTelegram] = strconv.FormatInt(telegram.ChatID, 10)
+ // Whether this is still necessary or not, i don't know.
+ if telegram.ChatID == 0 {
+ contactMethods[jellyseerr.FieldTelegram] = jellyseerr.BogusIdentifier
+ }
+ }
}
if len(contactMethods) > 0 {
err = js.ModifyNotifications(jellyfinID, contactMethods)
diff --git a/api-messages.go b/api-messages.go
index cb4f67f..8f8e41c 100644
--- a/api-messages.go
+++ b/api-messages.go
@@ -1,11 +1,11 @@
package main
import (
- "strings"
+ "net/url"
"time"
"github.com/gin-gonic/gin"
- "github.com/hrfee/jfa-go/jellyseerr"
+ "github.com/hrfee/jfa-go/common"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
@@ -23,25 +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},
- "UserExpiryAdjusted": {Name: app.storage.lang.Email[lang].UserExpiryAdjusted["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpiryAdjusted").Enabled},
- "InviteEmail": {Name: app.storage.lang.Email[lang].InviteEmail["name"], Enabled: app.storage.MustGetCustomContentKey("InviteEmail").Enabled},
- "WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.MustGetCustomContentKey("WelcomeEmail").Enabled},
- "EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.MustGetCustomContentKey("EmailConfirmation").Enabled},
- "UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpired").Enabled},
- "UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.MustGetCustomContentKey("UserLogin").Enabled},
- "UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("UserPage").Enabled},
- "PostSignupCard": {Name: app.storage.lang.Admin[adminLang].Strings["postSignupCard"], Enabled: app.storage.MustGetCustomContentKey("PostSignupCard").Enabled, Description: app.storage.lang.Admin[adminLang].Strings["postSignupCardDescription"]},
+ list := emailListDTO{}
+ for _, cc := range customContent {
+ if cc.ContentType == CustomTemplate {
+ continue
+ }
+ ccDescription := emailListEl{Name: cc.DisplayName(&app.storage.lang, lang), Enabled: app.storage.MustGetCustomContentKey(cc.Name).Enabled}
+ if cc.Description != nil {
+ ccDescription.Description = cc.Description(&app.storage.lang, lang)
+ }
+ list[cc.Name] = ccDescription
}
filter := gc.Query("filter")
@@ -73,11 +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)
@@ -123,146 +115,92 @@ func (app *appContext) SetCustomMessageState(gc *gin.Context) {
// @Security Bearer
// @tags Configuration
func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
- lang := app.storage.lang.chosenEmailLang
id := gc.Param("id")
- var content string
var err error
- var msg *Message
- var variables []string
- var conditionals []string
- var values map[string]interface{}
- username := app.storage.lang.Email[lang].Strings.get("username")
- emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress")
- customMessage, ok := app.storage.GetCustomContentKey(id)
+ contentInfo, ok := customContent[id]
+ // FIXME: Add announcement to customContent
if !ok && id != "Announcement" {
app.err.Printf(lm.FailedGetCustomMessage, id)
respondBool(400, false, gc)
return
}
- if id == "WelcomeEmail" {
- conditionals = []string{"{yourAccountWillExpire}"}
- customMessage.Conditionals = conditionals
- } else if id == "UserPage" {
- variables = []string{"{username}"}
- customMessage.Variables = variables
- } else if id == "UserLogin" {
- variables = []string{}
- customMessage.Variables = variables
- } else if id == "PostSignupCard" {
- variables = []string{"{username}", "{myAccountURL}"}
- customMessage.Variables = variables
+
+ content, ok := app.storage.GetCustomContentKey(id)
+
+ if contentInfo.Variables == nil {
+ contentInfo.Variables = []string{}
+ }
+ if contentInfo.Conditionals == nil {
+ contentInfo.Conditionals = []string{}
+ }
+ if contentInfo.Placeholders == nil {
+ contentInfo.Placeholders = map[string]any{}
}
- content = customMessage.Content
- noContent := content == ""
- if !noContent {
- variables = customMessage.Variables
+ // Generate content from real email, if the user hasn't already customised this message.
+ if content.Content == "" {
+ var msg *Message
+ switch id {
+ // FIXME: Add announcement to customContent
+ case "UserCreated":
+ msg, err = app.email.constructCreated("", "", time.Time{}, Invite{}, true)
+ case "InviteExpiry":
+ msg, err = app.email.constructExpiry(Invite{}, true)
+ case "PasswordReset":
+ msg, err = app.email.constructReset(PasswordReset{}, true)
+ case "UserDeleted":
+ msg, err = app.email.constructDeleted("", "", true)
+ case "UserDisabled":
+ msg, err = app.email.constructDisabled("", "", true)
+ case "UserEnabled":
+ msg, err = app.email.constructEnabled("", "", true)
+ case "UserExpiryAdjusted":
+ msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", true)
+ case "ExpiryReminder":
+ msg, err = app.email.constructExpiryReminder("", time.Now().AddDate(0, 0, 3), true)
+ case "InviteEmail":
+ msg, err = app.email.constructInvite(&Invite{Code: ""}, true)
+ case "WelcomeEmail":
+ msg, err = app.email.constructWelcome("", time.Time{}, true)
+ case "EmailConfirmation":
+ msg, err = app.email.constructConfirmation("", "", "", true)
+ case "UserExpired":
+ msg, err = app.email.constructUserExpired("", true)
+ case "Announcement":
+ case "UserPage":
+ case "UserLogin":
+ case "PostSignupCard":
+ case "PreSignupCard":
+ // These don't have any example content
+ msg = nil
+ }
+ if err != nil {
+ respondBool(500, false, gc)
+ return
+ }
+ if msg != nil {
+ content.Content = msg.Text
+ }
}
- switch id {
- case "Announcement":
- // Just send the email html
- content = ""
- case "UserCreated":
- if noContent {
- msg, err = app.email.constructCreated("", "", "", Invite{}, app, true)
- }
- values = app.email.createdValues("xxxxxx", username, emailAddress, Invite{}, app, false)
- case "InviteExpiry":
- if noContent {
- msg, err = app.email.constructExpiry("", Invite{}, app, true)
- }
- values = app.email.expiryValues("xxxxxx", Invite{}, app, false)
- case "PasswordReset":
- if noContent {
- msg, err = app.email.constructReset(PasswordReset{}, app, true)
- }
- values = app.email.resetValues(PasswordReset{Pin: "12-34-56", Username: username}, app, false)
- case "UserDeleted":
- if noContent {
- msg, err = app.email.constructDeleted("", app, true)
- }
- values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
- case "UserDisabled":
- if noContent {
- msg, err = app.email.constructDisabled("", app, true)
- }
- values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
- case "UserEnabled":
- if noContent {
- msg, err = app.email.constructEnabled("", app, true)
- }
- values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
- case "UserExpiryAdjusted":
- if noContent {
- msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", app, true)
- }
- values = app.email.expiryAdjustedValues(username, time.Now(), app.storage.lang.Email[lang].Strings.get("reason"), app, false, true)
- case "InviteEmail":
- if noContent {
- msg, err = app.email.constructInvite("", Invite{}, app, true)
- }
- values = app.email.inviteValues("xxxxxx", Invite{}, app, false)
- case "WelcomeEmail":
- if noContent {
- msg, err = app.email.constructWelcome("", time.Time{}, app, true)
- }
- values = app.email.welcomeValues(username, time.Now(), app, false, true)
- case "EmailConfirmation":
- if noContent {
- msg, err = app.email.constructConfirmation("", "", "", app, true)
- }
- values = app.email.confirmationValues("xxxxxx", username, "xxxxxx", app, false)
- case "UserExpired":
- if noContent {
- msg, err = app.email.constructUserExpired(app, true)
- }
- values = app.email.userExpiredValues(app, false)
- case "UserLogin", "UserPage", "PostSignupCard":
- values = map[string]interface{}{}
- }
- if err != nil {
- respondBool(500, false, gc)
- return
- }
- if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" && id != "PostSignupCard" {
- content = msg.Text
- variables = make([]string, strings.Count(content, "{"))
- i := 0
- found := false
- buf := ""
- for _, c := range content {
- if !found && c != '{' && c != '}' {
- continue
- }
- found = true
- buf += string(c)
- if c == '}' {
- found = false
- variables[i] = buf
- buf = ""
- i++
- }
- }
- customMessage.Variables = variables
- }
- if variables == nil {
- variables = []string{}
- }
- app.storage.SetCustomContentKey(id, customMessage)
- var mail *Message
- if id != "UserLogin" && id != "UserPage" && id != "PostSignupCard" {
- mail, err = app.email.constructTemplate("", "", app)
+
+ var mail *Message = nil
+ if contentInfo.ContentType == CustomMessage {
+ mail, err = app.email.construct(EmptyCustomContent, CustomContent{
+ Name: EmptyCustomContent.Name,
+ Enabled: true,
+ Content: "",
+ }, map[string]any{})
if err != nil {
respondBool(500, false, gc)
return
}
} else if id == "PostSignupCard" {
- // Jankiness follows.
+ // Specific workaround for the currently-unique "Post signup card".
// Source content from "Success Message" setting.
- if noContent {
- content = "# " + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("successHeader") + "\n" + app.config.Section("ui").Key("success_message").String()
+ if content.Content == "" {
+ content.Content = "# " + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("successHeader") + "\n" + app.config.Section("ui").Key("success_message").String()
if app.config.Section("user_page").Key("enabled").MustBool(false) {
- content += "\n\n
\n" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.template("userPageSuccessMessage", tmpl{
+ content.Content += "\n\n
\n" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.template("userPageSuccessMessage", tmpl{
"myAccount": "[" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("myAccount") + "]({myAccountURL})",
})
}
@@ -271,13 +209,15 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
HTML: " ",
}
mail.Markdown = mail.HTML
- } else {
+ } else if contentInfo.ContentType == CustomCard {
mail = &Message{
HTML: "",
}
mail.Markdown = mail.HTML
+ } else {
+ app.err.Printf("unknown custom content type %d", contentInfo.ContentType)
}
- gc.JSON(200, customEmailDTO{Content: content, Variables: variables, Conditionals: conditionals, Values: values, HTML: mail.HTML, Plaintext: mail.Text})
+ gc.JSON(200, customEmailDTO{Content: content.Content, Variables: contentInfo.Variables, Conditionals: contentInfo.Conditionals, Values: contentInfo.Placeholders, HTML: mail.HTML, Plaintext: mail.Text})
}
// @Summary Returns a new Telegram verification PIN, and the bot username.
@@ -316,29 +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)
- if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
- jellyseerr.FieldTelegram: tgUser.ChatID,
- jellyseerr.FieldTelegramEnabled: tgUser.Contact,
- }); err != nil {
- app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
+ for _, tps := range app.thirdPartyServices {
+ if err := tps.SetContactMethods(req.ID, nil, nil, &tgUser, &common.ContactPreferences{
+ Telegram: &tgUser.Contact,
+ }); err != nil {
+ app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
+ }
}
- linkExistingOmbiDiscordTelegram(app)
+ app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
// @Summary Sets whether to notify a user through telegram/discord/matrix/email or not.
// @Produce json
-// @Param SetContactMethodsDTO body SetContactMethodsDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
+// @Param SetContactPreferencesDTO body SetContactPreferencesDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
// @Success 200 {object} boolResponse
// @Success 400 {object} boolResponse
// @Success 500 {object} boolResponse
@@ -346,24 +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) {
- jsPrefs := map[jellyseerr.NotificationsField]any{}
+func (app *appContext) setContactPreferences(req SetContactPreferencesDTO, gc *gin.Context) {
+ contactPrefs := common.ContactPreferences{}
if tgUser, ok := app.storage.GetTelegramKey(req.ID); ok {
change := tgUser.Contact != req.Telegram
tgUser.Contact = req.Telegram
app.storage.SetTelegramKey(req.ID, tgUser)
if change {
app.debug.Printf(lm.SetContactPrefForService, lm.Telegram, tgUser.Username, req.Telegram)
- jsPrefs[jellyseerr.FieldTelegramEnabled] = req.Telegram
+ contactPrefs.Telegram = &req.Telegram
}
}
if dcUser, ok := app.storage.GetDiscordKey(req.ID); ok {
@@ -372,7 +315,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
app.storage.SetDiscordKey(req.ID, dcUser)
if change {
app.debug.Printf(lm.SetContactPrefForService, lm.Discord, dcUser.Username, req.Discord)
- jsPrefs[jellyseerr.FieldDiscordEnabled] = req.Discord
+ contactPrefs.Discord = &req.Discord
}
}
if mxUser, ok := app.storage.GetMatrixKey(req.ID); ok {
@@ -381,6 +324,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
app.storage.SetMatrixKey(req.ID, mxUser)
if change {
app.debug.Printf(lm.SetContactPrefForService, lm.Matrix, mxUser.UserID, req.Matrix)
+ contactPrefs.Matrix = &req.Matrix
}
}
if email, ok := app.storage.GetEmailsKey(req.ID); ok {
@@ -389,15 +333,16 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
app.storage.SetEmailsKey(req.ID, email)
if change {
app.debug.Printf(lm.SetContactPrefForService, lm.Email, email.Addr, req.Email)
- jsPrefs[jellyseerr.FieldEmailEnabled] = req.Email
+ contactPrefs.Email = &req.Email
}
}
- if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
- err := app.js.ModifyNotifications(req.ID, jsPrefs)
- if err != nil {
- app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
+
+ for _, tps := range app.thirdPartyServices {
+ if err := tps.SetContactMethods(req.ID, nil, nil, nil, &contactPrefs); err != nil {
+ app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
}
}
+ app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -414,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
@@ -438,7 +383,7 @@ func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) {
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
@@ -462,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
@@ -489,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
@@ -528,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
@@ -626,6 +571,7 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
Lang: "en-us",
Contact: true,
})
+ app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -639,8 +585,9 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
// @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
}
@@ -680,11 +627,12 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
app.storage.SetDiscordKey(req.JellyfinID, user)
- if err := app.js.ModifyNotifications(req.JellyfinID, map[jellyseerr.NotificationsField]any{
- jellyseerr.FieldDiscord: req.DiscordID,
- jellyseerr.FieldDiscordEnabled: true,
- }); err != nil {
- app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
+ for _, tps := range app.thirdPartyServices {
+ if err := tps.SetContactMethods(req.JellyfinID, nil, &user, nil, &common.ContactPreferences{
+ Discord: &user.Contact,
+ }); err != nil {
+ app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
+ }
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
@@ -697,6 +645,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
}, gc, false)
linkExistingOmbiDiscordTelegram(app)
+ app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -717,12 +666,14 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
} */
app.storage.DeleteDiscordKey(req.ID)
- // May not actually remove Discord ID, but should disable interaction.
- if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
- jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier,
- jellyseerr.FieldDiscordEnabled: false,
- }); err != nil {
- app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
+ contact := false
+
+ for _, tps := range app.thirdPartyServices {
+ if err := tps.SetContactMethods(req.ID, nil, EmptyDiscordUser(), nil, &common.ContactPreferences{
+ Discord: &contact,
+ }); err != nil {
+ app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
+ }
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
@@ -734,6 +685,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
Time: time.Now(),
}, gc, false)
+ app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -754,11 +706,14 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
} */
app.storage.DeleteTelegramKey(req.ID)
- if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
- jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier,
- jellyseerr.FieldTelegramEnabled: false,
- }); err != nil {
- app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
+ contact := false
+
+ for _, tps := range app.thirdPartyServices {
+ if err := tps.SetContactMethods(req.ID, nil, nil, EmptyTelegramUser(), &common.ContactPreferences{
+ Telegram: &contact,
+ }); err != nil {
+ app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
+ }
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
@@ -770,6 +725,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
Time: time.Now(),
}, gc, false)
+ app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -799,5 +755,6 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) {
Time: time.Now(),
}, gc, false)
+ app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
diff --git a/api-ombi.go b/api-ombi.go
index 4f5a2d1..76b3828 100644
--- a/api-ombi.go
+++ b/api-ombi.go
@@ -8,7 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/common"
lm "github.com/hrfee/jfa-go/logmessages"
- "github.com/hrfee/jfa-go/ombi"
+ ombiLib "github.com/hrfee/jfa-go/ombi"
"github.com/hrfee/mediabrowser"
)
@@ -147,7 +147,8 @@ func (app *appContext) DeleteOmbiProfile(gc *gin.Context) {
}
type OmbiWrapper struct {
- *ombi.Ombi
+ OmbiUserByJfID func(jfID string) (map[string]interface{}, error)
+ *ombiLib.Ombi
}
func (ombi *OmbiWrapper) applyProfile(user map[string]interface{}, profile map[string]interface{}) (err error) {
@@ -189,23 +190,69 @@ func (ombi *OmbiWrapper) ImportUser(jellyfinID string, req newUserDTO, profile P
return
}
-func (ombi *OmbiWrapper) AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (err error) {
- var ombiUser map[string]interface{}
- ombiUser, err = ombi.getUser(req.Username, req.Email)
+func (ombi *OmbiWrapper) SetContactMethods(jellyfinID string, email *string, discord *DiscordUser, telegram *TelegramUser, contactPrefs *common.ContactPreferences) (err error) {
+ ombiUser, err := ombi.OmbiUserByJfID(jellyfinID)
if err != nil {
return
}
- if discordEnabled || telegramEnabled {
- dID := ""
- tUser := ""
+ if contactPrefs == nil {
+ contactPrefs = &common.ContactPreferences{
+ Email: nil,
+ Discord: nil,
+ Telegram: nil,
+ Matrix: nil,
+ }
+ }
+ if emailEnabled && email != nil {
+ ombiUser["emailAddress"] = *email
+ err = ombi.ModifyUser(ombiUser)
+ if err != nil {
+ // FIXME: This is a little ugly, considering all other errors are unformatted
+ err = fmt.Errorf(lm.FailedSetEmailAddress, lm.Ombi, jellyfinID, err)
+ return
+ }
+ }
+
+ data := make([]ombiLib.NotificationPref, 0, 2)
+ if discordEnabled {
+ pref := ombiLib.NotificationPref{
+ Agent: ombiLib.NotifAgentDiscord,
+ UserID: ombiUser["id"].(string),
+ }
+ valid := false
+ if contactPrefs.Discord != nil {
+ pref.Enabled = *(contactPrefs.Discord)
+ valid = true
+ } else if discord != nil && discord.ID != "" {
+ pref.Enabled = true
+ valid = true
+ }
if discord != nil {
- dID = discord.ID
+ pref.Value = discord.ID
+ valid = true
+ }
+ if valid {
+ data = append(data, pref)
+ }
+ }
+ if telegramEnabled && telegram != nil {
+ pref := ombiLib.NotificationPref{
+ Agent: ombiLib.NotifAgentTelegram,
+ UserID: ombiUser["id"].(string),
+ }
+ if contactPrefs.Telegram != nil {
+ pref.Enabled = *(contactPrefs.Telegram)
+ } else if telegram != nil && telegram.Username != "" {
+ pref.Enabled = true
}
if telegram != nil {
- tUser = telegram.Username
+ pref.Value = telegram.Username
}
+ data = append(data, pref)
+ }
+ if len(data) > 0 {
var resp string
- resp, err = ombi.SetNotificationPrefs(ombiUser, dID, tUser)
+ resp, err = ombi.SetNotificationPrefs(ombiUser, data)
if err != nil {
if resp != "" {
err = fmt.Errorf("%v, %s", err, resp)
diff --git a/api-profiles.go b/api-profiles.go
index 5dbfc01..907ec99 100644
--- a/api-profiles.go
+++ b/api-profiles.go
@@ -2,6 +2,9 @@ package main
import (
"fmt"
+ "net/http"
+ "net/url"
+ "strconv"
"time"
"github.com/gin-gonic/gin"
@@ -69,6 +72,68 @@ func (app *appContext) GetProfiles(gc *gin.Context) {
gc.JSON(200, out)
}
+// @Summary Get the raw values stored in a profile (Configuration, Policy, Jellyseerr/Ombi if applicable, etc.).
+// @Produce json
+// @Success 200 {object} ProfileDTO
+// @Failure 400 {object} boolResponse
+// @Param name path string true "name of profile (url encoded if necessary)"
+// @Router /profiles/raw/{name} [get]
+// @Security Bearer
+// @tags Profiles & Settings
+func (app *appContext) GetRawProfile(gc *gin.Context) {
+ escapedName := gc.Param("name")
+ name, err := url.QueryUnescape(escapedName)
+ if err != nil {
+ respondBool(400, false, gc)
+ return
+ }
+ if profile, ok := app.storage.GetProfileKey(name); ok {
+ gc.JSON(200, profile.ProfileDTO)
+ return
+ }
+ respondBool(400, false, gc)
+}
+
+// @Summary Update the raw data of a profile (Configuration, Policy, Jellyseerr/Ombi if applicable, etc.).
+// @Produce json
+// @Param ProfileDTO body ProfileDTO true "Raw profile data (all of it, do not omit anything)"
+// @Success 204 {object} boolResponse
+// @Success 201 {object} boolResponse
+// @Failure 400 {object} boolResponse
+// @Router /profiles/raw/{name} [put]
+// @Security Bearer
+// @tags Profiles & Settings
+func (app *appContext) ReplaceRawProfile(gc *gin.Context) {
+ escapedName := gc.Param("name")
+ name, err := url.QueryUnescape(escapedName)
+ if err != nil {
+ respondBool(400, false, gc)
+ return
+ }
+ existingProfile, ok := app.storage.GetProfileKey(name)
+ if !ok {
+ respondBool(400, false, gc)
+ return
+ }
+ var req ProfileDTO
+ gc.BindJSON(&req)
+ existingProfile.ProfileDTO = req
+ if req.Name == "" {
+ req.Name = name
+ }
+ status := http.StatusNoContent
+ app.storage.SetProfileKey(req.Name, existingProfile)
+ if req.Name != name {
+ // Name change
+ app.storage.DeleteProfileKey(name)
+ if discordEnabled {
+ app.discord.UpdateCommands()
+ }
+ status = http.StatusCreated
+ }
+ respondBool(status, true, gc)
+}
+
// @Summary Set the default profile to use.
// @Produce json
// @Param profileChangeDTO body profileChangeDTO true "Default profile object"
@@ -110,7 +175,7 @@ func (app *appContext) SetDefaultProfile(gc *gin.Context) {
func (app *appContext) CreateProfile(gc *gin.Context) {
var req newProfileDTO
gc.BindJSON(&req)
- app.jf.CacheExpiry = time.Now()
+ app.InvalidateJellyfinCache()
user, err := app.jf.UserByID(req.ID, false)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
@@ -119,7 +184,7 @@ func (app *appContext) CreateProfile(gc *gin.Context) {
}
profile := Profile{
FromUser: user.Name,
- Policy: user.Policy,
+ ProfileDTO: ProfileDTO{Policy: user.Policy},
Homescreen: req.Homescreen,
}
app.debug.Printf(lm.CreateProfileFromUser, user.Name)
@@ -132,6 +197,21 @@ func (app *appContext) CreateProfile(gc *gin.Context) {
return
}
}
+ if req.Jellyseerr && app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
+ user, err := app.js.MustGetUser(req.ID)
+ if err != nil {
+ app.err.Printf(lm.FailedGetUser, user.Name(), lm.Jellyseerr, err)
+ } else {
+ profile.Jellyseerr.User = user.UserTemplate
+ n, err := app.js.GetNotificationPreferencesByID(user.ID)
+ if err != nil {
+ app.err.Printf(lm.FailedGetJellyseerrNotificationPrefs, strconv.FormatInt(user.ID, 10), err)
+ } else {
+ profile.Jellyseerr.Notifications = n.NotificationsTemplate
+ profile.Jellyseerr.Enabled = true
+ }
+ }
+ }
app.storage.SetProfileKey(req.Name, profile)
// Refresh discord bots, profile list
if discordEnabled {
@@ -167,7 +247,13 @@ func (app *appContext) DeleteProfile(gc *gin.Context) {
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) EnableReferralForProfile(gc *gin.Context) {
- profileName := gc.Param("profile")
+ 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)
@@ -214,7 +300,13 @@ func (app *appContext) EnableReferralForProfile(gc *gin.Context) {
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) DisableReferralForProfile(gc *gin.Context) {
- profileName := gc.Param("profile")
+ 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)
diff --git a/api-userpage.go b/api-userpage.go
index a7d9b77..7b86d9b 100644
--- a/api-userpage.go
+++ b/api-userpage.go
@@ -107,7 +107,7 @@ func (app *appContext) MyDetails(gc *gin.Context) {
// @Summary Sets whether to notify yourself through telegram/discord/matrix/email or not.
// @Produce json
-// @Param SetContactMethodsDTO body SetContactMethodsDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
+// @Param SetContactPreferencesDTO body SetContactPreferencesDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
// @Success 200 {object} boolResponse
// @Success 400 {object} boolResponse
// @Success 500 {object} boolResponse
@@ -115,14 +115,14 @@ func (app *appContext) MyDetails(gc *gin.Context) {
// @Security Bearer
// @tags User Page
func (app *appContext) SetMyContactMethods(gc *gin.Context) {
- var req SetContactMethodsDTO
+ var req SetContactPreferencesDTO
gc.BindJSON(&req)
req.ID = gc.GetString("jfId")
if req.ID == "" {
respondBool(400, false, gc)
return
}
- app.setContactMethods(req, gc)
+ app.setContactPreferences(req, gc)
}
// @Summary Logout by deleting refresh token from cookies.
@@ -164,9 +164,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
var target ConfirmationTarget
var id string
fail := func() {
- gcHTML(gc, 404, "404.html", gin.H{
- "cssClass": app.cssClass,
- "cssVersion": cssVersion,
+ app.gcHTML(gc, 404, "404.html", OtherPage, gin.H{
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}
@@ -201,7 +199,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
// Perform an Action
if target == NoOp {
- gc.Redirect(http.StatusSeeOther, "/my/account")
+ gc.Redirect(http.StatusSeeOther, PAGES.MyAccount)
return
} else if target == UserEmailChange {
app.modifyEmail(id, claims["email"].(string))
@@ -216,7 +214,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
}, gc, true)
app.info.Printf(lm.UserEmailAdjusted, gc.GetString("jfId"))
- gc.Redirect(http.StatusSeeOther, "/my/account")
+ gc.Redirect(http.StatusSeeOther, PAGES.MyAccount)
return
}
}
@@ -266,7 +264,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
}
app.debug.Printf(lm.EmailConfirmationRequired, id)
respond(401, "confirmEmail", gc)
- msg, err := app.email.constructConfirmation("", name, key, app, false)
+ msg, err := app.email.constructConfirmation("", name, key, false)
if err != nil {
app.err.Printf(lm.FailedConstructConfirmationEmail, id, err)
} else if err := app.email.send(msg, req.Email); err != nil {
@@ -396,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
@@ -645,7 +645,7 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
Username: pwr.Username,
Expiry: pwr.Expiry,
Internal: true,
- }, app, false,
+ }, false,
)
if err != nil {
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
@@ -798,6 +798,7 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
app.storage.SetInvitesKey(inv.Code, inv)
}
+ app.InvalidateWebUserCache()
gc.JSON(200, GetMyReferralRespDTO{
Code: inv.Code,
RemainingUses: inv.RemainingUses,
diff --git a/api-users.go b/api-users.go
index 3d531d3..cf77781 100644
--- a/api-users.go
+++ b/api-users.go
@@ -4,12 +4,13 @@ import (
"fmt"
"net/url"
"os"
+ "slices"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
- "github.com/hrfee/jfa-go/jellyseerr"
+ "github.com/hrfee/jfa-go/common"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
@@ -53,17 +54,34 @@ func (app *appContext) NewUserFromAdmin(gc *gin.Context) {
nu.Log()
}
+ var emailStore *EmailAddress = nil
if emailEnabled && req.Email != "" {
- emailStore := EmailAddress{
+ emailStore = &EmailAddress{
Addr: req.Email,
Contact: true,
}
- app.storage.SetEmailsKey(nu.User.ID, emailStore)
+ app.storage.SetEmailsKey(nu.User.ID, *emailStore)
+ }
+
+ for _, tps := range app.thirdPartyServices {
+ if !tps.Enabled(app, &profile) {
+ continue
+ }
+ // We only have email
+ if emailStore == nil {
+ continue
+ }
+ err := tps.SetContactMethods(nu.User.ID, &req.Email, nil, nil, &common.ContactPreferences{
+ Email: &(emailStore.Contact),
+ })
+ if err != nil {
+ app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
+ }
}
welcomeMessageSentIfNecessary := true
if nu.Created {
- welcomeMessageSentIfNecessary = app.WelcomeNewUser(nu.User, time.Time{})
+ welcomeMessageSentIfNecessary = !app.WelcomeNewUser(nu.User, time.Time{})
}
respondUser(nu.Status, nu.Created, welcomeMessageSentIfNecessary, nu.Message, gc)
@@ -188,7 +206,7 @@ func (app *appContext) NewUserFromInvite(gc *gin.Context) {
app.debug.Printf(lm.EmailConfirmationRequired, req.Username)
respond(401, "confirmEmail", gc)
- msg, err := app.email.constructConfirmation(req.Code, req.Username, key, app, false)
+ msg, err := app.email.constructConfirmation(req.Code, req.Username, key, false)
if err != nil {
app.err.Printf(lm.FailedConstructConfirmationEmail, req.Code, err)
} else if err := app.email.send(msg, req.Email); err != nil {
@@ -261,18 +279,20 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
}
app.contactMethods[i].DeleteVerifiedToken(c.PIN)
c.User.SetJellyfin(nu.User.ID)
- c.User.Store(&(app.storage))
+ c.User.Store(app.storage)
}
}
referralsEnabled := profile != nil && profile.ReferralTemplateKey != "" && app.config.Section("user_page").Key("enabled").MustBool(false) && app.config.Section("user_page").Key("referrals").MustBool(false)
+ contactPrefs := common.ContactPreferences{}
if (emailEnabled && req.Email != "") || invite.UserLabel != "" || referralsEnabled {
emailStore := EmailAddress{
Addr: req.Email,
Contact: (req.Email != ""),
Label: invite.UserLabel,
}
+ contactPrefs.Email = &(emailStore.Contact)
if profile != nil {
profile.ReferralTemplateKey = profile.ReferralTemplateKey
}
@@ -289,7 +309,7 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
continue
}
go func(addr string) {
- msg, err := app.email.constructCreated(req.Code, req.Username, req.Email, invite, app, false)
+ msg, err := app.email.constructCreated(req.Username, req.Email, time.Now(), invite, false)
if err != nil {
app.err.Printf(lm.FailedConstructCreationAdmin, req.Code, err)
} else {
@@ -333,18 +353,22 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
var discordUser *DiscordUser = nil
var telegramUser *TelegramUser = nil
+ // FIXME: Make sure its okay to, then change this check to len(app.tps) != 0 && (for loop of tps.Enabled )
if app.ombi.Enabled(app, profile) || app.js.Enabled(app, profile) {
// FIXME: figure these out in a nicer way? this relies on the current ordering,
// which may not be fixed.
if discordEnabled {
- if req.completeContactMethods[0].User != nil {
- discordUser = req.completeContactMethods[0].User.(*DiscordUser)
+ if req.completeContactMethods[0].User != nil {
+ discordUser = req.completeContactMethods[0].User.(*DiscordUser)
+ contactPrefs.Discord = &discordUser.Contact
}
if telegramEnabled && req.completeContactMethods[1].User != nil {
telegramUser = req.completeContactMethods[1].User.(*TelegramUser)
+ contactPrefs.Telegram = &telegramUser.Contact
}
} else if telegramEnabled && req.completeContactMethods[0].User != nil {
telegramUser = req.completeContactMethods[0].User.(*TelegramUser)
+ contactPrefs.Telegram = &telegramUser.Contact
}
}
@@ -353,7 +377,7 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
continue
}
// User already created, now we can link contact methods
- err := tps.AddContactMethods(nu.User.ID, req.newUserDTO, discordUser, telegramUser)
+ err := tps.SetContactMethods(nu.User.ID, &(req.Email), discordUser, telegramUser, &contactPrefs)
if err != nil {
app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
}
@@ -379,19 +403,6 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
"SetPolicy": map[string]string{},
}
sendMail := messagesEnabled
- var msg *Message
- var err error
- if sendMail {
- if req.Enabled {
- msg, err = app.email.constructEnabled(req.Reason, app, false)
- } else {
- msg, err = app.email.constructDisabled(req.Reason, app, false)
- }
- if err != nil {
- app.err.Printf(lm.FailedConstructEnableDisableMessage, "?", err)
- sendMail = false
- }
- }
activityType := ActivityDisabled
if req.Enabled {
activityType = ActivityEnabled
@@ -403,6 +414,18 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
app.err.Printf(lm.FailedGetUser, user.ID, lm.Jellyfin, err)
continue
}
+ var msg *Message
+ if sendMail {
+ if req.Enabled {
+ msg, err = app.email.constructEnabled(user.Name, req.Reason, false)
+ } else {
+ msg, err = app.email.constructDisabled(user.Name, req.Reason, false)
+ }
+ if err != nil {
+ app.err.Printf(lm.FailedConstructEnableDisableMessage, "?", err)
+ sendMail = false
+ }
+ }
err, _, _ = app.SetUserDisabled(user, !req.Enabled)
if err != nil {
errors["SetPolicy"][user.ID] = err.Error()
@@ -426,7 +449,7 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
}
}
}
- app.jf.CacheExpiry = time.Now()
+ app.InvalidateUserCaches()
if len(errors["GetUser"]) != 0 || len(errors["SetPolicy"]) != 0 {
gc.JSON(500, errors)
return
@@ -448,15 +471,6 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
gc.BindJSON(&req)
errors := map[string]string{}
sendMail := messagesEnabled
- var msg *Message
- var err error
- if sendMail {
- msg, err = app.email.constructDeleted(req.Reason, app, false)
- if err != nil {
- app.err.Printf(lm.FailedConstructDeletionMessage, "?", err)
- sendMail = false
- }
- }
for _, userID := range req.Users {
user, err := app.jf.UserByID(userID, false)
if err != nil {
@@ -464,6 +478,15 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
errors[userID] = err.Error()
}
+ var msg *Message = nil
+ if sendMail {
+ msg, err = app.email.constructDeleted(user.Name, req.Reason, false)
+ if err != nil {
+ app.err.Printf(lm.FailedConstructDeletionMessage, "?", err)
+ sendMail = false
+ }
+ }
+
deleted := false
err, deleted = app.DeleteUser(user)
if err != nil {
@@ -494,7 +517,7 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
}
}
}
- app.jf.CacheExpiry = time.Now()
+ app.InvalidateUserCaches()
if len(errors) == len(req.Users) {
respondBool(500, false, gc)
app.err.Printf(lm.FailedDeleteUsers, lm.Jellyfin, errors[req.Users[0]])
@@ -525,6 +548,24 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
base := time.Now()
if expiry, ok := app.storage.GetUserExpiryKey(id); ok {
base = expiry.Expiry
+ app.debug.Printf(lm.FoundExistingExpiry)
+ } else if req.TryExtendFromPreviousExpiry {
+ var acts []Activity
+ app.storage.db.Find(&acts, badgerhold.Where("Type").Eq(ActivityDisabled).And("UserID").Eq(id).SortBy("Time").Reverse().Limit(1))
+ if len(acts) != 0 {
+ // Only do it if the most recent reason for disabling was expiry
+ if acts[0].SourceType == ActivityDaemon {
+ app.debug.Printf(lm.FoundPreviousExpiryLog, acts[0].Time)
+ newExpiry := acts[0].Time.AddDate(0, req.Months, req.Days).Add(time.Duration(((60 * req.Hours) + req.Minutes)) * time.Minute)
+ if newExpiry.After(base) {
+ base = acts[0].Time
+ } else {
+ app.debug.Printf(lm.ExpiryWouldBeInPast)
+ }
+ } else {
+ app.debug.Printf(lm.PreviousExpiryNotExpiry)
+ }
+ }
}
app.debug.Printf(lm.ExtendCreateExpiry, id)
expiry := UserExpiry{}
@@ -540,7 +581,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
if err != nil {
return
}
- msg, err := app.email.constructExpiryAdjusted(user.Name, exp, req.Reason, app, false)
+ msg, err := app.email.constructExpiryAdjusted(user.Name, exp, req.Reason, false)
if err != nil {
app.err.Printf(lm.FailedConstructExpiryAdjustmentMessage, uid, err)
return
@@ -551,6 +592,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
}(id, expiry.Expiry)
}
}
+ app.InvalidateWebUserCache()
respondBool(204, true, gc)
}
@@ -562,6 +604,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
// @tags Users
func (app *appContext) RemoveExpiry(gc *gin.Context) {
app.storage.DeleteUserExpiryKey(gc.Param("id"))
+ app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -582,7 +625,12 @@ func (app *appContext) EnableReferralForUsers(gc *gin.Context) {
gc.BindJSON(&req)
mode := gc.Param("mode")
- source := gc.Param("source")
+ escapedSource := gc.Param("source")
+ source, err := url.QueryUnescape(escapedSource)
+ if err != nil {
+ respondBool(400, false, gc)
+ return
+ }
useExpiry := gc.Param("useExpiry") == "with-expiry"
baseInv := Invite{}
if mode == "profile" {
@@ -623,6 +671,7 @@ func (app *appContext) EnableReferralForUsers(gc *gin.Context) {
inv.UseReferralExpiry = useExpiry
app.storage.SetInvitesKey(inv.Code, inv)
}
+ app.InvalidateWebUserCache()
}
// @Summary Disable referrals for the given user(s).
@@ -646,6 +695,7 @@ func (app *appContext) DisableReferralForUsers(gc *gin.Context) {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(u, user)
}
+ app.InvalidateWebUserCache()
respondBool(200, true, gc)
}
@@ -674,7 +724,10 @@ func (app *appContext) Announce(gc *gin.Context) {
app.err.Printf(lm.FailedGetUser, userID, lm.Jellyfin, err)
continue
}
- msg, err := app.email.constructTemplate(req.Subject, req.Message, app, user.Name)
+ msg, err := app.email.construct(AnnouncementCustomContent(req.Subject), CustomContent{
+ Enabled: true,
+ Content: req.Message,
+ }, map[string]any{"username": user.Name})
if err != nil {
app.err.Printf(lm.FailedConstructAnnouncementMessage, userID, err)
respondBool(500, false, gc)
@@ -687,7 +740,10 @@ func (app *appContext) Announce(gc *gin.Context) {
}
// app.info.Printf(lm.SentAnnouncementMessage, "*", "?")
} else {
- msg, err := app.email.constructTemplate(req.Subject, req.Message, app)
+ msg, err := app.email.construct(AnnouncementCustomContent(req.Subject), CustomContent{
+ Enabled: true,
+ Content: req.Message,
+ }, map[string]any{"username": ""})
if err != nil {
app.err.Printf(lm.FailedConstructAnnouncementMessage, "*", err)
respondBool(500, false, gc)
@@ -762,13 +818,19 @@ func (app *appContext) GetAnnounceTemplate(gc *gin.Context) {
// @Summary Delete an announcement template.
// @Produce json
// @Success 200 {object} boolResponse
+// @Failure 400 {object} boolResponse
// @Failure 500 {object} boolResponse
-// @Param name path string true "name of template"
+// @Param name path string true "name of template (url encoded if necessary)"
// @Router /users/announce/template/{name} [delete]
// @Security Bearer
// @tags Users
func (app *appContext) DeleteAnnounceTemplate(gc *gin.Context) {
- name := gc.Param("name")
+ escapedName := gc.Param("name")
+ name, err := url.QueryUnescape(escapedName)
+ if err != nil {
+ respondBool(400, false, gc)
+ return
+ }
app.storage.DeleteAnnouncementsKey(name)
respondBool(200, false, gc)
}
@@ -807,7 +869,7 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) {
app.internalPWRs[pwr.PIN] = pwr
sendAddress := app.getAddressOrName(id)
if sendAddress == "" || len(req.Users) == 1 {
- resp.Link, err = app.GenResetLink(pwr.PIN)
+ resp.Link, err = GenResetLink(pwr.PIN)
linkCount++
if sendAddress == "" {
resp.Manual = true
@@ -820,7 +882,7 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) {
Username: pwr.Username,
Expiry: pwr.Expiry,
Internal: true,
- }, app, false,
+ }, false,
)
if err != nil {
app.err.Printf(lm.FailedConstructPWRMessage, id, err)
@@ -840,6 +902,8 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) {
respondBool(204, true, gc)
}
+// userSummary generates a respUser for to be displayed to the user, or sorted/filtered.
+// also, consider it a source of which data fields/struct modifications need to trigger a cache invalidation.
func (app *appContext) userSummary(jfUser mediabrowser.User) respUser {
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
allowAll := app.config.Section("ui").Key("allow_all").MustBool(false)
@@ -894,7 +958,40 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser {
}
-// @Summary Get a list of Jellyfin users.
+// @Summary Returns the total number of Jellyfin users.
+// @Produce json
+// @Success 200 {object} PageCountDTO
+// @Router /users/count [get]
+// @Security Bearer
+// @tags Users,Statistics
+func (app *appContext) GetUserCount(gc *gin.Context) {
+ resp := PageCountDTO{}
+ users, err := app.jf.GetUsers(false)
+ if err != nil {
+ app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
+ respond(500, "Couldn't get users", gc)
+ return
+ }
+ resp.Count = uint64(len(users))
+ gc.JSON(200, resp)
+}
+
+// @Summary Returns the list of all labels on accounts.
+// @Produce json
+// @Success 200 {object} LabelsDTO
+// @Router /users/labels [get]
+// @Security Bearer
+// @tags Users,Statistics
+func (app *appContext) GetLabels(gc *gin.Context) {
+ if err := app.userCache.MaybeSync(app); err != nil {
+ app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
+ respond(500, "Couldn't get users", gc)
+ return
+ }
+ gc.JSON(200, LabelsDTO{Labels: app.userCache.Labels})
+}
+
+// @Summary Get a list of -all- Jellyfin users.
// @Produce json
// @Success 200 {object} getUsersDTO
// @Failure 500 {object} stringResponse
@@ -903,19 +1000,93 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser {
// @tags Users
func (app *appContext) GetUsers(gc *gin.Context) {
var resp getUsersDTO
- users, err := app.jf.GetUsers(false)
- resp.UserList = make([]respUser, len(users))
+ // We're sending all users, so this is always true
+ resp.LastPage = true
+ var err error
+ resp.UserList, err = app.userCache.GetUserDTOs(app, true)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Couldn't get users", gc)
return
}
- i := 0
- for _, jfUser := range users {
- user := app.userSummary(jfUser)
- resp.UserList[i] = user
- i++
+ gc.JSON(200, resp)
+}
+
+// @Summary Get a paginated, searchable list of Jellyfin users.
+// @Produce json
+// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search / pagination parameters"
+// @Success 200 {object} getUsersDTO
+// @Failure 500 {object} stringResponse
+// @Router /users [post]
+// @Security Bearer
+// @tags Users,Statistics
+func (app *appContext) SearchUsers(gc *gin.Context) {
+ req := ServerSearchReqDTO{}
+ gc.BindJSON(&req)
+ if req.SortByField == "" {
+ req.SortByField = USER_DEFAULT_SORT_FIELD
}
+
+ var resp getUsersDTO
+ userList, err := app.userCache.GetUserDTOs(app, req.SortByField == USER_DEFAULT_SORT_FIELD)
+ if err != nil {
+ app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
+ respond(500, "Couldn't get users", gc)
+ return
+ }
+ var filtered []*respUser
+ if len(req.SearchTerms) != 0 || len(req.Queries) != 0 {
+ filtered = app.userCache.Filter(userList, req.SearchTerms, req.Queries)
+ } else {
+ filtered = slices.Clone(userList)
+ }
+
+ if req.SortByField == USER_DEFAULT_SORT_FIELD {
+ if req.Ascending != USER_DEFAULT_SORT_ASCENDING {
+ slices.Reverse(filtered)
+ }
+ } else {
+ app.userCache.Sort(filtered, req.SortByField, req.Ascending)
+ }
+
+ startIndex := (req.Page * req.Limit)
+ if startIndex < len(filtered) {
+ endIndex := min(startIndex+req.Limit, len(filtered))
+ resp.UserList = filtered[startIndex:endIndex]
+ }
+ resp.LastPage = len(resp.UserList) != req.Limit
+ gc.JSON(200, resp)
+}
+
+// @Summary Get a count of users matching the search provided
+// @Produce json
+// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search / pagination parameters"
+// @Success 200 {object} PageCountDTO
+// @Failure 500 {object} stringResponse
+// @Router /users/count [post]
+// @Security Bearer
+// @tags Users,Statistics
+func (app *appContext) GetFilteredUserCount(gc *gin.Context) {
+ req := ServerSearchReqDTO{}
+ gc.BindJSON(&req)
+ if req.SortByField == "" {
+ req.SortByField = USER_DEFAULT_SORT_FIELD
+ }
+
+ var resp PageCountDTO
+ // No need to sort
+ userList, err := app.userCache.GetUserDTOs(app, false)
+ if err != nil {
+ app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
+ respond(500, "Couldn't get users", gc)
+ return
+ }
+ if len(req.SearchTerms) != 0 || len(req.Queries) != 0 {
+ resp.Count = uint64(len(app.userCache.Filter(userList, req.SearchTerms, req.Queries)))
+ } else {
+ resp.Count = uint64(len(userList))
+ }
+
gc.JSON(200, resp)
}
@@ -948,6 +1119,7 @@ func (app *appContext) SetAccountsAdmin(gc *gin.Context) {
app.info.Printf(lm.UserAdminAdjusted, id, admin)
}
}
+ app.InvalidateWebUserCache()
respondBool(204, true, gc)
}
@@ -980,45 +1152,29 @@ func (app *appContext) ModifyLabels(gc *gin.Context) {
app.storage.SetEmailsKey(id, emailStore)
}
}
+ app.InvalidateWebUserCache()
respondBool(204, true, gc)
}
func (app *appContext) modifyEmail(jfID string, addr string) {
- contactPrefChanged := false
emailStore, ok := app.storage.GetEmailsKey(jfID)
// Auto enable contact by email for newly added addresses
if !ok || emailStore.Addr == "" {
emailStore = EmailAddress{
Contact: true,
}
- contactPrefChanged = true
}
emailStore.Addr = addr
app.storage.SetEmailsKey(jfID, emailStore)
- if app.config.Section("ombi").Key("enabled").MustBool(false) {
- ombiUser, err := app.getOmbiUser(jfID)
- if err == nil {
- ombiUser["emailAddress"] = addr
- err = app.ombi.ModifyUser(ombiUser)
- if err != nil {
- app.err.Printf(lm.FailedSetEmailAddress, lm.Ombi, jfID, err)
- }
- }
- }
- if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
- err := app.js.ModifyMainUserSettings(jfID, jellyseerr.MainUserSettings{Email: addr})
- if err != nil {
- app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, jfID, err)
- } else if contactPrefChanged {
- contactMethods := map[jellyseerr.NotificationsField]any{
- jellyseerr.FieldEmailEnabled: true,
- }
- err := app.js.ModifyNotifications(jfID, contactMethods)
- if err != nil {
- app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
- }
+
+ for _, tps := range app.thirdPartyServices {
+ if err := tps.SetContactMethods(jfID, &addr, nil, nil, &common.ContactPreferences{
+ Email: &(emailStore.Contact),
+ }); err != nil {
+ app.err.Printf(lm.FailedSetEmailAddress, tps.Name(), jfID, err)
}
}
+ app.InvalidateWebUserCache()
}
// @Summary Modify user's email addresses.
@@ -1118,7 +1274,7 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
} else if req.From == "user" {
applyingFromType = lm.User
- app.jf.CacheExpiry = time.Now()
+ app.InvalidateJellyfinCache()
user, err := app.jf.UserByID(req.ID, false)
if err != nil {
app.err.Printf(lm.FailedGetUser, req.ID, lm.Jellyfin, err)
@@ -1229,5 +1385,6 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
if len(errors["policy"]) == len(req.ApplyTo) || len(errors["homescreen"]) == len(req.ApplyTo) {
code = 500
}
+ app.InvalidateUserCaches()
gc.JSON(code, errors)
}
diff --git a/api.go b/api.go
index c09fece..53097df 100644
--- a/api.go
+++ b/api.go
@@ -36,23 +36,14 @@ func respondBool(code int, val bool, gc *gin.Context) {
gc.Abort()
}
-func (app *appContext) loadStrftime() {
- app.datePattern = app.config.Section("messages").Key("date_format").String()
- app.timePattern = `%H:%M`
- if val, _ := app.config.Section("messages").Key("use_24h").Bool(); !val {
- app.timePattern = `%I:%M %p`
- }
+func prettyTime(dt time.Time) (date, time string) {
+ date = timefmt.Format(dt, datePattern)
+ time = timefmt.Format(dt, timePattern)
return
}
-func (app *appContext) prettyTime(dt time.Time) (date, time string) {
- date = timefmt.Format(dt, app.datePattern)
- time = timefmt.Format(dt, app.timePattern)
- return
-}
-
-func (app *appContext) formatDatetime(dt time.Time) string {
- d, t := app.prettyTime(dt)
+func formatDatetime(dt time.Time) string {
+ d, t := prettyTime(dt)
return d + " " + t
}
@@ -310,7 +301,7 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
if req["restart-program"] != nil && req["restart-program"].(bool) {
app.Restart()
}
- app.loadConfig()
+ app.ReloadConfig()
// Patch new settings for next GetConfig
app.PatchConfigBase()
// Reinitialize password validator on config change, as opposed to every applicable request like in python.
diff --git a/args.go b/args.go
index e7eb6f3..bf58876 100644
--- a/args.go
+++ b/args.go
@@ -8,6 +8,8 @@ import (
"os"
"path/filepath"
"strings"
+
+ lm "github.com/hrfee/jfa-go/logmessages"
)
func (app *appContext) loadArgs(firstCall bool) {
@@ -28,6 +30,9 @@ func (app *appContext) loadArgs(firstCall bool) {
PPROF = flag.Bool("pprof", false, "Exposes pprof profiler on /debug/pprof.")
SWAGGER = flag.Bool("swagger", false, "Enable swagger at /swagger/index.html")
+ flag.BoolVar(&NO_API_AUTH_DO_NOT_USE, "disable-api-auth-do-not-use", false, "Disables API authentication. DO NOT USE!")
+ flag.StringVar(&NO_API_AUTH_FORCE_JFID, "disable-api-auth-force-jf-id", "", "Assume given JFID when API auth is disabled.")
+
flag.Parse()
if *help {
flag.Usage()
@@ -45,6 +50,19 @@ func (app *appContext) loadArgs(firstCall bool) {
if *_LOADBAK != "" {
LOADBAK = *_LOADBAK
}
+
+ if NO_API_AUTH_DO_NOT_USE && *DEBUG {
+ NO_API_AUTH_DO_NOT_USE = false
+ forceJfID := NO_API_AUTH_FORCE_JFID
+ NO_API_AUTH_FORCE_JFID = ""
+ buf := bufio.NewReader(os.Stdin)
+ app.err.Print(lm.NoAPIAuthPrompt)
+ sentence, err := buf.ReadBytes('\n')
+ if err == nil && strings.ContainsRune(string(sentence), 'y') {
+ NO_API_AUTH_DO_NOT_USE = true
+ NO_API_AUTH_FORCE_JFID = forceJfID
+ }
+ }
}
if os.Getenv("SWAGGER") == "1" {
diff --git a/auth.go b/auth.go
index 541c97a..b1e8e68 100644
--- a/auth.go
+++ b/auth.go
@@ -40,7 +40,11 @@ func (app *appContext) logIpErr(gc *gin.Context, user bool, out string) {
}
func (app *appContext) webAuth() gin.HandlerFunc {
- return app.authenticate
+ if NO_API_AUTH_DO_NOT_USE {
+ return app.bogusAuthenticate
+ } else {
+ return app.authenticate
+ }
}
func (app *appContext) authLog(v any) { app.debug.PrintfCustomLevel(4, lm.FailedAuthRequest, v) }
@@ -138,6 +142,13 @@ func (app *appContext) authenticate(gc *gin.Context) {
gc.Next()
}
+// bogusAuthenticate is for use with NO_API_AUTH_DO_NOT_USE, it sets the jfId/userId value from NO_API_AUTH_FORCE_JF_ID.
+func (app *appContext) bogusAuthenticate(gc *gin.Context) {
+ gc.Set("jfId", NO_API_AUTH_FORCE_JFID)
+ gc.Set("userId", NO_API_AUTH_FORCE_JFID)
+ gc.Next()
+}
+
func checkToken(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method %v", token.Header["alg"])
@@ -165,6 +176,31 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool)
return
}
+func (app *appContext) canAccessAdminPage(user mediabrowser.User, emailStore EmailAddress) bool {
+ // 1. "Allow all" is enabled, so simply being a user implies access.
+ if app.config.Section("ui").Key("allow_all").MustBool(false) && user.ID != "" {
+ return true
+ }
+ // 2. You've been made an "accounts admin" from the accounts tab.
+ if emailStore.Admin {
+ return true
+ }
+ // 3. (Jellyfin) "Admins only" is enabled, and you're one.
+ if app.config.Section("ui").Key("admin_only").MustBool(true) && user.ID != "" && user.Policy.IsAdministrator {
+ return true
+ }
+ return false
+}
+
+func (app *appContext) canAccessAdminPageByID(jfID string) bool {
+ user, err := app.jf.UserByID(jfID, false)
+ if err != nil {
+ return false
+ }
+ emailStore, _ := app.storage.GetEmailsKey(jfID)
+ return app.canAccessAdminPage(user, emailStore)
+}
+
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context, userpage bool) (user mediabrowser.User, ok bool) {
ok = false
user, err := app.authJf.Authenticate(username, password)
@@ -220,18 +256,12 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
return
}
jfID = user.ID
- if !app.config.Section("ui").Key("allow_all").MustBool(false) {
- accountsAdmin := false
- adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
- if emailStore, ok := app.storage.GetEmailsKey(jfID); ok {
- accountsAdmin = emailStore.Admin
- }
- accountsAdmin = accountsAdmin || (adminOnly && user.Policy.IsAdministrator)
- if !accountsAdmin {
- app.authLog(fmt.Sprintf(lm.NonAdminUser, username))
- respond(401, "Unauthorized", gc)
- return
- }
+ emailStore, _ := app.storage.GetEmailsKey(jfID)
+ accountsAdmin := app.canAccessAdminPage(user, emailStore)
+ if !accountsAdmin {
+ app.authLog(fmt.Sprintf(lm.NonAdminUser, username))
+ respond(401, "Unauthorized", gc)
+ return
}
// New users are only added when using jellyfinLogin.
userID = shortuuid.New()
@@ -247,8 +277,7 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
respond(500, "Couldn't generate token", gc)
return
}
- // host := gc.Request.URL.Hostname()
- host := app.ExternalDomain
+ host := app.ExternalDomainNoPort(gc)
// Before you think this is broken: the first "true" arg is for "secure", i.e. only HTTPS!
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
@@ -310,7 +339,7 @@ func (app *appContext) getTokenRefresh(gc *gin.Context) {
return
}
// host := gc.Request.URL.Hostname()
- host := app.ExternalDomain
+ host := app.ExternalDomainNoPort(gc)
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
gc.JSON(200, getTokenDTO{jwt})
}
diff --git a/autostart.go b/autostart.go
index c0b75e2..9ecb0f4 100644
--- a/autostart.go
+++ b/autostart.go
@@ -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")
}
}
- }
+ })
}
diff --git a/backups.go b/backups.go
index 6690454..eadad73 100644
--- a/backups.go
+++ b/backups.go
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"sort"
+ "strconv"
"strings"
"time"
@@ -12,35 +13,127 @@ import (
)
const (
- BACKUP_PREFIX = "jfa-go-db-"
+ BACKUP_PREFIX = "jfa-go-db"
+ BACKUP_PREFIX_OLD = "jfa-go-db-"
+ BACKUP_COMMIT_PREFIX = "-c-"
+ BACKUP_DATE_PREFIX = "-d-"
BACKUP_UPLOAD_PREFIX = "upload-"
BACKUP_DATEFMT = "2006-01-02T15-04-05"
BACKUP_SUFFIX = ".bak"
)
+type Backup struct {
+ Date time.Time
+ Commit string
+ Upload bool
+}
+
+func (b Backup) IsZero() bool { return b.Date.IsZero() && b.Commit == "" && b.Upload == false }
+
+func (b Backup) Equals(a Backup) bool {
+ return a.Date.Equal(b.Date) && a.Commit == b.Commit && a.Upload == b.Upload
+}
+
+// Pre 21/03/25 format: "{BACKUP_PREFIX_OLD}{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-2006-01-02T15-04-05.bak"
+// Post 21/03/25 format: "{BACKUP_PREFIX}-c-{commit}-d-{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-c-0b92060-d-2006-01-02T15-04-05.bak"
+
+func (b Backup) String() string {
+ t := b.Date
+ if t.IsZero() {
+ t = time.Now()
+ }
+ out := BACKUP_PREFIX
+ if b.Upload {
+ out = BACKUP_UPLOAD_PREFIX + out
+ }
+ if b.Commit != "" {
+ out += BACKUP_COMMIT_PREFIX + b.Commit
+ }
+ out += BACKUP_DATE_PREFIX + t.Local().Format(BACKUP_DATEFMT) + BACKUP_SUFFIX
+ return out
+}
+
+func (b *Backup) FromString(f string) error {
+ of := f
+ if strings.HasPrefix(f, BACKUP_UPLOAD_PREFIX) {
+ b.Upload = true
+ f = f[len(BACKUP_UPLOAD_PREFIX):]
+ }
+ if !strings.HasPrefix(f, BACKUP_PREFIX) {
+ return fmt.Errorf("file doesn't have correct prefix (\"%s\")", BACKUP_PREFIX)
+ }
+ f = f[len(BACKUP_PREFIX):]
+ if !strings.HasSuffix(f, BACKUP_SUFFIX) {
+ return fmt.Errorf("file doesn't have correct suffix (\"%s\")", BACKUP_SUFFIX)
+ }
+ for range 2 {
+ if strings.HasPrefix(f, BACKUP_COMMIT_PREFIX) {
+ f = f[len(BACKUP_COMMIT_PREFIX):]
+ commitEnd := strings.Index(f, BACKUP_DATE_PREFIX)
+ if commitEnd == -1 {
+ commitEnd = strings.Index(f, BACKUP_SUFFIX)
+ }
+ if commitEnd == -1 {
+ return fmt.Errorf("end of commit (\"%s\" or \"%s\") not found in \"%s\"", BACKUP_DATE_PREFIX, BACKUP_PREFIX, f)
+ }
+ b.Commit = f[:commitEnd]
+ f = f[commitEnd:]
+ } else if strings.HasPrefix(f, BACKUP_DATE_PREFIX) {
+ f = f[len(BACKUP_DATE_PREFIX):]
+ dateEnd := strings.Index(f, BACKUP_COMMIT_PREFIX)
+ if dateEnd == -1 {
+ dateEnd = strings.Index(f, BACKUP_SUFFIX)
+ }
+ if dateEnd == -1 {
+ return fmt.Errorf("end of date (\"%s\" or \"%s\") not found in \"%s\"", BACKUP_COMMIT_PREFIX, BACKUP_PREFIX, f)
+ }
+ t, err := time.Parse(BACKUP_DATEFMT, f[:dateEnd])
+ if err != nil {
+ return err
+ }
+ b.Date = t
+ f = f[dateEnd:]
+ }
+ }
+ if b.Date.IsZero() {
+ return b.FromOldString(of)
+ }
+ return nil
+}
+
+func (b *Backup) FromOldString(f string) error {
+ t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(f, BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX+"-"), BACKUP_SUFFIX))
+ if err != nil {
+ return fmt.Errorf(lm.FailedParseTime, err)
+ }
+ b.Date = t
+ return nil
+
+}
+
type BackupList struct {
files []os.DirEntry
- dates []time.Time
+ info []Backup
count int
}
func (bl BackupList) Len() int { return len(bl.files) }
func (bl BackupList) Swap(i, j int) {
bl.files[i], bl.files[j] = bl.files[j], bl.files[i]
- bl.dates[i], bl.dates[j] = bl.dates[j], bl.dates[i]
+ bl.info[i], bl.info[j] = bl.info[j], bl.info[i]
}
func (bl BackupList) Less(i, j int) bool {
// Push non-backup files to the end of the array,
// Since they didn't have a date parsed.
- if bl.dates[i].IsZero() {
+ if bl.info[i].Date.IsZero() {
return false
}
- if bl.dates[j].IsZero() {
+ if bl.info[j].Date.IsZero() {
return true
}
// Sort by oldest first
- return bl.dates[j].After(bl.dates[i])
+ return bl.info[j].Date.After(bl.info[i].Date)
}
// Get human-readable file size from f.Size() result.
@@ -72,18 +165,19 @@ func (app *appContext) getBackups() *BackupList {
}
backups := &BackupList{}
backups.files = items
- backups.dates = make([]time.Time, len(items))
+ backups.info = make([]Backup, len(items))
backups.count = 0
for i, item := range items {
+ // Even though Backup{} can parse and check validity, still check if the file ends in .bak, we don't need to print an error if a file isn't a .bak.
if item.IsDir() || !(strings.HasSuffix(item.Name(), BACKUP_SUFFIX)) {
continue
}
- t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(item.Name(), BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX), BACKUP_SUFFIX))
- if err != nil {
- app.debug.Printf(lm.FailedParseTime, err)
+ b := Backup{}
+ if err := b.FromString(item.Name()); err != nil {
+ app.debug.Printf(lm.FailedParseBackup, item.Name(), err)
continue
}
- backups.dates[i] = t
+ backups.info[i] = b
backups.count++
}
return backups
@@ -91,17 +185,46 @@ func (app *appContext) getBackups() *BackupList {
func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) {
toKeep := app.config.Section("backups").Key("keep_n_backups").MustInt(20)
- fname := BACKUP_PREFIX + time.Now().Local().Format(BACKUP_DATEFMT) + BACKUP_SUFFIX
+ keepPreviousVersions := app.config.Section("backups").Key("keep_previous_version_backup").MustBool(true)
+
+ b := Backup{Commit: commit}
+ fname := b.String()
path := app.config.Section("backups").Key("path").String()
backups := app.getBackups()
if backups == nil {
return
}
toDelete := backups.count + 1 - toKeep
+ if toDelete > 0 || keepPreviousVersions {
+ sort.Sort(backups)
+ }
+ backupsByCommit := map[string]int{}
+ if keepPreviousVersions {
+ // Count backups by commit
+ for _, b := range backups.info {
+ if b.IsZero() {
+ continue
+ }
+ // If b.Commit is empty, the backup is pre-versions-in-backup-names.
+ // Still use the empty string as a key, considering these as a single version.
+ count, ok := backupsByCommit[b.Commit]
+ if !ok {
+ count = 0
+ }
+ count += 1
+ backupsByCommit[b.Commit] = count
+ }
+ }
// fmt.Printf("toDelete: %d, backCount: %d, keep: %d, length: %d\n", toDelete, backups.count, toKeep, len(backups.files))
if toDelete > 0 && toDelete <= backups.count {
- sort.Sort(backups)
- for _, item := range backups.files[:toDelete] {
+ for i := range toDelete {
+ backupsRemaining, ok := backupsByCommit[backups.info[i].Commit]
+ app.debug.Println("item", backups.files[i], "remaining", backupsRemaining)
+ if keepPreviousVersions && ok && backupsRemaining <= 1 {
+ continue
+ }
+
+ item := backups.files[i]
fullpath := filepath.Join(path, item.Name())
err := os.Remove(fullpath)
if err != nil {
@@ -109,6 +232,10 @@ func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) {
return
}
app.debug.Printf(lm.DeleteOldBackup, fullpath)
+ if keepPreviousVersions && ok {
+ backupsRemaining -= 1
+ backupsByCommit[backups.info[i].Commit] = backupsRemaining
+ }
}
}
fullpath := filepath.Join(path, fname)
@@ -140,15 +267,17 @@ func (app *appContext) loadPendingBackup() {
if LOADBAK == "" {
return
}
- oldPath := filepath.Join(app.dataPath, "db-"+string(time.Now().Unix())+"-pre-"+filepath.Base(LOADBAK))
+ oldPath := filepath.Join(app.dataPath, "db-"+strconv.FormatInt(time.Now().Unix(), 10)+"-pre-"+filepath.Base(LOADBAK))
err := os.Rename(app.storage.db_path, oldPath)
if err != nil {
app.err.Fatalf(lm.FailedMoveOldDB, oldPath, err)
}
app.info.Printf(lm.MoveOldDB, oldPath)
- app.ConnectDB()
- defer app.storage.db.Close()
+ 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 {
diff --git a/backups_test.go b/backups_test.go
new file mode 100644
index 0000000..10c3afd
--- /dev/null
+++ b/backups_test.go
@@ -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)
+}
diff --git a/common/common.go b/common/common.go
index 58d0424..c361aa7 100644
--- a/common/common.go
+++ b/common/common.go
@@ -11,10 +11,21 @@ import (
"net/http"
"net/url"
"strings"
+ "time"
lm "github.com/hrfee/jfa-go/logmessages"
)
+const (
+ BogusIdentifier = "123412341234123456"
+)
+
+// ContactPreferences holds whether or not a user should be contacted through each of the available
+// methods. If nil, leave setting alone.
+type ContactPreferences struct {
+ Email, Discord, Telegram, Matrix *bool
+}
+
// TimeoutHandler recovers from an http timeout or panic.
type TimeoutHandler func()
@@ -79,6 +90,13 @@ func GenericErr(status int, err error) error {
}
}
+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)
@@ -148,3 +166,11 @@ func decodeResp(resp *http.Response) (string, error) {
}
return buf.String(), nil
}
+
+// MustAuthenticateOptions is used to control the behaviour of the MustAuthenticate-like methods.
+type MustAuthenticateOptions struct {
+ RetryCount int // Number of Retries before failure.
+ RetryGap time.Duration // Duration to wait between tries.
+ LogFailures bool // Whether or not to print failures to the log.
+ Counter int // The current retry count.
+}
diff --git a/common/config.go b/common/config.go
index 7d68233..4e5de56 100644
--- a/common/config.go
+++ b/common/config.go
@@ -1,13 +1,14 @@
package common
type SectionMeta struct {
- Name string `json:"name" yaml:"name" example:"My Section"` // friendly name of the section
- Description string `json:"description" yaml:"description"`
- Advanced bool `json:"advanced,omitempty" yaml:"advanced,omitempty"`
- Disabled bool `json:"disabled,omitempty" yaml:"disabled,omitempty"`
- DependsTrue string `json:"depends_true,omitempty" yaml:"depends_true,omitempty"`
- DependsFalse string `json:"depends_false,omitempty" yaml:"depends_false,omitempty"`
- WikiLink string `json:"wiki_link,omitempty" yaml:"wiki_link,omitempty"`
+ Name string `json:"name" yaml:"name" example:"My Section"` // friendly name of the section
+ Description string `json:"description" yaml:"description"`
+ Advanced bool `json:"advanced,omitempty" yaml:"advanced,omitempty"`
+ Disabled bool `json:"disabled,omitempty" yaml:"disabled,omitempty"`
+ DependsTrue string `json:"depends_true,omitempty" yaml:"depends_true,omitempty"`
+ DependsFalse string `json:"depends_false,omitempty" yaml:"depends_false,omitempty"`
+ WikiLink string `json:"wiki_link,omitempty" yaml:"wiki_link,omitempty"`
+ Aliases []string `json:"aliases,omitempty" yaml:"aliases,omitempty"`
}
type Option [2]string
@@ -40,6 +41,7 @@ type Setting struct {
Style string `json:"style,omitempty" yaml:"style,omitempty"`
Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"`
WikiLink string `json:"wiki_link,omitempty" yaml:"wiki_link,omitempty"`
+ Aliases []string `json:"aliases,omitempty" yaml:"aliases,omitempty"`
}
type Section struct {
@@ -48,8 +50,25 @@ type Section struct {
Settings []Setting `json:"settings" yaml:"settings"`
}
+// Member is a member of a group, and can either reference a Section or another Group, hence the two fields.
+type Member struct {
+ Group string `json:"group,omitempty", yaml:"group,omitempty"`
+ Section string `json:"section,omitempty", yaml:"section,omitempty"`
+}
+
+type Group struct {
+ Group string `json:"group" yaml:"group" example:"messaging_providers"`
+ Name string `json:"name" yaml:"name" example:"Messaging Providers"`
+ Description string `json:"description" yaml:"description" example:"Options for setting up messaging providers."`
+ Members []Member `json:"members" yaml:"members"`
+}
+
type Config struct {
Sections []Section `json:"sections" yaml:"sections"`
+ Groups []Group `json:"groups" yaml:"groups"`
+ // Optional order, which can interleave sections and groups.
+ // If unset, falls back to sections in order, then groups in order.
+ Order []Member `json:"order,omitempty" yaml:"order,omitempty"`
}
func (c *Config) removeSection(section string) {
diff --git a/config.go b/config.go
index c58f425..300fc86 100644
--- a/config.go
+++ b/config.go
@@ -3,6 +3,8 @@ package main
import (
"fmt"
"io/fs"
+ "net"
+ "net/http"
"net/url"
"os"
"path/filepath"
@@ -10,20 +12,31 @@ import (
"strings"
"time"
+ "github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/common"
"github.com/hrfee/jfa-go/easyproxy"
lm "github.com/hrfee/jfa-go/logmessages"
"gopkg.in/ini.v1"
)
+type Config struct {
+ *ini.File
+ proxyTransport *http.Transport
+ proxyConfig *easyproxy.ProxyConfig
+}
+
var emailEnabled = false
var messagesEnabled = false
var telegramEnabled = false
var discordEnabled = false
var matrixEnabled = false
-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:")
}
@@ -31,154 +44,289 @@ func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
return os.DirFS(dir), file
}
-func (app *appContext) MustSetValue(section, key, val string) {
- app.config.Section(section).Key(key).SetValue(app.config.Section(section).Key(key).MustString(val))
+func (config *Config) MustSetValue(section, key, val string) {
+ config.Section(section).Key(key).SetValue(config.Section(section).Key(key).MustString(val))
}
-func (app *appContext) loadConfig() error {
- var err error
- app.config, err = ini.ShadowLoad(app.configPath)
+func (config *Config) MustSetURLPath(section, key, val string) {
+ if !strings.HasPrefix(val, "/") && val != "" {
+ val = "/" + val
+ }
+ config.MustSetValue(section, key, val)
+}
+
+func FixFullURL(v string) string {
+ // Keep relative paths relative
+ if strings.HasPrefix(v, "/") {
+ return v
+ }
+ if !strings.HasPrefix(v, "http://") && !strings.HasPrefix(v, "https://") {
+ v = "http://" + v
+ }
+ return v
+}
+
+func FormatSubpath(path string, removeSingleSlash bool) string {
+ if path == "/" {
+ if removeSingleSlash {
+ return ""
+ }
+ return path
+ }
+ return strings.TrimSuffix(path, "/")
+}
+
+func (config *Config) MustCorrectURL(section, key, value string) {
+ v := config.Section(section).Key(key).String()
+ if v == "" {
+ v = value
+ }
+ v = FixFullURL(v)
+ config.Section(section).Key(key).SetValue(v)
+}
+
+// ExternalDomain returns the Host for the request, using the fixed externalDomain value unless UseProxyHost is true.
+func ExternalDomain(gc *gin.Context) string {
+ if !UseProxyHost || gc.Request.Host == "" {
+ return externalDomain
+ }
+ return gc.Request.Host
+}
+
+// ExternalDomainNoPort attempts to return ExternalDomain() with the port removed. If the internally-used method fails, it is assumed the domain has no port anyway.
+func (app *appContext) ExternalDomainNoPort(gc *gin.Context) string {
+ domain := ExternalDomain(gc)
+ host, _, err := net.SplitHostPort(domain)
if err != nil {
- return err
+ return domain
+ }
+ return host
+}
+
+// ExternalURI returns the External URI of jfa-go's root directory (by default, where the admin page is), using the fixed externalURI value unless UseProxyHost is true and gc is not nil.
+// When nil is passed, externalURI is returned.
+func ExternalURI(gc *gin.Context) string {
+ if gc == nil {
+ return externalURI
}
- app.MustSetValue("jellyfin", "public_server", app.config.Section("jellyfin").Key("server").String())
+ var proto string
+ if gc.Request.TLS != nil || gc.Request.Header.Get("X-Forwarded-Proto") == "https" || gc.Request.Header.Get("X-Forwarded-Protocol") == "https" {
+ proto = "https://"
+ } else {
+ proto = "http://"
+ }
- app.MustSetValue("ui", "redirect_url", app.config.Section("jellyfin").Key("public_server").String())
+ // app.debug.Printf("Request: %+v\n", gc.Request)
+ if UseProxyHost && gc.Request.Host != "" {
+ return proto + gc.Request.Host + PAGES.Base
+ }
+ return externalURI
+}
- for _, key := range app.config.Section("files").Keys() {
+func (app *appContext) EvaluateRelativePath(gc *gin.Context, path string) string {
+ if !strings.HasPrefix(path, "/") {
+ return path
+ }
+
+ var proto string
+ if gc.Request.TLS != nil || gc.Request.Header.Get("X-Forwarded-Proto") == "https" || gc.Request.Header.Get("X-Forwarded-Protocol") == "https" {
+ proto = "https://"
+ } else {
+ proto = "http://"
+ }
+
+ return proto + ExternalDomain(gc) + path
+}
+
+// NewConfig reads and patches a config file for use. Passed loggers are used only once. Some dependencies can be reloaded after this is called with ReloadDependents(app).
+func NewConfig(configPathOrContents any, dataPath string, logs LoggerSet) (*Config, error) {
+ var err error
+ config := &Config{}
+ config.File, err = ini.ShadowLoad(configPathOrContents)
+ if err != nil {
+ return config, err
+ }
+
+ // URLs
+ config.MustSetURLPath("ui", "url_base", "")
+ config.MustSetURLPath("url_paths", "admin", "")
+ config.MustSetURLPath("url_paths", "user_page", "/my/account")
+ config.MustSetURLPath("url_paths", "form", "/invite")
+ PAGES.Base = FormatSubpath(config.Section("ui").Key("url_base").String(), true)
+ PAGES.Admin = FormatSubpath(config.Section("url_paths").Key("admin").String(), true)
+ PAGES.MyAccount = FormatSubpath(config.Section("url_paths").Key("user_page").String(), true)
+ PAGES.Form = FormatSubpath(config.Section("url_paths").Key("form").String(), true)
+ if !(config.Section("user_page").Key("enabled").MustBool(true)) {
+ PAGES.MyAccount = "disabled"
+ }
+ if PAGES.Base == PAGES.Form || PAGES.Base == "/accounts" || PAGES.Base == "/settings" || PAGES.Base == "/activity" {
+ logs.err.Printf(lm.BadURLBase, PAGES.Base)
+ }
+ logs.info.Printf(lm.SubpathBlockMessage, PAGES.Base, PAGES.Admin, PAGES.MyAccount, PAGES.Form)
+
+ config.MustCorrectURL("jellyfin", "server", "")
+ config.MustCorrectURL("jellyfin", "public_server", config.Section("jellyfin").Key("server").String())
+ config.MustCorrectURL("ui", "redirect_url", config.Section("jellyfin").Key("public_server").String())
+
+ for _, key := range config.Section("files").Keys() {
if name := key.Name(); name != "html_templates" && name != "lang_files" {
- key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json"))))
+ key.SetValue(key.MustString(filepath.Join(dataPath, (key.Name() + ".json"))))
}
}
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users", "announcements", "custom_user_page_content"} {
- app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json"))))
+ config.Section("files").Key(key).SetValue(config.Section("files").Key(key).MustString(filepath.Join(dataPath, (key + ".json"))))
}
for _, key := range []string{"matrix_sql"} {
- app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".db"))))
+ config.Section("files").Key(key).SetValue(config.Section("files").Key(key).MustString(filepath.Join(dataPath, (key + ".db"))))
}
- app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/")
- if app.URLBase == "/invite" || app.URLBase == "/accounts" || app.URLBase == "/settings" || app.URLBase == "/activity" {
- app.err.Printf(lm.BadURLBase, app.URLBase)
+ // 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)
}
- app.ExternalURI = strings.TrimSuffix(strings.TrimSuffix(app.config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/")
- if !strings.HasSuffix(app.ExternalURI, app.URLBase) {
- app.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)
+ }
}
- if app.ExternalURI == "" {
- app.err.Println(lm.NoExternalHost + lm.LoginWontSave)
- }
- u, err := url.Parse(app.ExternalURI)
+ u, err := url.Parse(externalURI)
if err == nil {
- app.ExternalDomain = u.Hostname()
+ externalDomain = u.Hostname()
}
- app.config.Section("email").Key("no_username").SetValue(strconv.FormatBool(app.config.Section("email").Key("no_username").MustBool(false)))
+ config.Section("email").Key("no_username").SetValue(strconv.FormatBool(config.Section("email").Key("no_username").MustBool(false)))
- app.MustSetValue("password_resets", "email_html", "jfa-go:"+"email.html")
- app.MustSetValue("password_resets", "email_text", "jfa-go:"+"email.txt")
+ // FIXME: Remove all these, eventually
+ // config.MustSetValue("password_resets", "email_html", "jfa-go:"+"password-reset.html")
+ // config.MustSetValue("password_resets", "email_text", "jfa-go:"+"password-reset.txt")
- app.MustSetValue("invite_emails", "email_html", "jfa-go:"+"invite-email.html")
- app.MustSetValue("invite_emails", "email_text", "jfa-go:"+"invite-email.txt")
+ // config.MustSetValue("invite_emails", "email_html", "jfa-go:"+"invite-email.html")
+ // config.MustSetValue("invite_emails", "email_text", "jfa-go:"+"invite-email.txt")
- app.MustSetValue("email_confirmation", "email_html", "jfa-go:"+"confirmation.html")
- app.MustSetValue("email_confirmation", "email_text", "jfa-go:"+"confirmation.txt")
+ // config.MustSetValue("email_confirmation", "email_html", "jfa-go:"+"confirmation.html")
+ // config.MustSetValue("email_confirmation", "email_text", "jfa-go:"+"confirmation.txt")
- app.MustSetValue("notifications", "expiry_html", "jfa-go:"+"expired.html")
- app.MustSetValue("notifications", "expiry_text", "jfa-go:"+"expired.txt")
+ // config.MustSetValue("notifications", "expiry_html", "jfa-go:"+"expired.html")
+ // config.MustSetValue("notifications", "expiry_text", "jfa-go:"+"expired.txt")
- app.MustSetValue("notifications", "created_html", "jfa-go:"+"created.html")
- app.MustSetValue("notifications", "created_text", "jfa-go:"+"created.txt")
+ // config.MustSetValue("notifications", "created_html", "jfa-go:"+"created.html")
+ // config.MustSetValue("notifications", "created_text", "jfa-go:"+"created.txt")
- app.MustSetValue("deletion", "email_html", "jfa-go:"+"deleted.html")
- app.MustSetValue("deletion", "email_text", "jfa-go:"+"deleted.txt")
-
- app.MustSetValue("smtp", "hello_hostname", "localhost")
- app.MustSetValue("smtp", "cert_validation", "true")
- app.MustSetValue("smtp", "auth_type", "4")
- app.MustSetValue("smtp", "port", "465")
-
- app.MustSetValue("activity_log", "keep_n_records", "1000")
- app.MustSetValue("activity_log", "delete_after_days", "90")
-
- sc := app.config.Section("discord").Key("start_command").MustString("start")
- app.config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
-
- jfUrl := app.config.Section("jellyfin").Key("server").String()
- if !(strings.HasPrefix(jfUrl, "http://") || strings.HasPrefix(jfUrl, "https://")) {
- app.config.Section("jellyfin").Key("server").SetValue("http://" + jfUrl)
- }
+ // config.MustSetValue("deletion", "email_html", "jfa-go:"+"deleted.html")
+ // config.MustSetValue("deletion", "email_text", "jfa-go:"+"deleted.txt")
// Deletion template is good enough for these as well.
- app.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html")
- app.MustSetValue("disable_enable", "disabled_text", "jfa-go:"+"deleted.txt")
- app.MustSetValue("disable_enable", "enabled_html", "jfa-go:"+"deleted.html")
- app.MustSetValue("disable_enable", "enabled_text", "jfa-go:"+"deleted.txt")
+ // config.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html")
+ // config.MustSetValue("disable_enable", "disabled_text", "jfa-go:"+"deleted.txt")
+ // config.MustSetValue("disable_enable", "enabled_html", "jfa-go:"+"deleted.html")
+ // config.MustSetValue("disable_enable", "enabled_text", "jfa-go:"+"deleted.txt")
- app.MustSetValue("welcome_email", "email_html", "jfa-go:"+"welcome.html")
- app.MustSetValue("welcome_email", "email_text", "jfa-go:"+"welcome.txt")
+ // config.MustSetValue("welcome_email", "email_html", "jfa-go:"+"welcome.html")
+ // config.MustSetValue("welcome_email", "email_text", "jfa-go:"+"welcome.txt")
- app.MustSetValue("template_email", "email_html", "jfa-go:"+"template.html")
- app.MustSetValue("template_email", "email_text", "jfa-go:"+"template.txt")
+ // config.MustSetValue("template_email", "email_html", "jfa-go:"+"template.html")
+ // config.MustSetValue("template_email", "email_text", "jfa-go:"+"template.txt")
- app.MustSetValue("user_expiry", "behaviour", "disable_user")
- app.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html")
- app.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt")
+ config.MustSetValue("user_expiry", "behaviour", "disable_user")
+ // config.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html")
+ // config.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt")
- app.MustSetValue("user_expiry", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html")
- app.MustSetValue("user_expiry", "adjustment_email_text", "jfa-go:"+"expiry-adjusted.txt")
+ // config.MustSetValue("user_expiry", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html")
+ // config.MustSetValue("user_expiry", "adjustment_email_text", "jfa-go:"+"expiry-adjusted.txt")
- app.MustSetValue("email", "collect", "true")
+ // config.MustSetValue("user_expiry", "reminder_email_html", "jfa-go:"+"expiry-reminder.html")
+ // config.MustSetValue("user_expiry", "reminder_email_text", "jfa-go:"+"expiry-reminder.txt")
- app.MustSetValue("matrix", "topic", "Jellyfin notifications")
- app.MustSetValue("matrix", "show_on_reg", "true")
+ fnameSettingSuffix := []string{"html", "text"}
+ fnameExtension := []string{"html", "txt"}
- app.MustSetValue("discord", "show_on_reg", "true")
+ for _, cc := range customContent {
+ if cc.SourceFile.DefaultValue == "" {
+ continue
+ }
+ for i := range fnameSettingSuffix {
+ config.MustSetValue(cc.SourceFile.Section, cc.SourceFile.SettingPrefix+fnameSettingSuffix[i], "jfa-go:"+cc.SourceFile.DefaultValue+"."+fnameExtension[i])
+ }
+ }
- app.MustSetValue("telegram", "show_on_reg", "true")
+ config.MustSetValue("smtp", "hello_hostname", "localhost")
+ config.MustSetValue("smtp", "cert_validation", "true")
+ config.MustSetValue("smtp", "auth_type", "4")
+ config.MustSetValue("smtp", "port", "465")
- app.MustSetValue("backups", "every_n_minutes", "1440")
- app.MustSetValue("backups", "path", filepath.Join(app.dataPath, "backups"))
- app.MustSetValue("backups", "keep_n_backups", "20")
+ config.MustSetValue("activity_log", "keep_n_records", "1000")
+ config.MustSetValue("activity_log", "delete_after_days", "90")
- app.config.Section("jellyfin").Key("version").SetValue(version)
- app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
- app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
+ sc := config.Section("discord").Key("start_command").MustString("start")
+ config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
- LOGIP = app.config.Section("advanced").Key("log_ips").MustBool(false)
- LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false)
+ config.MustSetValue("email", "collect", "true")
+ collect := config.Section("email").Key("collect").MustBool(true)
+ required := config.Section("email").Key("required").MustBool(false) && collect
+ config.Section("email").Key("required").SetValue(strconv.FormatBool(required))
+ unique := config.Section("email").Key("require_unique").MustBool(false) && collect
+ config.Section("email").Key("require_unique").SetValue(strconv.FormatBool(unique))
- app.MustSetValue("advanced", "auth_retry_count", "6")
- app.MustSetValue("advanced", "auth_retry_gap", "10")
+ config.MustSetValue("matrix", "topic", "Jellyfin notifications")
+ config.MustSetValue("matrix", "show_on_reg", "true")
- app.MustSetValue("ui", "port", "8056")
- app.MustSetValue("advanced", "tls_port", "8057")
+ config.MustSetValue("discord", "show_on_reg", "true")
+
+ config.MustSetValue("telegram", "show_on_reg", "true")
+
+ config.MustSetValue("backups", "every_n_minutes", "1440")
+ config.MustSetValue("backups", "path", filepath.Join(dataPath, "backups"))
+ config.MustSetValue("backups", "keep_n_backups", "20")
+ config.MustSetValue("backups", "keep_previous_version_backup", "true")
+
+ config.Section("jellyfin").Key("version").SetValue(version)
+ config.Section("jellyfin").Key("device").SetValue("jfa-go")
+ config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
+
+ config.MustSetValue("jellyfin", "cache_timeout", "30")
+ config.MustSetValue("jellyfin", "web_cache_async_timeout", "1")
+ config.MustSetValue("jellyfin", "web_cache_sync_timeout", "10")
+
+ LOGIP = config.Section("advanced").Key("log_ips").MustBool(false)
+ LOGIPU = config.Section("advanced").Key("log_ips_users").MustBool(false)
+
+ config.MustSetValue("advanced", "auth_retry_count", "6")
+ config.MustSetValue("advanced", "auth_retry_gap", "10")
+
+ config.MustSetValue("ui", "port", "8056")
+ config.MustSetValue("advanced", "tls_port", "8057")
+
+ config.MustSetValue("advanced", "value_log_size", "512")
pwrMethods := []string{"allow_pwr_username", "allow_pwr_email", "allow_pwr_contact_method"}
allDisabled := true
for _, v := range pwrMethods {
- if app.config.Section("user_page").Key(v).MustBool(true) {
+ if config.Section("user_page").Key(v).MustBool(true) {
allDisabled = false
}
}
if allDisabled {
- app.info.Println(lm.EnableAllPWRMethods)
+ logs.info.Println(lm.EnableAllPWRMethods)
for _, v := range pwrMethods {
- app.config.Section("user_page").Key(v).SetValue("true")
+ config.Section("user_page").Key(v).SetValue("true")
}
}
- messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false)
- telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false)
- discordEnabled = app.config.Section("discord").Key("enabled").MustBool(false)
- matrixEnabled = app.config.Section("matrix").Key("enabled").MustBool(false)
+ messagesEnabled = config.Section("messages").Key("enabled").MustBool(false)
+ telegramEnabled = config.Section("telegram").Key("enabled").MustBool(false)
+ discordEnabled = config.Section("discord").Key("enabled").MustBool(false)
+ matrixEnabled = config.Section("matrix").Key("enabled").MustBool(false)
if !messagesEnabled {
emailEnabled = false
telegramEnabled = false
discordEnabled = false
matrixEnabled = false
- } else if app.config.Section("email").Key("method").MustString("") == "" {
+ } else if config.Section("email").Key("method").MustString("") == "" {
emailEnabled = false
} else {
emailEnabled = true
@@ -187,31 +335,64 @@ func (app *appContext) loadConfig() error {
messagesEnabled = false
}
- if app.proxyEnabled = app.config.Section("advanced").Key("proxy").MustBool(false); app.proxyEnabled {
- app.proxyConfig = easyproxy.ProxyConfig{}
- app.proxyConfig.Protocol = easyproxy.HTTP
- if strings.Contains(app.config.Section("advanced").Key("proxy_protocol").MustString("http"), "socks") {
- app.proxyConfig.Protocol = easyproxy.SOCKS5
+ if proxyEnabled := config.Section("advanced").Key("proxy").MustBool(false); proxyEnabled {
+ config.proxyConfig = &easyproxy.ProxyConfig{}
+ config.proxyConfig.Protocol = easyproxy.HTTP
+ if strings.Contains(config.Section("advanced").Key("proxy_protocol").MustString("http"), "socks") {
+ config.proxyConfig.Protocol = easyproxy.SOCKS5
}
- app.proxyConfig.Addr = app.config.Section("advanced").Key("proxy_address").MustString("")
- app.proxyConfig.User = app.config.Section("advanced").Key("proxy_user").MustString("")
- app.proxyConfig.Password = app.config.Section("advanced").Key("proxy_password").MustString("")
- app.proxyTransport, err = easyproxy.NewTransport(app.proxyConfig)
+ config.proxyConfig.Addr = config.Section("advanced").Key("proxy_address").MustString("")
+ config.proxyConfig.User = config.Section("advanced").Key("proxy_user").MustString("")
+ config.proxyConfig.Password = config.Section("advanced").Key("proxy_password").MustString("")
+ config.proxyTransport, err = easyproxy.NewTransport(*(config.proxyConfig))
if err != nil {
- app.err.Printf(lm.FailedInitProxy, app.proxyConfig.Addr, err)
+ logs.err.Printf(lm.FailedInitProxy, config.proxyConfig.Addr, err)
// As explained in lm.FailedInitProxy, sleep here might grab the admin's attention,
// Since we don't crash on this failing.
time.Sleep(15 * time.Second)
- app.proxyEnabled = false
+ config.proxyConfig = nil
+ config.proxyTransport = nil
} else {
- app.proxyEnabled = true
- app.info.Printf(lm.InitProxy, app.proxyConfig.Addr)
+ logs.info.Printf(lm.InitProxy, config.proxyConfig.Addr)
}
}
- app.MustSetValue("updates", "enabled", "true")
- releaseChannel := app.config.Section("updates").Key("channel").String()
- if app.config.Section("updates").Key("enabled").MustBool(false) {
+ config.MustSetValue("updates", "enabled", "true")
+
+ substituteStrings = config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("")
+
+ if substituteStrings != "" {
+ v := config.Section("ui").Key("success_message")
+ v.SetValue(strings.ReplaceAll(v.String(), "Jellyfin", substituteStrings))
+ }
+
+ datePattern = config.Section("messages").Key("date_format").String()
+ timePattern = `%H:%M`
+ if !(config.Section("messages").Key("use_24h").MustBool(true)) {
+ timePattern = `%I:%M %p`
+ }
+
+ return config, nil
+}
+
+// ReloadDependents re-initialises or applies changes to components of the app which can be reconfigured without restarting.
+func (config *Config) ReloadDependents(app *appContext) {
+ oldFormLang := config.Section("ui").Key("language").MustString("")
+ if oldFormLang != "" {
+ app.storage.lang.chosenUserLang = oldFormLang
+ }
+ newFormLang := config.Section("ui").Key("language-form").MustString("")
+ if newFormLang != "" {
+ app.storage.lang.chosenUserLang = newFormLang
+ }
+
+ app.storage.lang.chosenAdminLang = config.Section("ui").Key("language-admin").MustString("en-us")
+ app.storage.lang.chosenEmailLang = config.Section("email").Key("language").MustString("en-us")
+ app.storage.lang.chosenPWRLang = config.Section("password_resets").Key("language").MustString("en-us")
+ app.storage.lang.chosenTelegramLang = config.Section("telegram").Key("language").MustString("en-us")
+
+ releaseChannel := config.Section("updates").Key("channel").String()
+ if config.Section("updates").Key("enabled").MustBool(false) {
v := version
if releaseChannel == "stable" {
if version == "git" {
@@ -220,9 +401,9 @@ func (app *appContext) loadConfig() error {
} else if releaseChannel == "unstable" {
v = "git"
}
- app.updater = newUpdater(baseURL, namespace, repo, v, commit, updater)
- if app.proxyEnabled {
- app.updater.SetTransport(app.proxyTransport)
+ app.updater = NewUpdater(baseURL, namespace, repo, v, commit, updater)
+ if config.proxyTransport != nil {
+ app.updater.SetTransport(config.proxyTransport)
}
}
if releaseChannel == "" {
@@ -231,32 +412,22 @@ func (app *appContext) loadConfig() error {
} else {
releaseChannel = "stable"
}
- app.MustSetValue("updates", "channel", releaseChannel)
+ config.MustSetValue("updates", "channel", releaseChannel)
}
- substituteStrings = app.config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("")
+ app.email = NewEmailer(config, app.storage, app.LoggerSet)
- if substituteStrings != "" {
- v := app.config.Section("ui").Key("success_message")
- v.SetValue(strings.ReplaceAll(v.String(), "Jellyfin", substituteStrings))
+}
+
+func (app *appContext) ReloadConfig() {
+ var err error = nil
+ app.config, err = NewConfig(app.configPath, app.dataPath, app.LoggerSet)
+ if err != nil {
+ app.err.Fatalf(lm.FailedLoadConfig, app.configPath, err)
}
- oldFormLang := app.config.Section("ui").Key("language").MustString("")
- if oldFormLang != "" {
- app.storage.lang.chosenUserLang = oldFormLang
- }
- newFormLang := app.config.Section("ui").Key("language-form").MustString("")
- if newFormLang != "" {
- app.storage.lang.chosenUserLang = newFormLang
- }
- app.storage.lang.chosenAdminLang = app.config.Section("ui").Key("language-admin").MustString("en-us")
- app.storage.lang.chosenEmailLang = app.config.Section("email").Key("language").MustString("en-us")
- app.storage.lang.chosenPWRLang = app.config.Section("password_resets").Key("language").MustString("en-us")
- app.storage.lang.chosenTelegramLang = app.config.Section("telegram").Key("language").MustString("en-us")
-
- app.email = NewEmailer(app)
-
- return nil
+ app.config.ReloadDependents(app)
+ app.info.Printf(lm.LoadConfig, app.configPath)
}
func (app *appContext) PatchConfigBase() {
diff --git a/config/config-base.yaml b/config/config-base.yaml
index ff427d7..03afac7 100644
--- a/config/config-base.yaml
+++ b/config/config-base.yaml
@@ -1,3 +1,60 @@
+order:
+ - section: ui
+ - section: advanced
+ - section: jellyfin
+ - group: sign_up
+ - group: accounts
+ - section: messages
+ - group: external_services
+ - section: activity_log
+ - section: backups
+ - section: updates
+ - section: url_paths
+ - section: template_email
+ - section: files
+groups:
+ - group: external_services
+ name: "Integrations"
+ description: "Integrations with external services."
+ members:
+ - group: email
+ - group: chatbots
+ - section: ombi
+ - section: jellyseerr
+ - section: webhooks
+ - group: email
+ name: "Email"
+ description: "Options for sending emails through jfa-go."
+ members:
+ - section: email
+ - section: smtp
+ - section: mailgun
+ - section: email_confirmation
+ - group: chatbots
+ name: "Chatbots"
+ description: "Options for messaging through chat services."
+ members:
+ - section: discord
+ - section: telegram
+ - section: matrix
+ - group: sign_up
+ name: "Invites & Referrals"
+ description: "Settings relating to invites, the sign up page and referrals."
+ members:
+ - section: captcha
+ - section: password_validation
+ - section: invite_emails
+ - section: notifications
+ - section: welcome_email
+ - group: accounts
+ name: "Accounts"
+ description: "Settings relating to account management."
+ members:
+ - section: user_page
+ - section: password_resets
+ - section: user_expiry
+ - section: disable_enable
+ - section: deletion
sections:
- section: updates
meta:
@@ -65,6 +122,20 @@ sections:
type: number
value: 30
description: Timeout of user cache in minutes. Set to 0 to disable.
+ - setting: web_cache_async_timeout
+ name: User search cache asynchronous timeout (minutes)
+ requires_restart: true
+ advanced: true
+ type: number
+ value: 1
+ description: "Synchronise after cache is this old, but don't wait for it: The accounts tab will load quickly but show old results until the next request."
+ - setting: web_cache_sync_timeout
+ name: User search cache synchronous timeout (minutes)
+ requires_restart: true
+ advanced: true
+ type: number
+ value: 10
+ description: "Synchronise after cache is this old, and wait for it: The accounts tab may take a little longer to load while it does."
- setting: type
name: Server type
requires_restart: true
@@ -194,21 +265,28 @@ sections:
value: Your account has been created. Click below to continue to Jellyfin.
description: Displayed when a user creates an account. Use the "post-signup card"
in the Message editor for more control.
+ - setting: subfolder_note
+ name: "Reverse proxy-ing to a subfolder?"
+ type: note
+ description: "Put the folder (e.g. /accounts) in \"Reverse Proxy subfolder\", and the full URL including it (e.g. https://jellyf.in/accounts) in \"External jfa-go URL\"."
+ - setting: jfa_url
+ name: External jfa-go URL
+ required: true
+ type: text
+ value: http://accounts.jellyf.in:8056
+ description: The URL at which the jfa-go root (usually the admin page) is accessible, including
+ the subfolder if you use one. While your reverse proxy should report this anyway, server-side actions like sending invite messages don't receive such wisdom.
+ - setting: use_proxy_host
+ name: Use reverse-proxy reported "Host" when possible
+ type: bool
+ value: false
+ description: If enabled, the "Host" reported by your reverse proxy will be used in the web app, rather than the "External jfa-go URL" value. Useful if you regularly access jfa-go from more than one host/domain. Also, make sure your proxy passes X-Forwarded-Proto/X-Forwarded-Protocol.
- setting: url_base
name: Reverse Proxy subfolder
requires_restart: true
type: text
description: URL base for when running jfa-go with a reverse proxy in a subfolder.
include preceding /, e.g "/accounts".
- - setting: jfa_url
- name: External jfa-go URL
- required: true
- depends_true: enabled
- type: text
- value: http://accounts.jellyf.in:8056
- description: The URL at which the jfa-go root (admin page) is accessible, including
- the subfolder if you use one. This is necessary because using a reverse proxy
- means the program has no way of knowing the URL itself.
- setting: redirect_url
name: Form success redirect URL
type: text
@@ -232,6 +310,37 @@ sections:
- ["opaque", "Opaque"]
value: clear
description: Appearance of the Admin login screen.
+ - setting: urlpaths_note
+ name: "URL Paths:"
+ type: note
+ description: "Want \"My Account\" at \"/\" or the admin page at \"/admin\"? Enable advanced settings and check the \"URL Paths\" section."
+- section: url_paths
+ meta:
+ name: URL Paths
+ description: Settings for changing where different pages are accessed. If you change & forget these, they're printed in the logs on startup. Paths should have a slash at the beginning but not at the end.
+ advanced: true
+ settings:
+ - setting: admin
+ name: Admin page subpath
+ type: text
+ required: true
+ requires_restart: true
+ value: ""
+ description: URL subpath the admin page should be at.
+ - setting: user_page
+ name: "\"My Account\" subpath"
+ type: text
+ required: true
+ requires_restart: true
+ value: "/my/account"
+ description: URL subpath the "My Account" page should be at.
+ - setting: form
+ name: Invite subpath
+ type: text
+ required: true
+ requires_restart: true
+ value: "/invite"
+ description: URL subpath invites should be on.
- section: advanced
meta:
name: Advanced
@@ -330,6 +439,19 @@ sections:
requires_restart: true
type: password
description: Leave blank for no Authentication.
+ - setting: value_log_note
+ name: 'Value Log:'
+ type: note
+ depends_true: enabled
+ required: false
+ description: The Value Log (vlog) file in your config folder is used to ensure data integrity in the database. It can get large, so you can adjust the maximum size. After quitting jfa-go, the file can be deleted safely without data loss.
+ style: info
+ - setting: value_log_size
+ name: Database max vlog size (MiB)
+ requires_restart: true
+ type: number
+ value: 256
+ description: Max size for the database's value log. Lower values may reduce performance.
- setting: debug_log_emails
name: 'Debug Storage Logging: Emails'
requires_restart: true
@@ -451,7 +573,7 @@ sections:
meta:
name: Captcha
description: Settings related to user creation CAPTCHAs.
- wiki_link: https://wiki.jfa-go.com/docs/captcha/
+ wiki_link: https://wiki.jfa-go.com/docs/external-services/captcha/
settings:
- setting: enabled
name: Enabled
@@ -605,7 +727,7 @@ sections:
meta:
name: Messages/Notifications
description: General settings for emails/messages.
- wiki_link: https://wiki.jfa-go.com/docs/emails/
+ wiki_link: https://wiki.jfa-go.com/docs/customization/emails/
settings:
- setting: enabled
name: Enabled
@@ -654,9 +776,27 @@ sections:
- ["en-us", "English (US)"]
value: en-us
description: Default email language. Submit a PR on github if you'd like to translate.
+ - setting: collect
+ name: Collect on sign-up
+ type: bool
+ value: true
+ description: Ask for an email address on the sign-up form.
+ - setting: required
+ name: Require on sign-up
+ depends_true: collect
+ type: bool
+ value: false
+ description: Require an email address on sign-up.
+ - setting: require_unique
+ name: Require unique address
+ requires_restart: true
+ depends_true: method
+ type: bool
+ value: false
+ description: Disables using the same address on multiple accounts.
- setting: no_username
name: Use email addresses as username
- depends_true: method
+ depends_true: collect
type: bool
value: false
description: Use email address from invite form as username on Jellyfin.
@@ -668,6 +808,7 @@ sections:
- ["smtp", "SMTP"]
- ["mailgun", "Mailgun"]
value: smtp
+ depends_true: messages|enabled
description: Method of sending email to use.
- setting: address
name: Sent from (address)
@@ -688,28 +829,15 @@ sections:
type: bool
value: false
description: Send emails as plain text instead of HTML.
- - setting: collect
- name: Collect on sign-up
- depends_true: method
- type: bool
- value: true
- description: Ask for an email address on the sign-up form.
- - setting: required
- name: Require on sign-up
- depends_true: collect
- type: bool
- value: false
- description: Require an email address on sign-up.
- - setting: require_unique
- name: Require unique address
- requires_restart: true
- depends_true: method
- type: bool
- value: false
- description: Disables using the same address on multiple accounts.
+ - setting: test_note
+ name: 'Test your settings:'
+ type: note
+ depends_true: enabled
+ required: false
+ description: Go over to the accounts tab, select your user (ensuring you've assigned it an email address) and send yourself an announcement.
- section: mailgun
meta:
- name: Mailgun (Email)
+ name: Mailgun
description: Mailgun API connection settings
depends_true: email|method
settings:
@@ -723,7 +851,7 @@ sections:
value: your api key
- section: smtp
meta:
- name: SMTP (Email)
+ name: SMTP
description: SMTP Server connection settings.
depends_true: email|method
settings:
@@ -737,6 +865,7 @@ sections:
options:
- ["ssl_tls", "SSL/TLS"]
- ["starttls", "STARTTLS"]
+ - ["none", "None (only use locally!)"]
value: starttls
description: Your email provider should provide different ports for each encryption
method. Generally 465 for ssl_tls, 587 for starttls.
@@ -788,7 +917,7 @@ sections:
meta:
name: Discord
description: Settings for Discord invites/signup/notifications
- wiki_link: https://wiki.jfa-go.com/docs/bots/discord/
+ wiki_link: https://wiki.jfa-go.com/docs/external-services/bots/discord/
settings:
- setting: enabled
name: Enabled
@@ -852,7 +981,7 @@ sections:
requires_restart: true
depends_true: provide_invite
type: text
- description: Channel to invite new users to.
+ description: Name of channel to invite new users to.
- setting: apply_role
name: Apply Role on connection
requires_restart: true
@@ -883,7 +1012,7 @@ sections:
name: Telegram
description: Settings for Telegram signup/notifications. See the jfa-go wiki for
info on setting this up.
- wiki_link: https://wiki.jfa-go.com/docs/bots/telegram/
+ wiki_link: https://wiki.jfa-go.com/docs/external-services/bots/telegram/
settings:
- setting: enabled
name: Enabled
@@ -932,7 +1061,7 @@ sections:
name: Matrix
description: Settings for Matrix invites/signup/notifications. See the jfa-go
wiki for info on setting this up.
- wiki_link: https://wiki.jfa-go.com/docs/bots/matrix/
+ wiki_link: https://wiki.jfa-go.com/docs/external-services/bots/matrix/
settings:
- setting: enabled
name: Enabled
@@ -1069,8 +1198,7 @@ sections:
type: note
depends_true: link_reset
required: false
- description: Set the "External jfa-go URL" in General so that links to jfa-go
- can be made.
+ description: Set the "External jfa-go URL" value in General so that links to jfa-go can be made.
- setting: language
name: Default reset link language
requires_restart: true
@@ -1099,9 +1227,9 @@ sections:
description: Subject of password reset emails.
- section: invite_emails
meta:
- name: Invite emails
+ name: Invite Messages
description: Settings for sending invites directly to users.
- depends_true: email|method
+ depends_true: messages|enabled
settings:
- setting: enabled
name: Enabled
@@ -1165,7 +1293,7 @@ sections:
description: Path to custom email text template for announcements/custom messages.
- section: notifications
meta:
- name: Admin invite notifications
+ name: Admin notifications
description: Allows toggling "user created" and "invite expired" notifications
to be sent to the admin per-invite.
depends_true: messages|enabled
@@ -1205,13 +1333,13 @@ sections:
description: Path to user creation notification email in plaintext.
- section: ombi
meta:
- name: Ombi Integration
+ name: Ombi
description: Connect to Ombi to automatically create both Ombi and Jellyfin accounts
for new users. You'll need to add a ombi template to an existing User Profile
for accounts to be created, which you can do by refreshing then checking Settings
> User Profiles. To handle password resets for Ombi & Jellyfin, enable "Use
reset link instead of PIN".
- wiki_link: https://wiki.jfa-go.com/docs/ombi/
+ wiki_link: https://wiki.jfa-go.com/docs/external-services/ombi/
settings:
- setting: enabled
name: Enabled
@@ -1234,11 +1362,16 @@ sections:
description: API Key. Get this from the first tab in Ombi settings.
- section: jellyseerr
meta:
- name: Jellyseerr Integration
+ name: Jellyseerr
description: Connect to Jellyseerr to automatically trigger the import of users
on account creation, and to automatically link contact methods (email, discord
and telegram). A template must be added to a User Profile for accounts to be
created.
+ wiki_link: https://wiki.jfa-go.com/docs/external-services/jellyseerr/
+ aliases:
+ - Jellyseerr
+ - Overseerr
+ - Seerr
settings:
- setting: enabled
name: Enabled
@@ -1265,7 +1398,7 @@ sections:
requires_restart: true
type: text
depends_true: enabled
- description: API Key. Get this from the first tab in Jellyseerr's settings.
+ description: API Key. Get this from the first tab in Jellyseerr's settings (NOT the "Jellyfin" tab!)
- setting: import_existing
name: Import existing users to Jellyseerr
requires_restart: true
@@ -1274,6 +1407,7 @@ sections:
depends_true: enabled
description: Existing users (and those created outside jfa-go) will have their
contact info imported to Jellyseerr.
+ deprecated: true
- setting: constraints_note
name: 'Unique Emails:'
type: note
@@ -1315,6 +1449,12 @@ sections:
value: 20
description: Number of most recent backups to keep. Once this is hit, the oldest
backup will be deleted before doing a new one.
+ - setting: keep_previous_version_backup
+ name: Keep 1 backup from each previous version
+ requires_restart: true
+ type: bool
+ value: true
+ description: Always keep the most recent backup for each jfa-go version, incase updates mess things up. If enabled, these aren't counted by the "Number of backups to keep" setting.
- section: welcome_email
meta:
name: Welcome Message
@@ -1347,7 +1487,7 @@ sections:
name: Email confirmation
description: If enabled, a user will be sent an email confirmation link to ensure
their password is right before they can make an account.
- depends_true: email|method
+ depends_true: email|collect
settings:
- setting: enabled
name: Enabled
@@ -1370,7 +1510,7 @@ sections:
description: Path to custom email in plain text
- section: user_expiry
meta:
- name: User Expiry
+ name: Account Expiry
description: When set on an invite, users will be deleted or disabled a specified
amount of time after they create their account. Expiries can also be set and
extended for invididual users, optionally with a message why.
@@ -1391,11 +1531,15 @@ sections:
description: When set, user accounts will be deleted this many days after expiring
(if "Behaviour" is "Disable user"). Set to 0 to disable.
- setting: send_email
- name: Send email
+ name: Send message
type: bool
value: true
depends_true: messages|enabled
description: Send an email when a user's account expires.
+ - setting: send_reminder_n_days_before
+ name: Send message N days before expiry
+ type: list
+ description: Send users a message N days before their account is due to expire. Multiple can be set.
- setting: subject
name: Email subject
depends_true: messages|enabled
@@ -1431,6 +1575,23 @@ sections:
depends_true: messages|enabled
type: text
description: Path to custom email in plain text
+ - setting: reminder_subject
+ name: 'Reminder: email subject'
+ depends_true: messages|enabled
+ type: text
+ description: Subject of expiry reminder emails.
+ - setting: reminder_email_html
+ name: 'Reminder: Custom email (HTML)'
+ advanced: true
+ depends_true: messages|enabled
+ type: text
+ description: Path to custom email html
+ - setting: reminder_email_text
+ name: 'Reminder: Custom email (plaintext)'
+ advanced: true
+ depends_true: messages|enabled
+ type: text
+ description: Path to custom email in plain text
- section: disable_enable
meta:
name: Account Disabling/Enabling
@@ -1491,7 +1652,7 @@ sections:
description: jfa-go will send a POST request to these URLs when an event occurs,
with relevant information. Request information is logged when debug logging
is enabled.
- wiki_link: https://wiki.jfa-go.com/docs/webhooks/
+ wiki_link: https://wiki.jfa-go.com/docs/dev/webhooks/
settings:
- setting: created
name: User Created
@@ -1509,30 +1670,36 @@ sections:
requires_restart: true
type: text
description: Location of stored invites (json).
+ deprecated: true
- setting: password_resets
name: Password Resets
requires_restart: true
type: text
description: Location of stored non-Jellyfin password resets (json).
+ deprecated: true
- setting: emails
name: Email Addresses
requires_restart: true
type: text
description: Location of stored email addresses (json).
+ deprecated: true
- setting: users
name: User storage
type: text
description: Stores users temporarily when a user expiry is set.
+ deprecated: true
- setting: ombi_template
name: Ombi user template
type: text
description: Location of stored Ombi user template.
+ deprecated: true
- setting: user_profiles
name: User Profiles
requires_restart: true
type: text
description: Location of stored user profiles (encompasses template and configuration
and displayprefs) (json)
+ deprecated: true
- setting: html_templates
name: Custom HTML Template Directory
requires_restart: true
@@ -1550,19 +1717,23 @@ sections:
type: text
description: JSON file generated by program in settings, different from email_html/email_text.
See wiki for more info.
+ deprecated: true
- setting: custom_user_page_content
name: Custom user page content
type: text
description: JSON file generated by program in settings, containing user page
messages. See wiki for more info.
+ deprecated: true
- setting: telegram_users
name: Telegram users
type: text
description: Stores telegram user IDs and language preferences.
+ deprecated: true
- setting: matrix_users
name: Matrix users
type: text
description: Stores matrix user IDs and language preferences.
+ deprecated: true
- setting: matrix_sql
name: Matrix encryption DB
type: text
@@ -1571,7 +1742,9 @@ sections:
name: Discord users
type: text
description: Stores discord user IDs and language preferences.
+ deprecated: true
- setting: announcements
name: Announcement templates
type: text
description: Stores custom announcement templates.
+ deprecated: true
diff --git a/css/base.css b/css/base.css
index d9b53e6..d88d34a 100644
--- a/css/base.css
+++ b/css/base.css
@@ -221,15 +221,8 @@ sup.\~critical, .text-critical {
padding-bottom: 0.1rem;
}
-.settings-section-button {
- width: 100%;
- height: 2.5rem;
-}
-
.settings-section-button:hover, .settings-section-button:focus {
box-sizing: border-box;
- width: 100%;
- height: 2.5rem;
background-color: var(--color-neutral-normal-fill);
filter: brightness(var(--settings-section-button-filter)) !important;
}
@@ -242,7 +235,7 @@ sup.\~critical, .text-critical {
margin-bottom: 0.25rem;
}
-.textarea {
+.textarea:not(code-input *) {
resize: vertical;
}
@@ -254,7 +247,7 @@ sup.\~critical, .text-critical {
overflow-y: visible;
}
-select, textarea {
+select, textarea:not(code-input *) {
color: inherit;
border: 0 solid var(--color-neutral-300);
appearance: none;
@@ -262,7 +255,7 @@ select, textarea {
-moz-appearance: none;
}
-html.dark textarea {
+html.dark textarea:not(code-input *) {
background-color: #202020
}
@@ -320,7 +313,7 @@ p.top {
bottom: 115%;
}
-pre {
+pre:not(code-input *) {
white-space: pre-wrap; /* css-3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */
@@ -466,7 +459,23 @@ section.section:not(.\~neutral) {
@layer components {
.switch {
- @apply flex flex-row gap-1 items-center;
+ @apply flex flex-row gap-2 items-center;
}
}
+:root {
+ /* seems to be the sweet spot */
+ --inside-input-base: -2.6rem;
+
+ /* thought --spacing would do the trick but apparently not */
+ --tailwind-spacing: 0.25rem;
+}
+
+/* places buttons inside a sibling input element (hopefully), based on the flex gap of the parent. */
+.gap-1 > .button.inside-input {
+ margin-left: calc(var(--inside-input-base) - 1.0*var(--tailwind-spacing));
+}
+
+.gap-2 > .button.inside-input {
+ margin-left: calc(var(--inside-input-base) - 2.0*var(--tailwind-spacing));
+}
diff --git a/css/colors.js b/css/colors.js
new file mode 100644
index 0000000..f631140
--- /dev/null
+++ b/css/colors.js
@@ -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"
+};
diff --git a/css/tooltip.css b/css/tooltip.css
index 85a289e..ab4d187 100644
--- a/css/tooltip.css
+++ b/css/tooltip.css
@@ -6,7 +6,7 @@
.tooltip .content {
visibility: hidden;
opacity: 0;
- max-width: 10rem;
+ max-width: 16rem;
min-width: 6rem;
background-color: rgba(0, 0, 0, 0.6);
color: #fff;
@@ -22,9 +22,18 @@
}
.tooltip.below .content {
- top: 2.5rem;
- left: 0;
+ top: calc(100% + 0.125rem);
+ left: 50%;
right: 0;
+ transform: translateX(-50%);
+}
+
+.tooltip.above .content {
+ top: unset;
+ bottom: calc(100% + 0.125rem);
+ left: 50%;
+ right: 0;
+ transform: translateX(-50%);
}
.tooltip.darker .content {
diff --git a/customcontent.go b/customcontent.go
new file mode 100644
index 0000000..54fcccb
--- /dev/null
+++ b/customcontent.go
@@ -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
+}()
diff --git a/discord.go b/discord.go
index df102da..ccbf21c 100644
--- a/discord.go
+++ b/discord.go
@@ -1,12 +1,14 @@
package main
import (
+ "errors"
"fmt"
"net/http"
"strings"
"time"
dg "github.com/bwmarrin/discordgo"
+ "github.com/hrfee/jfa-go/common"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/timshannon/badgerhold/v4"
)
@@ -27,6 +29,18 @@ type DiscordDaemon struct {
commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string)
commandIDs []string
commandDescriptions []*dg.ApplicationCommand
+ retryOpts *common.MustAuthenticateOptions
+}
+
+func EmptyDiscordUser() *DiscordUser {
+ return &DiscordUser{
+ ID: "",
+ Username: "",
+ Discriminator: "",
+ Lang: "",
+ Contact: false,
+ JellyfinID: "",
+ }
}
func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
@@ -58,6 +72,16 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
dd.users[user.ID] = user
}
+ dd.retryOpts = &common.MustAuthenticateOptions{
+ RetryCount: app.config.Section("advanced").Key("auth_retry_count").MustInt(6),
+ RetryGap: time.Duration(app.config.Section("advanced").Key("auth_retry_gap").MustInt(10)) * time.Second,
+ LogFailures: true,
+ }
+
+ dd.bot.AddHandler(dd.commandHandler)
+
+ dd.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers | dg.IntentsGuildInvites
+
return dd, nil
}
@@ -98,13 +122,27 @@ func (d *DiscordDaemon) MustGetUser(channelID, userID, discrim, username string)
return d.NewUnknownUser(channelID, userID, discrim, username)
}
-func (d *DiscordDaemon) run() {
- d.bot.AddHandler(d.commandHandler)
+func (d *DiscordDaemon) Run() {
+ ro := common.MustAuthenticateOptions{}
+ ro = *d.retryOpts
+ ro.Counter = 0
+ d.run(&ro)
+}
- d.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers | dg.IntentsGuildInvites
+func (d *DiscordDaemon) run(retry *common.MustAuthenticateOptions) {
if err := d.bot.Open(); err != nil {
- d.app.err.Printf(lm.FailedStartDaemon, lm.Discord, err)
- return
+ if retry == nil || retry.LogFailures {
+ d.app.err.Printf(lm.FailedStartDaemon, lm.Discord, err)
+ }
+ if retry != nil {
+ retry.Counter += 1
+ if retry.Counter >= retry.RetryCount {
+ return
+ }
+ time.Sleep(retry.RetryGap)
+ d.run(retry)
+ return
+ }
}
// Wait for everything to populate, it's slow sometimes.
for d.bot.State == nil {
@@ -134,15 +172,18 @@ func (d *DiscordDaemon) run() {
d.InviteChannel.Name = invChannel
}
}
- err = d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start"))
+ d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start"))
defer d.deregisterCommands()
defer d.bot.Close()
- go d.registerCommands()
+ ro := common.MustAuthenticateOptions{}
+ ro = *(d.retryOpts)
+ ro.Counter = 0
+
+ go d.registerCommands(&ro)
<-d.ShutdownChannel
d.ShutdownChannel <- "Down"
- return
}
// ListRoles returns a list of available (excluding bot and @everyone) roles in a guild as a list of containing an array of the guild ID and its name.
@@ -196,7 +237,7 @@ func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconU
var inv *dg.Invite
var err error
if d.InviteChannel.Name == "" {
- d.app.err.Println(lm.FailedCreateDiscordInviteChannel, lm.InviteChannelEmpty)
+ d.app.err.Printf(lm.FailedCreateDiscordInviteChannel, lm.InviteChannelEmpty)
return
}
if d.InviteChannel.ID == "" {
@@ -332,7 +373,7 @@ func (d *DiscordDaemon) Shutdown() {
close(d.ShutdownChannel)
}
-func (d *DiscordDaemon) registerCommands() {
+func (d *DiscordDaemon) registerCommands(retry *common.MustAuthenticateOptions) {
d.commandDescriptions = []*dg.ApplicationCommand{
{
Name: d.app.config.Section("discord").Key("start_command").MustString("start"),
@@ -429,7 +470,27 @@ func (d *DiscordDaemon) registerCommands() {
// if err != nil {
// d.app.err.Printf("Discord: Cannot create commands: %v", err)
// }
- for i, cmd := range d.commandDescriptions {
+
+ cCommands, err := d.bot.ApplicationCommandBulkOverwrite(d.bot.State.User.ID, d.guildID, d.commandDescriptions)
+ if err != nil {
+ if retry == nil || retry.LogFailures {
+ d.app.err.Printf(lm.FailedRegisterDiscordCommand, "*", err)
+ }
+ if retry != nil {
+ retry.Counter += 1
+ if retry.Counter >= retry.RetryCount {
+ return
+ }
+ time.Sleep(retry.RetryGap)
+ d.registerCommands(retry)
+ }
+ } else {
+ for i := range len(d.commandDescriptions) {
+ d.commandIDs[i] = cCommands[i].ID
+ }
+ d.app.debug.Printf(lm.RegisterDiscordCommand, "*")
+ }
+ /* for i, cmd := range d.commandDescriptions {
command, err := d.bot.ApplicationCommandCreate(d.bot.State.User.ID, d.guildID, cmd)
if err != nil {
d.app.err.Printf(lm.FailedRegisterDiscordCommand, cmd.Name, err)
@@ -437,7 +498,7 @@ func (d *DiscordDaemon) registerCommands() {
d.app.debug.Printf(lm.RegisterDiscordCommand, cmd.Name)
d.commandIDs[i] = command.ID
}
- }
+ } */
}
func (d *DiscordDaemon) deregisterCommands() {
@@ -605,6 +666,21 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
requester := d.MustGetUser(channel.ID, i.Interaction.Member.User.ID, i.Interaction.Member.User.Discriminator, i.Interaction.Member.User.Username)
d.users[i.Interaction.Member.User.ID] = requester
recipient := i.ApplicationCommandData().Options[0].UserValue(s)
+
+ // We don't reveal much in the message response itself so we can re-use this easily.
+ sendResponse := func(langKey string) {
+ err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
+ Type: dg.InteractionResponseChannelMessageWithSource,
+ Data: &dg.InteractionResponseData{
+ Content: d.app.storage.lang.Telegram[lang].Strings.get(langKey),
+ Flags: 64, // Ephemeral
+ },
+ })
+ if err != nil {
+ d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
+ }
+ }
+
// d.app.debug.Println(invuser)
//label := i.ApplicationCommandData().Options[2].StringValue()
//profile := i.ApplicationCommandData().Options[3].StringValue()
@@ -612,11 +688,10 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
//if mins > 0 {
// expmin = mins
//}
- // Check whether requestor is linked to the admin account
- requesterEmail, ok := d.app.storage.GetEmailsKey(requester.JellyfinID)
- if !(ok && requesterEmail.Admin) {
+ // We want the same criteria for running this command as accessing the admin page (i.e. an "admin" of some sort)
+ if !(d.app.canAccessAdminPageByID(requester.JellyfinID)) {
d.app.err.Printf(lm.FailedGenerateInvite, fmt.Sprintf(lm.NonAdminUser, requester.JellyfinID))
- // FIXME: add response message
+ sendResponse("noPermission")
return
}
@@ -658,54 +733,45 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
}
}
- if recipient != nil && d.app.config.Section("invite_emails").Key("enabled").MustBool(false) {
- invname, err := d.bot.GuildMember(d.guildID, recipient.ID)
- invite.SendTo = invname.User.Username
- msg, err := d.app.email.constructInvite(invite.Code, invite, d.app, false)
- if err != nil {
- invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, invite.Code, err)
- d.app.err.Println(invite.SendTo)
- err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
- Type: dg.InteractionResponseChannelMessageWithSource,
- Data: &dg.InteractionResponseData{
- Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInviteFailure"),
- Flags: 64, // Ephemeral
- },
- })
+ if 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(lm.FailedReply, lm.Discord, requester.ID, err)
- }
- } else {
- var err error
- err = d.app.discord.SendDM(msg, recipient.ID)
- if err != nil {
- invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, RenderDiscordUsername(recipient), err)
- d.app.err.Println(invite.SendTo)
- err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
- Type: dg.InteractionResponseChannelMessageWithSource,
- Data: &dg.InteractionResponseData{
- Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInviteFailure"),
- Flags: 64, // Ephemeral
- },
+ // 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,
})
- if err != nil {
- d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
- }
- } else {
- d.app.info.Printf(lm.SentInviteMessage, invite.Code, RenderDiscordUsername(recipient))
- err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
- Type: dg.InteractionResponseChannelMessageWithSource,
- Data: &dg.InteractionResponseData{
- Content: d.app.storage.lang.Telegram[lang].Strings.get("sentInvite"),
- Flags: 64, // Ephemeral
- },
- })
- if err != nil {
- d.app.err.Printf(lm.FailedReply, lm.Discord, requester.ID, err)
- }
+ d.app.err.Printf(lm.FailedConstructInviteMessage, invite.Code, err)
}
}
+
+ if err == nil {
+ err = d.app.discord.SendDM(msg, recipient.ID)
+ }
+
+ if err == nil {
+ d.app.info.Printf(lm.SentInviteMessage, invite.Code, RenderDiscordUsername(recipient))
+ invite.SentTo.Success = append(invite.SentTo.Success, invname.User.Username)
+ sendResponse("sentInvite")
+ }
+
+ if err != nil {
+ invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, RenderDiscordUsername(recipient), err)
+ sendResponse("sentInviteFailure")
+ }
}
+
//if profile != "" {
d.app.storage.SetInvitesKey(invite.Code, invite)
}
diff --git a/easyproxy/go.mod b/easyproxy/go.mod
index b96e18f..f8813b0 100644
--- a/easyproxy/go.mod
+++ b/easyproxy/go.mod
@@ -1,7 +1,7 @@
module github.com/hrfee/jfa-go/easyproxy
-go 1.18
+go 1.24.0
-require golang.org/x/net v0.36.0
+require golang.org/x/net v0.47.0
require github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b
diff --git a/easyproxy/go.sum b/easyproxy/go.sum
index dbdf8e6..c5b971b 100644
--- a/easyproxy/go.sum
+++ b/easyproxy/go.sum
@@ -1,4 +1,4 @@
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b h1:xZ59n7Frzh8CwyfAapUZLSg+gXH5m63YEaFCMpDHhpI=
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b/go.mod h1:uDd4sYVYsqcxAB8j+Q7uhL6IJCs/r1kxib1HV4bgOMg=
-golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
-golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
diff --git a/email.go b/email.go
index 6fb0fac..3be75d5 100644
--- a/email.go
+++ b/email.go
@@ -10,6 +10,7 @@ import (
"html/template"
"io"
"io/fs"
+ "maps"
"net/http"
"net/url"
"os"
@@ -18,6 +19,8 @@ import (
textTemplate "text/template"
"time"
+ sTemplate "github.com/hrfee/simple-template"
+
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/hrfee/jfa-go/easyproxy"
@@ -41,6 +44,9 @@ type Emailer struct {
fromAddr, fromName string
lang emailLang
sender EmailClient
+ config *Config
+ storage *Storage
+ LoggerSet
}
// Message stores content.
@@ -51,7 +57,7 @@ type Message struct {
Markdown string `json:"markdown"`
}
-func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool, datePattern, timePattern string) (d, t, expiresIn string) {
+func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool) (d, t, expiresIn string) {
d = timefmt.Format(expiry, datePattern)
t = timefmt.Format(expiry, timePattern)
currentTime := time.Now()
@@ -73,34 +79,38 @@ func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool, datePattern
}
// NewEmailer configures and returns a new emailer.
-func NewEmailer(app *appContext) *Emailer {
+func NewEmailer(config *Config, storage *Storage, logs LoggerSet) *Emailer {
emailer := &Emailer{
- fromAddr: app.config.Section("email").Key("address").String(),
- fromName: app.config.Section("email").Key("from").String(),
- lang: app.storage.lang.Email[app.storage.lang.chosenEmailLang],
+ fromAddr: config.Section("email").Key("address").String(),
+ fromName: config.Section("email").Key("from").String(),
+ lang: storage.lang.Email[storage.lang.chosenEmailLang],
+ LoggerSet: logs,
+ config: config,
+ storage: storage,
}
- method := app.config.Section("email").Key("method").String()
+ method := emailer.config.Section("email").Key("method").String()
if method == "smtp" {
- sslTLS := false
- if app.config.Section("smtp").Key("encryption").String() == "ssl_tls" {
- sslTLS = true
+ enc := sMail.EncryptionSTARTTLS
+ switch emailer.config.Section("smtp").Key("encryption").String() {
+ case "ssl_tls":
+ enc = sMail.EncryptionSSLTLS
+ case "starttls":
+ enc = sMail.EncryptionSTARTTLS
+ case "none":
+ enc = sMail.EncryptionNone
}
- username := app.config.Section("smtp").Key("username").MustString("")
- password := app.config.Section("smtp").Key("password").String()
+ username := emailer.config.Section("smtp").Key("username").MustString("")
+ password := emailer.config.Section("smtp").Key("password").String()
if username == "" && password != "" {
username = emailer.fromAddr
}
- var proxyConf *easyproxy.ProxyConfig = nil
- if app.proxyEnabled {
- proxyConf = &app.proxyConfig
- }
- authType := sMail.AuthType(app.config.Section("smtp").Key("auth_type").MustInt(4))
- err := emailer.NewSMTP(app.config.Section("smtp").Key("server").String(), app.config.Section("smtp").Key("port").MustInt(465), username, password, sslTLS, app.config.Section("smtp").Key("ssl_cert").MustString(""), app.config.Section("smtp").Key("hello_hostname").String(), app.config.Section("smtp").Key("cert_validation").MustBool(true), authType, proxyConf)
+ authType := sMail.AuthType(emailer.config.Section("smtp").Key("auth_type").MustInt(4))
+ err := emailer.NewSMTP(emailer.config.Section("smtp").Key("server").String(), emailer.config.Section("smtp").Key("port").MustInt(465), username, password, enc, emailer.config.Section("smtp").Key("ssl_cert").MustString(""), emailer.config.Section("smtp").Key("hello_hostname").String(), emailer.config.Section("smtp").Key("cert_validation").MustBool(true), authType, emailer.config.proxyConfig)
if err != nil {
- app.err.Printf(lm.FailedInitSMTP, err)
+ emailer.err.Printf(lm.FailedInitSMTP, err)
}
} else if method == "mailgun" {
- emailer.NewMailgun(app.config.Section("mailgun").Key("api_url").String(), app.config.Section("mailgun").Key("api_key").String(), app.proxyTransport)
+ emailer.NewMailgun(emailer.config.Section("mailgun").Key("api_url").String(), emailer.config.Section("mailgun").Key("api_key").String(), emailer.config.proxyTransport)
} else if method == "dummy" {
emailer.sender = &DummyClient{}
}
@@ -121,14 +131,10 @@ type SMTP struct {
}
// NewSMTP returns an SMTP emailClient.
-func (emailer *Emailer) NewSMTP(server string, port int, username, password string, sslTLS bool, certPath string, helloHostname string, validateCertificate bool, authType sMail.AuthType, proxy *easyproxy.ProxyConfig) (err error) {
+func (emailer *Emailer) NewSMTP(server string, port int, username, password string, encryption sMail.Encryption, certPath string, helloHostname string, validateCertificate bool, authType sMail.AuthType, proxy *easyproxy.ProxyConfig) (err error) {
sender := &SMTP{}
sender.Client = sMail.NewSMTPClient()
- if sslTLS {
- sender.Client.Encryption = sMail.EncryptionSSLTLS
- } else {
- sender.Client.Encryption = sMail.EncryptionSTARTTLS
- }
+ sender.Client.Encryption = encryption
if username != "" || password != "" {
sender.Client.Authentication = authType
sender.Client.Username = username
@@ -160,7 +166,7 @@ func (emailer *Emailer) NewSMTP(server string, port int, username, password stri
var cert []byte
cert, err = os.ReadFile(certPath)
if rootCAs.AppendCertsFromPEM(cert) == false {
- err = errors.New("Failed to append cert to pool")
+ err = errors.New("failed to append cert to pool")
}
}
sender.Client.TLSConfig = &tls.Config{
@@ -242,22 +248,48 @@ type templ interface {
Execute(wr io.Writer, data interface{}) error
}
-func (emailer *Emailer) construct(app *appContext, section, keyFragment string, data map[string]interface{}) (html, text, markdown string, err error) {
- var tpl templ
- if substituteStrings == "" {
- data["jellyfin"] = "Jellyfin"
- } else {
- data["jellyfin"] = substituteStrings
+func (emailer *Emailer) construct(contentInfo CustomContentInfo, cc CustomContent, data map[string]any) (*Message, error) {
+ msg := &Message{
+ Subject: contentInfo.Subject(emailer.config, &emailer.lang),
}
+ // Template the subject for bonus points
+ if subject, err := sTemplate.Template(msg.Subject, data); err == nil {
+ msg.Subject = subject
+ }
+ if cc.Enabled {
+ // Use template email, rather than the built-in's email file.
+ contentInfo.SourceFile = customContent["TemplateEmail"].SourceFile
+ content, err := sTemplate.Template(cc.Content, data)
+ if err != nil {
+ emailer.err.Printf(lm.FailedConstructCustomContent, msg.Subject, err)
+ return msg, err
+ }
+ html := markdown.ToHTML([]byte(content), nil, markdownRenderer)
+ text := stripMarkdown(content)
+ templateData := map[string]interface{}{
+ "text": template.HTML(html),
+ "plaintext": text,
+ "md": content,
+ }
+ data = templateData
+ }
+ var err error = nil
+
+ var tpl templ
+ msg.Text = ""
+ msg.Markdown = ""
+ msg.HTML = ""
+ data["header"] = contentInfo.HeaderText(emailer.config, &emailer.lang)
+ data["footer"] = contentInfo.FooterText(emailer.config, &emailer.lang)
var keys []string
- plaintext := app.config.Section("email").Key("plaintext").MustBool(false)
+ plaintext := emailer.config.Section("email").Key("plaintext").MustBool(false)
if plaintext {
if telegramEnabled || discordEnabled {
keys = []string{"text"}
- text, markdown = "", ""
+ msg.Text, msg.Markdown = "", ""
} else {
keys = []string{"text"}
- text = ""
+ msg.Text = ""
}
} else {
if telegramEnabled || discordEnabled {
@@ -270,9 +302,9 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string,
var filesystem fs.FS
var fpath string
if key == "markdown" {
- filesystem, fpath = app.GetPath(section, keyFragment+"text")
+ filesystem, fpath = emailer.config.GetPath(contentInfo.SourceFile.Section, contentInfo.SourceFile.SettingPrefix+"text")
} else {
- filesystem, fpath = app.GetPath(section, keyFragment+key)
+ filesystem, fpath = emailer.config.GetPath(contentInfo.SourceFile.Section, contentInfo.SourceFile.SettingPrefix+key)
}
if key == "html" {
tpl, err = template.ParseFS(filesystem, fpath)
@@ -280,7 +312,7 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string,
tpl, err = textTemplate.ParseFS(filesystem, fpath)
}
if err != nil {
- return
+ return msg, fmt.Errorf("error reading from fs path \"%s\": %v", fpath, err)
}
// For constructTemplate, if "md" is found in data it's used in stead of "text".
foundMarkdown := false
@@ -293,616 +325,284 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string,
var tplData bytes.Buffer
err = tpl.Execute(&tplData, data)
if err != nil {
- return
+ return msg, err
}
if foundMarkdown {
data["plaintext"], data["md"] = data["md"], data["plaintext"]
}
if key == "html" {
- html = tplData.String()
+ msg.HTML = tplData.String()
} else if key == "text" {
- text = tplData.String()
+ msg.Text = tplData.String()
} else {
- markdown = tplData.String()
+ msg.Markdown = tplData.String()
}
}
- return
+ return msg, nil
}
-func (emailer *Emailer) confirmationValues(code, username, key string, app *appContext, noSub bool) map[string]interface{} {
- template := map[string]interface{}{
+func (emailer *Emailer) baseValues(name string, username string, placeholders bool, values map[string]any) (CustomContentInfo, map[string]any) {
+ contentInfo := customContent[name]
+ template := map[string]any{
+ "username": username,
+ }
+ maps.Copy(template, values)
+ // When generating a version for the user to customise, we'll replace "variable" with "{variable}", so the templater used for custom content understands them.
+ if placeholders {
+ for _, v := range contentInfo.Variables {
+ template[v] = "{" + v + "}"
+ }
+ }
+ return contentInfo, template
+}
+
+func (emailer *Emailer) constructConfirmation(code, username, key string, placeholders bool) (*Message, error) {
+ if placeholders {
+ username = "{username}"
+ }
+ contentInfo, template := emailer.baseValues("EmailConfirmation", username, placeholders, map[string]any{
+ "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
"clickBelow": emailer.lang.EmailConfirmation.get("clickBelow"),
"ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"),
"confirmEmail": emailer.lang.EmailConfirmation.get("confirmEmail"),
- "message": "",
- "username": username,
- }
- if noSub {
- template["helloUser"] = emailer.lang.Strings.get("helloUser")
- empty := []string{"confirmationURL"}
- for _, v := range empty {
- template[v] = "{" + v + "}"
- }
- } else {
- message := app.config.Section("messages").Key("message").String()
- inviteLink := app.ExternalURI
+ })
+ if !placeholders {
+ inviteLink := ExternalURI(nil)
if code == "" { // Personal email change
inviteLink = fmt.Sprintf("%s/my/confirm/%s", inviteLink, url.PathEscape(key))
} else { // Invite email confirmation
- inviteLink = fmt.Sprintf("%s/invite/%s?key=%s", inviteLink, code, url.PathEscape(key))
+ inviteLink = fmt.Sprintf("%s%s/%s?key=%s", inviteLink, PAGES.Form, code, url.PathEscape(key))
}
- template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username})
template["confirmationURL"] = inviteLink
- template["message"] = message
}
- return template
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ return emailer.construct(contentInfo, cc, template)
}
-func (emailer *Emailer) constructConfirmation(code, username, key string, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.EmailConfirmation.get("title")),
- }
- var err error
- template := emailer.confirmationValues(code, username, key, app, noSub)
- message := app.storage.MustGetCustomContentKey("EmailConfirmation")
- if message.Enabled {
- content := templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "email_confirmation", "email_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-// username is optional, but should only be passed once.
-func (emailer *Emailer) constructTemplate(subject, md string, app *appContext, username ...string) (*Message, error) {
- if len(username) != 0 {
- md = templateEmail(md, []string{"{username}"}, nil, map[string]interface{}{"username": username[0]})
- subject = templateEmail(subject, []string{"{username}"}, nil, map[string]interface{}{"username": username[0]})
- }
- email := &Message{Subject: subject}
- html := markdown.ToHTML([]byte(md), nil, markdownRenderer)
- text := stripMarkdown(md)
- message := app.config.Section("messages").Key("message").String()
- var err error
- data := map[string]interface{}{
- "text": template.HTML(html),
- "plaintext": text,
- "message": message,
- "md": md,
- }
- if len(username) != 0 {
- data["username"] = username[0]
- }
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "template_email", "email_", data)
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-func (emailer *Emailer) inviteValues(code string, invite Invite, app *appContext, noSub bool) map[string]interface{} {
+func (emailer *Emailer) constructInvite(invite *Invite, placeholders bool) (*Message, error) {
expiry := invite.ValidTill
- d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern)
- message := app.config.Section("messages").Key("message").String()
- inviteLink := fmt.Sprintf("%s/invite/%s", app.ExternalURI, code)
- template := map[string]interface{}{
+ d, t, expiresIn := emailer.formatExpiry(expiry, false)
+ inviteLink := fmt.Sprintf("%s%s/%s", ExternalURI(nil), PAGES.Form, invite.Code)
+ contentInfo, template := emailer.baseValues("InviteEmail", "", placeholders, map[string]any{
"hello": emailer.lang.InviteEmail.get("hello"),
"youHaveBeenInvited": emailer.lang.InviteEmail.get("youHaveBeenInvited"),
"toJoin": emailer.lang.InviteEmail.get("toJoin"),
"linkButton": emailer.lang.InviteEmail.get("linkButton"),
- "message": "",
"date": d,
"time": t,
"expiresInMinutes": expiresIn,
+ "inviteURL": inviteLink,
+ "inviteExpiry": emailer.lang.InviteEmail.get("inviteExpiry"),
+ })
+ if !placeholders {
+ template["inviteExpiry"] = emailer.lang.InviteEmail.template("inviteExpiry", template)
}
- if noSub {
- template["inviteExpiry"] = emailer.lang.InviteEmail.get("inviteExpiry")
- empty := []string{"inviteURL"}
- for _, v := range empty {
- template[v] = "{" + v + "}"
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ return emailer.construct(contentInfo, cc, template)
+}
+
+func (emailer *Emailer) constructExpiry(invite Invite, placeholders bool) (*Message, error) {
+ expiry := formatDatetime(invite.ValidTill)
+ contentInfo, template := emailer.baseValues("InviteExpiry", "", placeholders, map[string]any{
+ "inviteExpired": emailer.lang.InviteExpiry.get("inviteExpired"),
+ "expiredAt": emailer.lang.InviteExpiry.get("expiredAt"),
+ "code": "\"" + invite.Code + "\"",
+ "time": expiry,
+ })
+ if !placeholders {
+ template["expiredAt"] = emailer.lang.InviteExpiry.template("expiredAt", template)
+ }
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ return emailer.construct(contentInfo, cc, template)
+}
+
+func (emailer *Emailer) constructCreated(username, address string, when time.Time, invite Invite, placeholders bool) (*Message, error) {
+ // NOTE: This was previously invite.Created, not sure why.
+ created := formatDatetime(when)
+ contentInfo, template := emailer.baseValues("UserCreated", username, placeholders, map[string]any{
+ "aUserWasCreated": emailer.lang.UserCreated.get("aUserWasCreated"),
+ "nameString": emailer.lang.Strings.get("name"),
+ "addressString": emailer.lang.Strings.get("emailAddress"),
+ "timeString": emailer.lang.UserCreated.get("time"),
+ "code": "\"" + invite.Code + "\"",
+ "name": username,
+ "time": created,
+ "address": address,
+ })
+ if !placeholders {
+ template["aUserWasCreated"] = emailer.lang.UserCreated.template("aUserWasCreated", template)
+ if emailer.config.Section("email").Key("no_username").MustBool(false) {
+ template["address"] = "n/a"
}
- } else {
- template["inviteExpiry"] = emailer.lang.InviteEmail.template("inviteExpiry", tmpl{"date": d, "time": t, "expiresInMinutes": expiresIn})
- template["inviteURL"] = inviteLink
- template["message"] = message
}
- return template
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ return emailer.construct(contentInfo, cc, template)
}
-func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: app.config.Section("invite_emails").Key("subject").MustString(emailer.lang.InviteEmail.get("title")),
+func (emailer *Emailer) constructReset(pwr PasswordReset, placeholders bool) (*Message, error) {
+ if placeholders {
+ pwr.Username = "{username}"
}
- template := emailer.inviteValues(code, invite, app, noSub)
- var err error
- message := app.storage.MustGetCustomContentKey("InviteEmail")
- if message.Enabled {
- content := templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "invite_emails", "email_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-func (emailer *Emailer) expiryValues(code string, invite Invite, app *appContext, noSub bool) map[string]interface{} {
- expiry := app.formatDatetime(invite.ValidTill)
- template := map[string]interface{}{
- "inviteExpired": emailer.lang.InviteExpiry.get("inviteExpired"),
- "notificationNotice": emailer.lang.InviteExpiry.get("notificationNotice"),
- "code": "\"" + code + "\"",
- "time": expiry,
- }
- if noSub {
- template["expiredAt"] = emailer.lang.InviteExpiry.get("expiredAt")
- } else {
- template["expiredAt"] = emailer.lang.InviteExpiry.template("expiredAt", tmpl{"code": template["code"].(string), "time": template["time"].(string)})
- }
- return template
-}
-
-func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: emailer.lang.InviteExpiry.get("title"),
- }
- var err error
- template := emailer.expiryValues(code, invite, app, noSub)
- message := app.storage.MustGetCustomContentKey("InviteExpiry")
- if message.Enabled {
- content := templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "notifications", "expiry_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-func (emailer *Emailer) createdValues(code, username, address string, invite Invite, app *appContext, noSub bool) map[string]interface{} {
- template := map[string]interface{}{
- "nameString": emailer.lang.Strings.get("name"),
- "addressString": emailer.lang.Strings.get("emailAddress"),
- "timeString": emailer.lang.UserCreated.get("time"),
- "notificationNotice": "",
- "code": "\"" + code + "\"",
- }
- if noSub {
- template["aUserWasCreated"] = emailer.lang.UserCreated.get("aUserWasCreated")
- empty := []string{"name", "address", "time"}
- for _, v := range empty {
- template[v] = "{" + v + "}"
- }
- } else {
- created := app.formatDatetime(invite.Created)
- var tplAddress string
- if app.config.Section("email").Key("no_username").MustBool(false) {
- tplAddress = "n/a"
- } else {
- tplAddress = address
- }
- template["aUserWasCreated"] = emailer.lang.UserCreated.template("aUserWasCreated", tmpl{"code": template["code"].(string)})
- template["name"] = username
- template["address"] = tplAddress
- template["time"] = created
- template["notificationNotice"] = emailer.lang.UserCreated.get("notificationNotice")
- }
- return template
-}
-
-func (emailer *Emailer) constructCreated(code, username, address string, invite Invite, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: emailer.lang.UserCreated.get("title"),
- }
- template := emailer.createdValues(code, username, address, invite, app, noSub)
- var err error
- message := app.storage.MustGetCustomContentKey("UserCreated")
- if message.Enabled {
- content := templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "notifications", "created_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bool) map[string]interface{} {
- d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern)
- message := app.config.Section("messages").Key("message").String()
- template := map[string]interface{}{
+ d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true)
+ linkResetEnabled := emailer.config.Section("password_resets").Key("link_reset").MustBool(false)
+ contentInfo, template := emailer.baseValues("PasswordReset", pwr.Username, placeholders, map[string]any{
+ "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": pwr.Username}),
"someoneHasRequestedReset": emailer.lang.PasswordReset.get("someoneHasRequestedReset"),
+ "ifItWasYou": emailer.lang.PasswordReset.get("ifItWasYou"),
"ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"),
"pinString": emailer.lang.PasswordReset.get("pin"),
- "link_reset": false,
- "message": "",
- "username": pwr.Username,
+ "codeExpiry": emailer.lang.PasswordReset.get("codeExpiry"),
+ "link_reset": linkResetEnabled && !placeholders,
"date": d,
"time": t,
"expiresInMinutes": expiresIn,
- }
- linkResetEnabled := app.config.Section("password_resets").Key("link_reset").MustBool(false)
+ "pin": pwr.Pin,
+ })
if linkResetEnabled {
template["ifItWasYou"] = emailer.lang.PasswordReset.get("ifItWasYouLink")
- } else {
- template["ifItWasYou"] = emailer.lang.PasswordReset.get("ifItWasYou")
}
- if noSub {
- template["helloUser"] = emailer.lang.Strings.get("helloUser")
- template["codeExpiry"] = emailer.lang.PasswordReset.get("codeExpiry")
- empty := []string{"pin"}
- for _, v := range empty {
- template[v] = "{" + v + "}"
- }
- } else {
- template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": pwr.Username})
- template["codeExpiry"] = emailer.lang.PasswordReset.template("codeExpiry", tmpl{"date": d, "time": t, "expiresInMinutes": expiresIn})
+ if !placeholders {
+ template["codeExpiry"] = emailer.lang.PasswordReset.template("codeExpiry", template)
if linkResetEnabled {
- pinLink, err := app.GenResetLink(pwr.Pin)
- if err == nil {
- // Strip /invite form end of this URL, ik its ugly.
- template["link_reset"] = true
+ pinLink, err := GenResetLink(pwr.Pin)
+ if err != nil {
+ template["link_reset"] = false
+ emailer.info.Printf(lm.FailedGeneratePWRLink, err)
+ } else {
template["pin"] = pinLink
// Only used in html email.
template["pin_code"] = pwr.Pin
- } else {
- app.info.Println(lm.FailedGeneratePWRLink, err)
- template["pin"] = pwr.Pin
}
- } else {
- template["pin"] = pwr.Pin
}
- template["message"] = message
}
- return template
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ return emailer.construct(contentInfo, cc, template)
}
-func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: app.config.Section("password_resets").Key("subject").MustString(emailer.lang.PasswordReset.get("title")),
+func (emailer *Emailer) constructDeleted(username, reason string, placeholders bool) (*Message, error) {
+ if placeholders {
+ username = "{username}"
+ reason = "{reason}"
}
- template := emailer.resetValues(pwr, app, noSub)
- var err error
- message := app.storage.MustGetCustomContentKey("PasswordReset")
- if message.Enabled {
- content := templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "password_resets", "email_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-func (emailer *Emailer) deletedValues(reason string, app *appContext, noSub bool) map[string]interface{} {
- template := map[string]interface{}{
+ contentInfo, template := emailer.baseValues("UserDeleted", username, placeholders, map[string]any{
+ "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
"yourAccountWas": emailer.lang.UserDeleted.get("yourAccountWasDeleted"),
"reasonString": emailer.lang.Strings.get("reason"),
- "message": "",
- }
- if noSub {
- empty := []string{"reason"}
- for _, v := range empty {
- template[v] = "{" + v + "}"
- }
- } else {
- template["reason"] = reason
- template["message"] = app.config.Section("messages").Key("message").String()
- }
- return template
+ "reason": reason,
+ })
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ return emailer.construct(contentInfo, cc, template)
}
-func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: app.config.Section("deletion").Key("subject").MustString(emailer.lang.UserDeleted.get("title")),
+func (emailer *Emailer) constructDisabled(username, reason string, placeholders bool) (*Message, error) {
+ if placeholders {
+ username = "{username}"
+ reason = "{reason}"
}
- var err error
- template := emailer.deletedValues(reason, app, noSub)
- message := app.storage.MustGetCustomContentKey("UserDeleted")
- if message.Enabled {
- content := templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "deletion", "email_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-func (emailer *Emailer) disabledValues(reason string, app *appContext, noSub bool) map[string]interface{} {
- template := map[string]interface{}{
+ contentInfo, template := emailer.baseValues("UserDisabled", username, placeholders, map[string]any{
+ "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
"yourAccountWas": emailer.lang.UserDisabled.get("yourAccountWasDisabled"),
"reasonString": emailer.lang.Strings.get("reason"),
- "message": "",
- }
- if noSub {
- empty := []string{"reason"}
- for _, v := range empty {
- template[v] = "{" + v + "}"
- }
- } else {
- template["reason"] = reason
- template["message"] = app.config.Section("messages").Key("message").String()
- }
- return template
+ "reason": reason,
+ })
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ return emailer.construct(contentInfo, cc, template)
}
-func (emailer *Emailer) constructDisabled(reason string, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: app.config.Section("disable_enable").Key("subject_disabled").MustString(emailer.lang.UserDisabled.get("title")),
+func (emailer *Emailer) constructEnabled(username, reason string, placeholders bool) (*Message, error) {
+ if placeholders {
+ username = "{username}"
+ reason = "{reason}"
}
- var err error
- template := emailer.disabledValues(reason, app, noSub)
- message := app.storage.MustGetCustomContentKey("UserDisabled")
- if message.Enabled {
- content := templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "disable_enable", "disabled_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-func (emailer *Emailer) enabledValues(reason string, app *appContext, noSub bool) map[string]interface{} {
- template := map[string]interface{}{
+ contentInfo, template := emailer.baseValues("UserEnabled", username, placeholders, map[string]any{
+ "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
"yourAccountWas": emailer.lang.UserEnabled.get("yourAccountWasEnabled"),
"reasonString": emailer.lang.Strings.get("reason"),
- "message": "",
- }
- if noSub {
- empty := []string{"reason"}
- for _, v := range empty {
- template[v] = "{" + v + "}"
- }
- } else {
- template["reason"] = reason
- template["message"] = app.config.Section("messages").Key("message").String()
- }
- return template
+ "reason": reason,
+ })
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ return emailer.construct(contentInfo, cc, template)
}
-func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: app.config.Section("disable_enable").Key("subject_enabled").MustString(emailer.lang.UserEnabled.get("title")),
+func (emailer *Emailer) constructExpiryAdjusted(username string, expiry time.Time, reason string, placeholders bool) (*Message, error) {
+ if placeholders {
+ username = "{username}"
}
- var err error
- template := emailer.enabledValues(reason, app, noSub)
- message := app.storage.MustGetCustomContentKey("UserEnabled")
- if message.Enabled {
- content := templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "disable_enable", "enabled_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-func (emailer *Emailer) expiryAdjustedValues(username string, expiry time.Time, reason string, app *appContext, noSub bool, custom bool) map[string]interface{} {
- template := map[string]interface{}{
+ exp := formatDatetime(expiry)
+ contentInfo, template := emailer.baseValues("UserExpiryAdjusted", username, placeholders, map[string]any{
+ "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
"yourExpiryWasAdjusted": emailer.lang.UserExpiryAdjusted.get("yourExpiryWasAdjusted"),
"ifPreviouslyDisabled": emailer.lang.UserExpiryAdjusted.get("ifPreviouslyDisabled"),
"reasonString": emailer.lang.Strings.get("reason"),
- "newExpiry": "",
- "message": "",
- }
- if noSub {
- template["helloUser"] = emailer.lang.Strings.get("helloUser")
- empty := []string{"reason", "newExpiry"}
- for _, v := range empty {
- template[v] = "{" + v + "}"
- }
- } else {
- template["reason"] = reason
- template["message"] = app.config.Section("messages").Key("message").String()
- template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username})
- exp := app.formatDatetime(expiry)
- if !expiry.IsZero() {
- if custom {
- template["newExpiry"] = exp
- } else if !expiry.IsZero() {
- template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
- "date": exp,
- })
- }
+ "reason": reason,
+ "newExpiry": exp,
+ })
+ cc := emailer.storage.MustGetCustomContentKey("UserExpiryAdjusted")
+ if !placeholders {
+ if !cc.Enabled {
+ template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
+ "date": exp,
+ })
}
}
- return template
+ return emailer.construct(contentInfo, cc, template)
}
-func (emailer *Emailer) constructExpiryAdjusted(username string, expiry time.Time, reason string, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: app.config.Section("user_expiry").Key("adjustment_subject").MustString(emailer.lang.UserExpiryAdjusted.get("title")),
+func (emailer *Emailer) constructExpiryReminder(username string, expiry time.Time, placeholders bool) (*Message, error) {
+ if placeholders {
+ username = "{username}"
}
- var err error
- var template map[string]interface{}
- message := app.storage.MustGetCustomContentKey("UserExpiryAdjusted")
- if message.Enabled {
- template = emailer.expiryAdjustedValues(username, expiry, reason, app, noSub, true)
- } else {
- template = emailer.expiryAdjustedValues(username, expiry, reason, app, noSub, false)
- }
- if noSub {
- template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
- "date": "{newExpiry}",
- })
- }
- if message.Enabled {
- content := templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "user_expiry", "adjustment_email_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-func (emailer *Emailer) welcomeValues(username string, expiry time.Time, app *appContext, noSub bool, custom bool) map[string]interface{} {
- template := map[string]interface{}{
- "welcome": emailer.lang.WelcomeEmail.get("welcome"),
- "youCanLoginWith": emailer.lang.WelcomeEmail.get("youCanLoginWith"),
- "jellyfinURLString": emailer.lang.WelcomeEmail.get("jellyfinURL"),
- "usernameString": emailer.lang.Strings.get("username"),
- "message": "",
- "yourAccountWillExpire": "",
- }
- if noSub {
- empty := []string{"jellyfinURL", "username", "yourAccountWillExpire"}
- for _, v := range empty {
- template[v] = "{" + v + "}"
- }
- } else {
- template["jellyfinURL"] = app.config.Section("jellyfin").Key("public_server").String()
- template["username"] = username
- template["message"] = app.config.Section("messages").Key("message").String()
- exp := app.formatDatetime(expiry)
- if !expiry.IsZero() {
- if custom {
- template["yourAccountWillExpire"] = exp
- } else if !expiry.IsZero() {
- template["yourAccountWillExpire"] = emailer.lang.WelcomeEmail.template("yourAccountWillExpire", tmpl{
- "date": exp,
- })
- }
+ d, t, expiresIn := emailer.formatExpiry(expiry, false)
+ contentInfo, template := emailer.baseValues("ExpiryReminder", username, placeholders, map[string]any{
+ "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
+ "yourAccountIsDueToExpire": emailer.lang.ExpiryReminder.get("yourAccountIsDueToExpire"),
+ "expiresIn": expiresIn,
+ "date": d,
+ "time": t,
+ })
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ if !placeholders {
+ if !cc.Enabled && !expiry.IsZero() {
+ template["yourAccountIsDueToExpire"] = emailer.lang.ExpiryReminder.template("yourAccountIsDueToExpire", template)
}
}
- return template
+ return emailer.construct(contentInfo, cc, template)
}
-func (emailer *Emailer) constructWelcome(username string, expiry time.Time, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: app.config.Section("welcome_email").Key("subject").MustString(emailer.lang.WelcomeEmail.get("title")),
+func (emailer *Emailer) constructWelcome(username string, expiry time.Time, placeholders bool) (*Message, error) {
+ var exp any = formatDatetime(expiry)
+ if placeholders {
+ username = "{username}"
+ exp = "{yourAccountWillExpire}"
}
- var err error
- var template map[string]interface{}
- message := app.storage.MustGetCustomContentKey("WelcomeEmail")
- if message.Enabled {
- template = emailer.welcomeValues(username, expiry, app, noSub, true)
- } else {
- template = emailer.welcomeValues(username, expiry, app, noSub, false)
- }
- if noSub {
+ contentInfo, template := emailer.baseValues("WelcomeEmail", username, placeholders, map[string]any{
+ "welcome": emailer.lang.WelcomeEmail.get("welcome"),
+ "youCanLoginWith": emailer.lang.WelcomeEmail.get("youCanLoginWith"),
+ "jellyfinURLString": emailer.lang.WelcomeEmail.get("jellyfinURL"),
+ "jellyfinURL": emailer.config.Section("jellyfin").Key("public_server").String(),
+ "usernameString": emailer.lang.Strings.get("username"),
+ })
+ if !expiry.IsZero() || placeholders {
template["yourAccountWillExpire"] = emailer.lang.WelcomeEmail.template("yourAccountWillExpire", tmpl{
- "date": "{yourAccountWillExpire}",
+ "date": exp,
})
}
- if message.Enabled {
- content := templateEmail(
- message.Content,
- message.Variables,
- message.Conditionals,
- template,
- )
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "welcome_email", "email_", template)
+ cc := emailer.storage.MustGetCustomContentKey("WelcomeEmail")
+ if !placeholders {
+ if cc.Enabled && !expiry.IsZero() {
+ template["yourAccountWillExpire"] = exp
+ }
}
- if err != nil {
- return nil, err
- }
- return email, nil
+ return emailer.construct(contentInfo, cc, template)
}
-func (emailer *Emailer) userExpiredValues(app *appContext, noSub bool) map[string]interface{} {
- template := map[string]interface{}{
+func (emailer *Emailer) constructUserExpired(username string, placeholders bool) (*Message, error) {
+ contentInfo, template := emailer.baseValues("UserExpired", username, placeholders, map[string]any{
"yourAccountHasExpired": emailer.lang.UserExpired.get("yourAccountHasExpired"),
"contactTheAdmin": emailer.lang.UserExpired.get("contactTheAdmin"),
- "message": "",
- }
- if !noSub {
- template["message"] = app.config.Section("messages").Key("message").String()
- }
- return template
-}
-
-func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: app.config.Section("user_expiry").Key("subject").MustString(emailer.lang.UserExpired.get("title")),
- }
- var err error
- template := emailer.userExpiredValues(app, noSub)
- message := app.storage.MustGetCustomContentKey("UserExpired")
- if message.Enabled {
- content := templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "user_expiry", "email_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
+ })
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ return emailer.construct(contentInfo, cc, template)
}
// calls the send method in the underlying emailClient.
diff --git a/email_test.go b/email_test.go
new file mode 100644
index 0000000..6729d31
--- /dev/null
+++ b/email_test.go
@@ -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)
+ }
+ }
+ })
+ })
+}
diff --git a/external.go b/external.go
index b493317..3036a89 100644
--- a/external.go
+++ b/external.go
@@ -4,20 +4,17 @@
package main
import (
- "io/fs"
- "log"
"os"
"path/filepath"
"strings"
+
+ "github.com/hrfee/jfa-go/logger"
)
const binaryType = "external"
func BuildTagsExternal() { buildTags = append(buildTags, "external") }
-var localFS dirFS
-var langFS dirFS
-
// When using os.DirFS, even on Windows the separator seems to be '/'.
// func FSJoin(elem ...string) string { return filepath.Join(elem...) }
func FSJoin(elem ...string) string {
@@ -32,23 +29,12 @@ func FSJoin(elem ...string) string {
return strings.TrimSuffix(path, sep)
}
-type dirFS string
-
-func (dir dirFS) Open(name string) (fs.File, error) {
- return os.Open(string(dir) + "/" + name)
-}
-
-func (dir dirFS) ReadFile(name string) ([]byte, error) {
- return os.ReadFile(string(dir) + "/" + name)
-}
-
-func (dir dirFS) ReadDir(name string) ([]fs.DirEntry, error) {
- return os.ReadDir(string(dir) + "/" + name)
-}
-
-func loadFilesystems() {
- log.Println("Using external storage")
- executable, _ := os.Executable()
- localFS = dirFS(filepath.Join(filepath.Dir(executable), "data"))
- langFS = dirFS(filepath.Join(filepath.Dir(executable), "data", "lang"))
+func loadFilesystems(rootDir string, logger *logger.Logger) {
+ logger.Println("Using external storage")
+ if rootDir == "" {
+ executable, _ := os.Executable()
+ rootDir = filepath.Dir(executable)
+ }
+ localFS = dirFS(filepath.Join(rootDir, "data"))
+ langFS = dirFS(filepath.Join(rootDir, "data", "lang"))
}
diff --git a/fs.go b/fs.go
new file mode 100644
index 0000000..02f3dff
--- /dev/null
+++ b/fs.go
@@ -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)
+}
diff --git a/generic-d.go b/generic-d.go
index 775ac0c..e10e9d1 100644
--- a/generic-d.go
+++ b/generic-d.go
@@ -11,6 +11,7 @@ import (
type GenericDaemon struct {
Stopped bool
ShutdownChannel chan string
+ TriggerChannel chan bool
Interval time.Duration
period time.Duration
jobs []func(app *appContext)
@@ -27,6 +28,7 @@ func NewGenericDaemon(interval time.Duration, app *appContext, jobs ...func(app
d := GenericDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
+ TriggerChannel: make(chan bool),
Interval: interval,
period: interval,
app: app,
@@ -46,6 +48,8 @@ func (d *GenericDaemon) run() {
case <-d.ShutdownChannel:
d.ShutdownChannel <- "Down"
return
+ case <-d.TriggerChannel:
+ break
case <-time.After(d.period):
break
}
@@ -61,6 +65,10 @@ func (d *GenericDaemon) run() {
}
}
+func (d *GenericDaemon) Trigger() {
+ d.TriggerChannel <- true
+}
+
func (d *GenericDaemon) Shutdown() {
d.Stopped = true
d.ShutdownChannel <- "Down"
diff --git a/go.mod b/go.mod
index 08cbe88..6f492a5 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/hrfee/jfa-go
-go 1.22.0
+go 1.24.0
replace github.com/hrfee/jfa-go/docs => ./docs
@@ -20,123 +20,124 @@ replace github.com/hrfee/jfa-go/easyproxy => ./easyproxy
replace github.com/hrfee/jfa-go/jellyseerr => ./jellyseerr
+// replace github.com/hrfee/mediabrowser => ../mediabrowser
+
require (
- github.com/bwmarrin/discordgo v0.28.1
- github.com/dgraph-io/badger/v4 v4.3.1
- github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a
+ github.com/bwmarrin/discordgo v0.29.0
+ github.com/dgraph-io/badger/v4 v4.8.0
+ github.com/emersion/go-autostart v0.0.0-20250403115856-34830d6457d2
github.com/fatih/color v1.18.0
- github.com/fsnotify/fsnotify v1.8.0
- github.com/getlantern/systray v1.2.2
- github.com/gin-contrib/pprof v1.5.0
- github.com/gin-contrib/static v1.1.2
- github.com/gin-gonic/gin v1.10.0
+ github.com/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-20241105142532-d03b89096d81
- github.com/hrfee/jfa-go/common v0.0.0-20241105225412-da4470bc4fbc
- github.com/hrfee/jfa-go/docs v0.0.0-20241105225412-da4470bc4fbc
- github.com/hrfee/jfa-go/easyproxy v0.0.0-20241105225412-da4470bc4fbc
- github.com/hrfee/jfa-go/jellyseerr v0.0.0-20241105225412-da4470bc4fbc
- github.com/hrfee/jfa-go/linecache v0.0.0-20241105225412-da4470bc4fbc
- github.com/hrfee/jfa-go/logger v0.0.0-20241105225412-da4470bc4fbc
- github.com/hrfee/jfa-go/logmessages v0.0.0-20241105225412-da4470bc4fbc
- github.com/hrfee/jfa-go/ombi v0.0.0-20241105225412-da4470bc4fbc
- github.com/hrfee/mediabrowser v0.3.24
- github.com/itchyny/timefmt-go v0.1.6
+ github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a
+ github.com/hrfee/jfa-go/common v0.0.0-20251123165523-7c9f91711460
+ github.com/hrfee/jfa-go/docs v0.0.0-20251123165523-7c9f91711460
+ github.com/hrfee/jfa-go/easyproxy v0.0.0-20251123165523-7c9f91711460
+ github.com/hrfee/jfa-go/jellyseerr v0.0.0-20251123165523-7c9f91711460
+ github.com/hrfee/jfa-go/linecache v0.0.0-20251123165523-7c9f91711460
+ github.com/hrfee/jfa-go/logger v0.0.0-20251123165523-7c9f91711460
+ github.com/hrfee/jfa-go/logmessages v0.0.0-20251123165523-7c9f91711460
+ github.com/hrfee/jfa-go/ombi v0.0.0-20251123165523-7c9f91711460
+ github.com/hrfee/mediabrowser v0.3.33
+ github.com/itchyny/timefmt-go v0.1.7
github.com/lithammer/shortuuid/v3 v3.0.7
- github.com/mailgun/mailgun-go/v4 v4.18.1
- github.com/mattn/go-sqlite3 v1.14.24
+ github.com/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.16.0
gopkg.in/ini.v1 v1.67.0
- gopkg.in/yaml.v3 v3.0.1
- maunium.net/go/mautrix v0.21.1
+ maunium.net/go/mautrix v0.26.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
- github.com/bytedance/sonic v1.12.4 // indirect
- github.com/bytedance/sonic/loader v0.2.1 // indirect
+ github.com/bytedance/gopkg v0.1.3 // indirect
+ github.com/bytedance/sonic v1.14.2 // indirect
+ github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/cloudwego/base64x v0.1.4 // indirect
- github.com/cloudwego/iasm v0.2.0 // indirect
- github.com/dgraph-io/ristretto v1.0.0 // indirect
+ github.com/cloudwego/base64x v0.1.6 // indirect
+ github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
- github.com/gabriel-vasile/mimetype v1.4.6 // indirect
- github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect
- github.com/getlantern/errors v1.0.4 // indirect
- github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 // indirect
- github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc // indirect
- github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 // indirect
- github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 // indirect
- github.com/gin-contrib/sse v0.1.0 // indirect
- github.com/go-chi/chi/v5 v5.1.0 // indirect
- github.com/go-logr/logr v1.4.2 // indirect
+ github.com/energye/systray v1.0.2 // 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.21.0 // indirect
- github.com/go-openapi/jsonreference v0.21.0 // indirect
- github.com/go-openapi/spec v0.21.0 // indirect
- github.com/go-openapi/swag v0.23.0 // indirect
+ github.com/go-openapi/jsonpointer v0.22.3 // indirect
+ github.com/go-openapi/jsonreference v0.21.3 // indirect
+ github.com/go-openapi/spec v0.22.1 // indirect
+ github.com/go-openapi/swag/conv v0.25.3 // indirect
+ github.com/go-openapi/swag/jsonname v0.25.3 // indirect
+ github.com/go-openapi/swag/jsonutils v0.25.3 // indirect
+ github.com/go-openapi/swag/loading v0.25.3 // indirect
+ github.com/go-openapi/swag/stringutils v0.25.3 // indirect
+ github.com/go-openapi/swag/typeutils v0.25.3 // indirect
+ github.com/go-openapi/swag/yamlutils v0.25.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
- github.com/go-playground/validator/v10 v10.22.1 // indirect
- github.com/go-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.3 // 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/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
- github.com/golang/protobuf v1.5.4 // indirect
- github.com/google/flatbuffers v24.3.25+incompatible // indirect
+ github.com/google/flatbuffers v25.9.23+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
- github.com/josharian/intern v1.0.0 // indirect
+ github.com/hrfee/simple-template v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
- github.com/klauspost/compress v1.17.11 // indirect
- github.com/klauspost/cpuid/v2 v2.2.8 // indirect
+ github.com/klauspost/compress v1.18.1 // indirect
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b // indirect
github.com/mailgun/errors v0.4.0 // indirect
- github.com/mailru/easyjson v0.7.7 // indirect
- github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
- github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
- github.com/pelletier/go-toml/v2 v2.2.3 // indirect
- github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274 // indirect
- github.com/pkg/errors v0.9.1 // indirect
- github.com/rs/zerolog v1.33.0 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+ github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
+ github.com/quic-go/qpack v0.6.0 // indirect
+ github.com/quic-go/quic-go v0.57.0 // indirect
+ github.com/rs/zerolog v1.34.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
- github.com/swaggo/swag v1.16.4 // indirect
+ github.com/swaggo/swag v1.16.6 // indirect
github.com/technoweenie/multipartstreamer v1.0.1 // indirect
+ github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c // indirect
github.com/tidwall/gjson v1.18.0 // indirect
- github.com/tidwall/match v1.1.1 // indirect
+ github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
- github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 // indirect
+ github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
- github.com/ugorji/go/codec v1.2.12 // indirect
- go.mau.fi/util v0.8.1 // indirect
- go.opencensus.io v0.24.0 // indirect
- go.opentelemetry.io/otel v1.31.0 // indirect
- go.opentelemetry.io/otel/metric v1.31.0 // indirect
- go.opentelemetry.io/otel/trace v1.31.0 // indirect
- go.uber.org/multierr v1.11.0 // indirect
- go.uber.org/zap v1.27.0 // indirect
- golang.org/x/arch v0.11.0 // indirect
- golang.org/x/crypto v0.28.0 // indirect
- golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
- golang.org/x/image v0.21.0 // indirect
- golang.org/x/net v0.30.0 // indirect
- golang.org/x/sys v0.26.0 // indirect
- golang.org/x/text v0.19.0 // indirect
- golang.org/x/tools v0.26.0 // indirect
- google.golang.org/protobuf v1.35.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.38.0 // indirect
+ golang.org/x/text v0.31.0 // indirect
+ golang.org/x/tools v0.39.0 // indirect
+ google.golang.org/protobuf v1.36.10 // indirect
)
diff --git a/go.sum b/go.sum
index 2dfb31f..6ad636c 100644
--- a/go.sum
+++ b/go.sum
@@ -11,14 +11,14 @@ github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbt
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/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
-github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4=
-github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
-github.com/bytedance/sonic v1.12.4 h1:9Csb3c9ZJhfUWeMtpCDCq6BUoH5ogfDFLUgQ/jG+R0k=
-github.com/bytedance/sonic v1.12.4/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
-github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
-github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=
-github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
+github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
+github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
+github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
+github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
+github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
+github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
+github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
+github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
@@ -26,10 +26,8 @@ github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
-github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
-github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
-github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
+github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
@@ -41,19 +39,21 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v4 v4.1.0/go.mod h1:P50u28d39ibBRmIJuQC/NSdBOg46HnHw7al2SW5QRHg=
-github.com/dgraph-io/badger/v4 v4.3.1 h1:7r5wKqmoRpGgSxqa0S/nGdpOpvvzuREGPLSua73C8tw=
-github.com/dgraph-io/badger/v4 v4.3.1/go.mod h1:oObz97DImXpd6O/Dt8BqdKLLTDmEmarAimo72VV5whQ=
+github.com/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs=
+github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w=
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
-github.com/dgraph-io/ristretto v1.0.0 h1:SYG07bONKMlFDUYu5pEu3DGAh8c2OFNzKm6G9J4Si84=
-github.com/dgraph-io/ristretto v1.0.0/go.mod h1:jTi2FiYEhQ1NsMmA7DeBykizjOuY88NhKBkepyu1jPc=
+github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
+github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
-github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
-github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
+github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
+github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a h1:M88ob4TyDnEqNuL3PgsE/p3bDujfspnulR+0dQWNYZs=
-github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a/go.mod h1:buzQsO8HHkZX2Q45fdfGH1xejPjuDQaXH8btcYMFzPM=
+github.com/emersion/go-autostart v0.0.0-20250403115856-34830d6457d2 h1:CgF8+TNFvlnxEbplSgS70ZI4IUFEzVkY+ICNqTVE/AM=
+github.com/emersion/go-autostart v0.0.0-20250403115856-34830d6457d2/go.mod h1:buzQsO8HHkZX2Q45fdfGH1xejPjuDQaXH8btcYMFzPM=
+github.com/energye/systray v1.0.2 h1:63R4prQkANtpM2CIA4UrDCuwZFt+FiygG77JYCsNmXc=
+github.com/energye/systray v1.0.2/go.mod h1:sp7Q/q/I4/w5ebvpSuJVep71s9Bg7L9ZVp69gBASehM=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -62,95 +62,91 @@ github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGE
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
-github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
-github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
-github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
-github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
-github.com/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.4 h1:i2iR1M9GKj4WuingpNqJ+XQEw6i6dnAgKAmLj6ZB3X0=
-github.com/getlantern/errors v1.0.4/go.mod h1:/Foq8jtSDGP8GOXzAjeslsC4Ar/3kB+UiQH+WyV4pzY=
-github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
-github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 h1:NlQedYmPI3pRAXJb+hLVVDGqfvvXGRPV8vp7XOjKAZ0=
-github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65/go.mod h1:+ZU1h+iOVqWReBpky6d5Y2WL0sF2Llxu+QcxJFs2+OU=
-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-20231025133620-f368ab734534 h1:3BwvWj0JZzFEvNNiMhCu4bf60nqcIuQpTYb00Ezm1ag=
-github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534/go.mod h1:ZsLfOY6gKQOTyEcPYNA9ws5/XHZQFroxqCOhHjGcs9Y=
-github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
-github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
+github.com/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/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
-github.com/gin-contrib/pprof v1.5.0 h1:E/Oy7g+kNw94KfdCy3bZxQFtyDnAX2V7axRS7sNYVrU=
-github.com/gin-contrib/pprof v1.5.0/go.mod h1:GqFL6LerKoCQ/RSWnkYczkTJ+tOAUVN/8sbnEtaqOKs=
+github.com/gin-contrib/pprof v1.5.3 h1:Bj5SxJ3kQDVez/s/+f9+meedJIqLS+xlkIVDe/lcvgM=
+github.com/gin-contrib/pprof v1.5.3/go.mod h1:0+LQSZ4SLO0B6+2n6JBzaEygpTBxe/nI+YEYpfQQ6xY=
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
-github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
-github.com/gin-contrib/static v1.1.2 h1:c3kT4bFkUJn2aoRU3s6XnMjJT8J6nNWJkR0NglqmlZ4=
-github.com/gin-contrib/static v1.1.2/go.mod h1:Fw90ozjHCmZBWbgrsqrDvO28YbhKEKzKp8GixhR4yLw=
+github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
+github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y=
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
-github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
-github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
-github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
-github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
+github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
+github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
+github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
+github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
-github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
-github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
-github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M=
github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
-github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
-github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
+github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
+github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I=
github.com/go-openapi/jsonreference v0.19.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I=
github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc=
github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8=
-github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
-github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
+github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
+github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
github.com/go-openapi/spec v0.19.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI=
github.com/go-openapi/spec v0.19.4/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo=
-github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
-github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
+github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
+github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg=
github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
-github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
-github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
+github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
+github.com/go-openapi/swag/conv v0.25.3 h1:PcB18wwfba7MN5BVlBIV+VxvUUeC2kEuCEyJ2/t2X7E=
+github.com/go-openapi/swag/conv v0.25.3/go.mod h1:n4Ibfwhn8NJnPXNRhBO5Cqb9ez7alBR40JS4rbASUPU=
+github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A=
+github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
+github.com/go-openapi/swag/jsonutils v0.25.3 h1:kV7wer79KXUM4Ea4tBdAVTU842Rg6tWstX3QbM4fGdw=
+github.com/go-openapi/swag/jsonutils v0.25.3/go.mod h1:ILcKqe4HC1VEZmJx51cVuZQ6MF8QvdfXsQfiaCs0z9o=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3 h1:/i3E9hBujtXfHy91rjtwJ7Fgv5TuDHgnSrYjhFxwxOw=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3/go.mod h1:8kYfCR2rHyOj25HVvxL5Nm8wkfzggddgjZm6RgjT8Ao=
+github.com/go-openapi/swag/loading v0.25.3 h1:Nn65Zlzf4854MY6Ft0JdNrtnHh2bdcS/tXckpSnOb2Y=
+github.com/go-openapi/swag/loading v0.25.3/go.mod h1:xajJ5P4Ang+cwM5gKFrHBgkEDWfLcsAKepIuzTmOb/c=
+github.com/go-openapi/swag/stringutils v0.25.3 h1:nAmWq1fUTWl/XiaEPwALjp/8BPZJun70iDHRNq/sH6w=
+github.com/go-openapi/swag/stringutils v0.25.3/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
+github.com/go-openapi/swag/typeutils v0.25.3 h1:2w4mEEo7DQt3V4veWMZw0yTPQibiL3ri2fdDV4t2TQc=
+github.com/go-openapi/swag/typeutils v0.25.3/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
+github.com/go-openapi/swag/yamlutils v0.25.3 h1:LKTJjCn/W1ZfMec0XDL4Vxh8kyAnv1orH5F2OREDUrg=
+github.com/go-openapi/swag/yamlutils v0.25.3/go.mod h1:Y7QN6Wc5DOBXK14/xeo1cQlq0EA0wvLoSv13gDQoCao=
+github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
+github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
+github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
+github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
-github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
-github.com/go-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.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
-github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
+github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/godbus/dbus/v5 v5.0.4 h1:9349emZab16e7zQvpmsbtjc18ykshndd8y2PG3sgJbA=
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=
@@ -160,7 +156,6 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU
github.com/golang/glog v1.1.1/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -175,16 +170,14 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
-github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81 h1:5lyLWsV+qCkoYqsKUDuycESh9DEIPVKN6iCFeL7ag50=
-github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
+github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
+github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v23.5.9+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
-github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
-github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
+github.com/google/flatbuffers v25.9.23+incompatible h1:rGZKv+wOb6QPzIdkM2KxhBZCDrA0DeN6DNmRDrqIsQU=
+github.com/google/flatbuffers v25.9.23+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -193,9 +186,8 @@ 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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -205,13 +197,15 @@ github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
-github.com/hrfee/mediabrowser v0.3.24 h1:cT5+X3bZeaSBQFevMYkFIw6JJ8nW7Myvb+11a2/THMA=
-github.com/hrfee/mediabrowser v0.3.24/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
+github.com/hrfee/mediabrowser v0.3.33 h1:kjUFZc46hNhbOEU4xZNyhGVNjfZ5lENmX95Md1thxiA=
+github.com/hrfee/mediabrowser v0.3.33/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
+github.com/hrfee/simple-template v1.0.0 h1:Yyq36RDZF9fT5SFpgFwgzk6ywQy2fdYa/Tl+jkcnF7c=
+github.com/hrfee/simple-template v1.0.0/go.mod h1:s9a5QgfqbmT7j9WCC3GD5JuEqvihBEohyr+oYZmr4bA=
+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.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
-github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
-github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
-github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
+github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -220,12 +214,10 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
-github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
-github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
-github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
-github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
-github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
-github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
+github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
+github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -238,23 +230,24 @@ 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/lutischan-ferenc/systray v1.3.0 h1:O1jZ/nAXpUbGg0+vO1xKvGP6FE26f5Xh6PYrN/cLqbs=
+github.com/lutischan-ferenc/systray v1.3.0/go.mod h1:YYaJ28AVuhMrlI5JfqrMsYMIl3Aa4Q02bpXXCl9caqo=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b h1:xZ59n7Frzh8CwyfAapUZLSg+gXH5m63YEaFCMpDHhpI=
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b/go.mod h1:uDd4sYVYsqcxAB8j+Q7uhL6IJCs/r1kxib1HV4bgOMg=
github.com/mailgun/errors v0.4.0 h1:6LFBvod6VIW83CMIOT9sYNp28TCX0NejFPP4dSX++i8=
github.com/mailgun/errors v0.4.0/go.mod h1:xGBaaKdEdQT0/FhwvoXv4oBaqqmVZz9P1XEnvD/onc0=
-github.com/mailgun/mailgun-go/v4 v4.18.1 h1:ShNH/wzj7albTF/6le011FF+DGMd3azcSKL4iO9AgeI=
-github.com/mailgun/mailgun-go/v4 v4.18.1/go.mod h1:+d4FCswFAukgYc1XtKK2IxOYaVxjVm8AN2z/5TBiT8M=
+github.com/mailgun/mailgun-go/v4 v4.23.0 h1:jPEMJzzin2s7lvehcfv/0UkyBu18GvcURPr2+xtZRbk=
+github.com/mailgun/mailgun-go/v4 v4.23.0/go.mod h1:imTtizoFtpfZqPqGP8vltVBB6q9yWcv6llBhfFeElZU=
github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
-github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
-github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
@@ -263,8 +256,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
-github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
+github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -273,33 +266,33 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
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/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
-github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
-github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
-github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
-github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274 h1:qli3BGQK0tYDkSEvZ/FzZTi9ZrOX86Q6CIhKLGc489A=
-github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a h1:VweslR2akb/ARhXfqSfRbj1vpWwYXf3eeAUyw/ndms0=
+github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
+github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
+github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
+github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/robert-nix/ansihtml v1.0.1 h1:VTiyQ6/+AxSJoSSLsMecnkh8i0ZqOEdiRl/odOc64fc=
github.com/robert-nix/ansihtml v1.0.1/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yDXcNeHHKZINE=
-github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
-github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
-github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
-github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
-github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
+github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
+github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
-github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/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=
@@ -312,6 +305,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -320,25 +314,30 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.2.0/go.mod h1:qlH2+W7zXGZkczuL+r2nEBR2JTT+/lX05Nn6vPhc7OI=
-github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
-github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
+github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
+github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.5.1/go.mod h1:1Bl9F/ZBpVWh22nY0zmYyASPO1lI/zIwRDrpZU+tv8Y=
github.com/swaggo/swag v1.6.7/go.mod h1:xDhTyuFIujYiN3DKWC/H/83xcfHp+UE/IzWWampG7Zc=
-github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
-github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
+github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
+github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
+github.com/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.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
-github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
+github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
@@ -347,8 +346,8 @@ github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6
github.com/timshannon/badgerhold/v4 v4.0.3 h1:W6pd2qckoXw2cl8eH0ZCV/9CXNaXvaM26tzFi5Tj+v8=
github.com/timshannon/badgerhold/v4 v4.0.3/go.mod h1:IkZIr0kcZLMdD7YJfW/G6epb6ZXHD/h0XR2BTk/VZg8=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
-github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 h1:flbMkdl6HxQkLs6DDhH1UkcnFpNBOu70391STjMS0O4=
-github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
+github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA=
+github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
@@ -356,8 +355,8 @@ github.com/ugorji/go v1.1.5-pre/go.mod h1:FwP/aQVg39TXzItUBMwnWp9T9gPQnXw4Poh4/o
github.com/ugorji/go/codec v0.0.0-20181022190402-e5e69e061d4f/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v1.1.5-pre/go.mod h1:tULtS6Gy1AE1yCENaw4Vb//HLH5njI2tfCQDUqRd8fI=
-github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
-github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
+github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
@@ -367,33 +366,25 @@ github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4te
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.8.1 h1:Ga43cz6esQBYqcjZ/onRoVnYWoUwjWbsxVeJg2jOTSo=
-go.mau.fi/util v0.8.1/go.mod h1:T1u/rD2rzidVrBLyaUdPpZiJdP/rsyi+aTzn0D+Q6wc=
+go.mau.fi/util v0.9.3 h1:aqNF8KDIN8bFpFbybSk+mEBil7IHeBwlujfyTnvP0uU=
+go.mau.fi/util v0.9.3/go.mod h1:krWWfBM1jWTb5f8NCa2TLqWMQuM81X7TGQjhMjBeXmQ=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
-go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
-go.opentelemetry.io/otel v1.9.0/go.mod h1:np4EoPGzoPs3O67xUVNoPPcmSvsfOxNlNA4F4AC+0Eo=
-go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
-go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
-go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
-go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
-go.opentelemetry.io/otel/trace v1.9.0/go.mod h1:2737Q0MuG8q1uILYm2YYVkAyLtOofiTNGg6VODnOiPo=
-go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
-go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
-go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
-go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
-go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
-go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
-go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
-go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
-go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
-go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
-go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
-go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
-golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
-golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
+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=
@@ -401,25 +392,23 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
-golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
+golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
+golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
-golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
+golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
+golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
-golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
-golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
+golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
+golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-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.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
-golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
+golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
+golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -436,13 +425,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-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
-golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
+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=
@@ -451,11 +439,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.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
-golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
+golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -467,24 +454,21 @@ golang.org/x/sys v0.0.0-20190610200419-93c9922d18ae/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
-golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -496,8 +480,10 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
-golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
+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=
@@ -509,11 +495,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.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
-golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
+golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
+golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -542,9 +527,8 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
-google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
-google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
-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=
@@ -556,13 +540,10 @@ 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.v3 v3.0.0-20200313102051-9f266ea9e77c/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/mautrix v0.21.1 h1:Z+e448jtlY977iC1kokNJTH5kg2WmDpcQCqn+v9oZOA=
-maunium.net/go/mautrix v0.21.1/go.mod h1:7F/S6XAdyc/6DW+Q7xyFXRSPb6IjfqMb1OMepQ8C8OE=
-nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+maunium.net/go/mautrix v0.26.0 h1:valc2VmZF+oIY4bMq4Cd5H9cEKMRe8eP4FM7iiaYLxI=
+maunium.net/go/mautrix v0.26.0/go.mod h1:NWMv+243NX/gDrLofJ2nNXJPrG8vzoM+WUCWph85S6Q=
diff --git a/housekeeping-d.go b/housekeeping-d.go
index be0d2da..1e4c880 100644
--- a/housekeeping-d.go
+++ b/housekeeping-d.go
@@ -31,6 +31,7 @@ func (app *appContext) clearEmails() {
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
@@ -40,7 +41,7 @@ func (app *appContext) clearDiscord() {
app.discord.RemoveRole(discordUser.MethodID().(string))
app.storage.DeleteDiscordKey(discordUser.JellyfinID)
default:
- if user.Policy.IsDisabled {
+ if removeRoleOnDisable && user.Policy.IsDisabled {
app.discord.RemoveRole(discordUser.MethodID().(string))
}
continue
@@ -140,7 +141,7 @@ func newHousekeepingDaemon(interval time.Duration, app *appContext) *GenericDaem
clearPWR := app.config.Section("captcha").Key("enabled").MustBool(false) && !app.config.Section("captcha").Key("recaptcha").MustBool(false)
if clearEmail || clearDiscord || clearTelegram || clearMatrix {
- d.appendJobs(func(app *appContext) { app.jf.CacheExpiry = time.Now() })
+ d.appendJobs(func(app *appContext) { app.InvalidateJellyfinCache() })
}
if clearEmail {
diff --git a/html/404.html b/html/404.html
index 22b0cbb..429ecba 100644
--- a/html/404.html
+++ b/html/404.html
@@ -1,9 +1,8 @@
-
- {{ template "header.html" . }}
404 - jfa-go
+ {{ template "header.html" . }}
diff --git a/html/account-linking-discord.html b/html/account-linking-discord.html
new file mode 100644
index 0000000..4d25dd6
--- /dev/null
+++ b/html/account-linking-discord.html
@@ -0,0 +1,16 @@
+{{ if .discordEnabled }}
+
+
+
{{ .strings.linkDiscord }}
+
{{ .discordSendPINMessage }}
+
+
+
{{ .strings.success }}
+
+
+{{ end }}
diff --git a/html/account-linking-matrix.html b/html/account-linking-matrix.html
new file mode 100644
index 0000000..02865ff
--- /dev/null
+++ b/html/account-linking-matrix.html
@@ -0,0 +1,18 @@
+{{ if .matrixEnabled }}
+
+
+
{{ .strings.linkMatrix }}
+
{{ .strings.matrixEnterUser }}
+
+
+
+
+
+
+
+ {{ .matrixUser }}
+
+
{{ .strings.submit }}
+
+
+{{ end }}
diff --git a/html/account-linking-telegram.html b/html/account-linking-telegram.html
new file mode 100644
index 0000000..7c8fae7
--- /dev/null
+++ b/html/account-linking-telegram.html
@@ -0,0 +1,18 @@
+{{ if .telegramEnabled }}
+
+{{ end }}
diff --git a/html/account-linking.html b/html/account-linking.html
index e38f013..2ff6525 100644
--- a/html/account-linking.html
+++ b/html/account-linking.html
@@ -1,52 +1,3 @@
-{{ if .discordEnabled }}
-
-
-
{{ .strings.linkDiscord }}
-
{{ .discordSendPINMessage }}
-
-
-
{{ .strings.success }}
-
-
-{{ end }}
-{{ if .telegramEnabled }}
-
-{{ end }}
-{{ if .matrixEnabled }}
-
-
-
{{ .strings.linkMatrix }}
-
{{ .strings.matrixEnterUser }}
-
-
-
-
-
-
-
- {{ .matrixUser }}
-
-
{{ .strings.submit }}
-
-
-{{ end }}
+{{ template "account-linking-discord.html" . }}
+{{ template "account-linking-telegram.html" . }}
+{{ template "account-linking-matrix.html" . }}
diff --git a/html/admin.html b/html/admin.html
index ef75925..ec78dac 100644
--- a/html/admin.html
+++ b/html/admin.html
@@ -1,16 +1,8 @@
-
+ {{ template "syntaxhighlighting.html" . }}
Admin - jfa-go
@@ -47,7 +38,7 @@
-
+
×
{{ .strings.version }} {{ .version }}
{{ .strings.commitNoun }} {{ .commit }}
@@ -78,11 +69,18 @@
-
+
+
+
{{ .strings.tasks }}×
+
{{ .strings.tasksDescription }}
+
+
+