Merge branch 'main' of github.com:hrfee/jfa-go

This commit is contained in:
Harvey Tindall
2025-11-30 21:42:12 +00:00
145 changed files with 7118 additions and 3039 deletions
+18
View File
@@ -12,6 +12,24 @@ clone:
depth: 0 depth: 0
steps: 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 - name: build
image: docker.io/hrfee/jfa-go-build-docker:latest image: docker.io/hrfee/jfa-go-build-docker:latest
environment: environment:
+20
View File
@@ -11,11 +11,31 @@ clone:
depth: 0 depth: 0
steps: 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 - name: build
image: docker.io/hrfee/jfa-go-build-docker:latest image: docker.io/hrfee/jfa-go-build-docker:latest
environment: environment:
JFA_GO_BUILT_BY: JFA_GO_BUILT_BY:
from_secret: BUILT_BY from_secret: BUILT_BY
GITHUB_TOKEN:
from_secret: GITHUB_TOKEN
commands: commands:
- curl -sfL https://goreleaser.com/static/run > ../goreleaser - curl -sfL https://goreleaser.com/static/run > ../goreleaser
- chmod +x ../goreleaser - chmod +x ../goreleaser
+1 -1
View File
@@ -2,7 +2,7 @@
MIT License 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 Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+17 -5
View File
@@ -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 .DEFAULT_GOAL := all
GOESBUILD ?= off GOESBUILD ?= off
@@ -9,7 +9,7 @@ else
endif endif
GOBINARY ?= go GOBINARY ?= go
CSSVERSION ?= v3 CSSVERSION ?= $(shell git describe --tags --abbrev=0)
CSS_BUNDLE = $(DATA)/web/css/$(CSSVERSION)bundle.css CSS_BUNDLE = $(DATA)/web/css/$(CSSVERSION)bundle.css
VERSION ?= $(shell git describe --exact-match HEAD 2> /dev/null || echo vgit) VERSION ?= $(shell git describe --exact-match HEAD 2> /dev/null || echo vgit)
@@ -148,7 +148,7 @@ SWAGGER_SRC = $(wildcard api*.go) $(wildcard *auth.go) views.go
SWAGGER_TARGET = docs/docs.go SWAGGER_TARGET = docs/docs.go
$(SWAGGER_TARGET): $(SWAGGER_SRC) $(SWAGGER_TARGET): $(SWAGGER_SRC)
$(SWAGINSTALL) $(SWAGINSTALL)
swag init -g main.go swag init --parseDependency --parseInternal -g main.go
VARIANTS_SRC = $(wildcard html/*.html) VARIANTS_SRC = $(wildcard html/*.html)
VARIANTS_TARGET = $(DATA)/html/admin.html VARIANTS_TARGET = $(DATA)/html/admin.html
@@ -160,15 +160,24 @@ $(VARIANTS_TARGET): $(VARIANTS_SRC)
ICON_SRC = node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 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/%) 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_SRC = $(wildcard css/*.css)
CSS_TARGET = $(DATA)/web/css/part-bundle.css CSS_TARGET = $(DATA)/web/css/part-bundle.css
CSS_FULLTARGET = $(CSS_BUNDLE) 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) ALL_CSS_TARGET = $(ICON_TARGET)
$(CSS_FULLTARGET): $(TYPESCRIPT_TARGET) $(VARIANTS_TARGET) $(ALL_CSS_SRC) $(wildcard html/*.html) $(CSS_FULLTARGET): $(TYPESCRIPT_TARGET) $(VARIANTS_TARGET) $(ALL_CSS_SRC) $(wildcard html/*.html)
$(info copying fonts) $(info copying fonts)
cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 $(DATA)/web/css/ 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) $(info bundling css)
rm -f $(CSS_TARGET) $(CSS_FULLTARGET) rm -f $(CSS_TARGET) $(CSS_FULLTARGET)
$(ESBUILD) --bundle css/base.css --outfile=$(CSS_TARGET) --external:remixicon.css --external:../fonts/hanken* --minify $(ESBUILD) --bundle css/base.css --outfile=$(CSS_TARGET) --external:remixicon.css --external:../fonts/hanken* --minify
@@ -195,7 +204,7 @@ COPY_TARGET = $(DATA)/jfa-go.service
# $(DATA)/LICENSE $(LANG_TARGET) $(STATIC_TARGET) $(DATA)/web/css/$(CSSVERSION)bundle.css # $(DATA)/LICENSE $(LANG_TARGET) $(STATIC_TARGET) $(DATA)/web/css/$(CSSVERSION)bundle.css
$(COPY_TARGET): $(INLINE_TARGET) $(STATIC_SRC) $(LANG_SRC) $(CONFIG_BASE) $(COPY_TARGET): $(INLINE_TARGET) $(STATIC_SRC) $(LANG_SRC) $(CONFIG_BASE)
$(info copying $(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) $(info copying crash page)
cp $(DATA)/crash.html $(DATA)/html/ cp $(DATA)/crash.html $(DATA)/html/
$(info copying static data) $(info copying static data)
@@ -224,6 +233,9 @@ $(GO_TARGET): $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
mkdir -p build mkdir -p build
$(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o $(GO_TARGET) $(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o $(GO_TARGET)
test: $(BUILDDEPS) $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
$(GOBINARY) test -ldflags="$(LDFLAGS)" $(TAGS) -p 1
all: $(BUILDDEPS) $(GO_TARGET) all: $(BUILDDEPS) $(GO_TARGET)
compress: compress:
+1 -1
View File
@@ -13,7 +13,7 @@
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). 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? #### 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 compatability in the future, unless any big changes occur.
#### Alternatives #### 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 of a guarantee of support, I've seen these projects mentioned although haven't tried them myself.
+3 -3
View File
@@ -116,7 +116,7 @@ func (app *appContext) generateActivitiesQuery(req ServerFilterReqDTO) *badgerho
// @Success 200 {object} GetActivitiesRespDTO // @Success 200 {object} GetActivitiesRespDTO
// @Router /activity [post] // @Router /activity [post]
// @Security Bearer // @Security Bearer
// @tags Activity // @tags Activity,Statistics
func (app *appContext) GetActivities(gc *gin.Context) { func (app *appContext) GetActivities(gc *gin.Context) {
req := ServerSearchReqDTO{} req := ServerSearchReqDTO{}
gc.BindJSON(&req) gc.BindJSON(&req)
@@ -185,7 +185,7 @@ func (app *appContext) DeleteActivity(gc *gin.Context) {
// @Success 200 {object} PageCountDTO // @Success 200 {object} PageCountDTO
// @Router /activity/count [get] // @Router /activity/count [get]
// @Security Bearer // @Security Bearer
// @tags Activity // @tags Activity,Statistics
func (app *appContext) GetActivityCount(gc *gin.Context) { func (app *appContext) GetActivityCount(gc *gin.Context) {
resp := PageCountDTO{} resp := PageCountDTO{}
var err error var err error
@@ -202,7 +202,7 @@ func (app *appContext) GetActivityCount(gc *gin.Context) {
// @Success 200 {object} PageCountDTO // @Success 200 {object} PageCountDTO
// @Router /activity/count [post] // @Router /activity/count [post]
// @Security Bearer // @Security Bearer
// @tags Activity // @tags Activity,Statistics
func (app *appContext) GetFilteredActivityCount(gc *gin.Context) { func (app *appContext) GetFilteredActivityCount(gc *gin.Context) {
resp := PageCountDTO{} resp := PageCountDTO{}
req := ServerFilterReqDTO{} req := ServerFilterReqDTO{}
+9 -8
View File
@@ -104,6 +104,7 @@ func (app *appContext) deleteExpiredInvite(data Invite) {
if ok { if ok {
user.ReferralTemplateKey = "" user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(data.ReferrerJellyfinID, user) app.storage.SetEmailsKey(data.ReferrerJellyfinID, user)
app.InvalidateWebUserCache()
} }
} }
wait := app.sendAdminExpiryNotification(data) wait := app.sendAdminExpiryNotification(data)
@@ -124,7 +125,7 @@ func (app *appContext) deleteExpiredInvite(data Invite) {
func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup { func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup {
notify := data.Notify 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 return nil
} }
var wait sync.WaitGroup var wait sync.WaitGroup
@@ -135,7 +136,7 @@ func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup
wait.Add(1) wait.Add(1)
go func(addr string) { go func(addr string) {
defer wait.Done() defer wait.Done()
msg, err := app.email.constructExpiry(data.Code, data, app, false) msg, err := app.email.constructExpiry(data, false)
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructExpiryAdmin, data.Code, err) app.err.Printf(lm.FailedConstructExpiryAdmin, data.Code, err)
} else { } else {
@@ -218,7 +219,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
invite.SendTo = req.SendTo invite.SendTo = req.SendTo
} }
if addressValid { if addressValid {
msg, err := app.email.constructInvite(invite.Code, invite, app, false) msg, err := app.email.constructInvite(invite, false)
if err != nil { if err != nil {
// Slight misuse of the template // Slight misuse of the template
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, req.SendTo, err) invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, req.SendTo, err)
@@ -269,7 +270,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
// @Success 200 {object} PageCountDTO // @Success 200 {object} PageCountDTO
// @Router /invites/count [get] // @Router /invites/count [get]
// @Security Bearer // @Security Bearer
// @tags Invites // @tags Invites,Statistics
func (app *appContext) GetInviteCount(gc *gin.Context) { func (app *appContext) GetInviteCount(gc *gin.Context) {
resp := PageCountDTO{} resp := PageCountDTO{}
var err error var err error
@@ -283,9 +284,9 @@ func (app *appContext) GetInviteCount(gc *gin.Context) {
// @Summary Get the number of invites stored in the database that have been used (but are still valid). // @Summary Get the number of invites stored in the database that have been used (but are still valid).
// @Produce json // @Produce json
// @Success 200 {object} PageCountDTO // @Success 200 {object} PageCountDTO
// @Router /invites/count [get] // @Router /invites/count/used [get]
// @Security Bearer // @Security Bearer
// @tags Invites // @tags Invites,Statistics
func (app *appContext) GetInviteUsedCount(gc *gin.Context) { func (app *appContext) GetInviteUsedCount(gc *gin.Context) {
resp := PageCountDTO{} resp := PageCountDTO{}
var err error var err error
@@ -309,7 +310,7 @@ func (app *appContext) GetInviteUsedCount(gc *gin.Context) {
// @Success 200 {object} getInvitesDTO // @Success 200 {object} getInvitesDTO
// @Router /invites [get] // @Router /invites [get]
// @Security Bearer // @Security Bearer
// @tags Invites // @tags Invites,Statistics
func (app *appContext) GetInvites(gc *gin.Context) { func (app *appContext) GetInvites(gc *gin.Context) {
currentTime := time.Now() currentTime := time.Now()
app.checkInvites() app.checkInvites()
@@ -343,7 +344,7 @@ func (app *appContext) GetInvites(gc *gin.Context) {
// These used to be stored formatted instead of as a unix timestamp. // These used to be stored formatted instead of as a unix timestamp.
unix, err := strconv.ParseInt(pair[1], 10, 64) unix, err := strconv.ParseInt(pair[1], 10, 64)
if err != nil { if err != nil {
date, err := timefmt.Parse(pair[1], app.datePattern+" "+app.timePattern) date, err := timefmt.Parse(pair[1], datePattern+" "+timePattern)
if err != nil { if err != nil {
app.err.Printf(lm.FailedParseTime, err) app.err.Printf(lm.FailedParseTime, err)
} }
+47 -10
View File
@@ -6,6 +6,7 @@ import (
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/common"
"github.com/hrfee/jfa-go/jellyseerr" "github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages" lm "github.com/hrfee/jfa-go/logmessages"
) )
@@ -61,7 +62,7 @@ func (app *appContext) SetJellyseerrProfile(gc *gin.Context) {
} }
u, err := app.js.UserByID(jellyseerrID) u, err := app.js.UserByID(jellyseerrID)
if err != nil { if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyseerr, err) app.err.Printf(lm.FailedGetUser, jellyseerrID, lm.Jellyseerr, err)
respond(500, "Couldn't get user", gc) respond(500, "Couldn't get user", gc)
return return
} }
@@ -111,6 +112,9 @@ func (js *JellyseerrWrapper) ImportUser(jellyfinID string, req newUserDTO, profi
return return
} }
ok = true ok = true
if !profile.Jellyseerr.Enabled {
return
}
err = js.ApplyTemplateToUser(jellyfinID, profile.Jellyseerr.User) err = js.ApplyTemplateToUser(jellyfinID, profile.Jellyseerr.User)
if err != nil { if err != nil {
err = fmt.Errorf(lm.FailedApplyTemplate, "user", lm.Jellyseerr, jellyfinID, err) err = fmt.Errorf(lm.FailedApplyTemplate, "user", lm.Jellyseerr, jellyfinID, err)
@@ -124,29 +128,62 @@ func (js *JellyseerrWrapper) ImportUser(jellyfinID string, req newUserDTO, profi
return 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) _, err = js.MustGetUser(jellyfinID)
if err != nil { if err != nil {
return return
} }
if contactPrefs == nil {
contactPrefs = &common.ContactPreferences{
Email: nil,
Discord: nil,
Telegram: nil,
Matrix: nil,
}
}
contactMethods := map[jellyseerr.NotificationsField]any{} contactMethods := map[jellyseerr.NotificationsField]any{}
if emailEnabled { if emailEnabled {
err = js.ModifyMainUserSettings(jellyfinID, jellyseerr.MainUserSettings{Email: req.Email}) 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 { if err != nil {
// FIXME: This is a little ugly, considering all other errors are unformatted // FIXME: This is a little ugly, considering all other errors are unformatted
err = fmt.Errorf(lm.FailedSetEmailAddress, lm.Jellyseerr, jellyfinID, err) err = fmt.Errorf(lm.FailedSetEmailAddress, lm.Jellyseerr, jellyfinID, err)
return return
} else {
contactMethods[jellyseerr.FieldEmailEnabled] = req.EmailContact
} }
} }
if discordEnabled && discord != nil { }
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 contactMethods[jellyseerr.FieldDiscord] = discord.ID
contactMethods[jellyseerr.FieldDiscordEnabled] = req.DiscordContact // 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 telegramEnabled && discord != nil {
contactMethods[jellyseerr.FieldTelegram] = telegram.ChatID
contactMethods[jellyseerr.FieldTelegramEnabled] = req.TelegramContact
} }
if len(contactMethods) > 0 { if len(contactMethods) > 0 {
err = js.ModifyNotifications(jellyfinID, contactMethods) err = js.ModifyNotifications(jellyfinID, contactMethods)
+112 -158
View File
@@ -1,11 +1,10 @@
package main package main
import ( import (
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/jellyseerr" "github.com/hrfee/jfa-go/common"
lm "github.com/hrfee/jfa-go/logmessages" lm "github.com/hrfee/jfa-go/logmessages"
"github.com/lithammer/shortuuid/v3" "github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1" "gopkg.in/ini.v1"
@@ -23,25 +22,16 @@ func (app *appContext) GetCustomContent(gc *gin.Context) {
if _, ok := app.storage.lang.Email[lang]; !ok { if _, ok := app.storage.lang.Email[lang]; !ok {
lang = app.storage.lang.chosenEmailLang lang = app.storage.lang.chosenEmailLang
} }
adminLang := lang list := emailListDTO{}
if _, ok := app.storage.lang.Admin[lang]; !ok { for _, cc := range customContent {
adminLang = app.storage.lang.chosenAdminLang if cc.ContentType == CustomTemplate {
continue
} }
list := emailListDTO{ ccDescription := emailListEl{Name: cc.DisplayName(&app.storage.lang, lang), Enabled: app.storage.MustGetCustomContentKey(cc.Name).Enabled}
"UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.MustGetCustomContentKey("UserCreated").Enabled}, if cc.Description != nil {
"InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.MustGetCustomContentKey("InviteExpiry").Enabled}, ccDescription.Description = cc.Description(&app.storage.lang, lang)
"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}, list[cc.Name] = ccDescription
"UserDisabled": {Name: app.storage.lang.Email[lang].UserDisabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserDisabled").Enabled},
"UserEnabled": {Name: app.storage.lang.Email[lang].UserEnabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserEnabled").Enabled},
"UserExpiryAdjusted": {Name: app.storage.lang.Email[lang].UserExpiryAdjusted["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpiryAdjusted").Enabled},
"InviteEmail": {Name: app.storage.lang.Email[lang].InviteEmail["name"], Enabled: app.storage.MustGetCustomContentKey("InviteEmail").Enabled},
"WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.MustGetCustomContentKey("WelcomeEmail").Enabled},
"EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.MustGetCustomContentKey("EmailConfirmation").Enabled},
"UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpired").Enabled},
"UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.MustGetCustomContentKey("UserLogin").Enabled},
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("UserPage").Enabled},
"PostSignupCard": {Name: app.storage.lang.Admin[adminLang].Strings["postSignupCard"], Enabled: app.storage.MustGetCustomContentKey("PostSignupCard").Enabled, Description: app.storage.lang.Admin[adminLang].Strings["postSignupCardDescription"]},
} }
filter := gc.Query("filter") filter := gc.Query("filter")
@@ -73,11 +63,12 @@ func (app *appContext) SetCustomMessage(gc *gin.Context) {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
message, ok := app.storage.GetCustomContentKey(id) _, ok := customContent[id]
if !ok { if !ok {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
message, ok := app.storage.GetCustomContentKey(id)
message.Content = req.Content message.Content = req.Content
message.Enabled = true message.Enabled = true
app.storage.SetCustomContentKey(id, message) app.storage.SetCustomContentKey(id, message)
@@ -123,146 +114,91 @@ func (app *appContext) SetCustomMessageState(gc *gin.Context) {
// @Security Bearer // @Security Bearer
// @tags Configuration // @tags Configuration
func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) { func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
lang := app.storage.lang.chosenEmailLang
id := gc.Param("id") id := gc.Param("id")
var content string
var err error var err error
var msg *Message contentInfo, ok := customContent[id]
var variables []string // FIXME: Add announcement to customContent
var conditionals []string
var values map[string]interface{}
username := app.storage.lang.Email[lang].Strings.get("username")
emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress")
customMessage, ok := app.storage.GetCustomContentKey(id)
if !ok && id != "Announcement" { if !ok && id != "Announcement" {
app.err.Printf(lm.FailedGetCustomMessage, id) app.err.Printf(lm.FailedGetCustomMessage, id)
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
if id == "WelcomeEmail" {
conditionals = []string{"{yourAccountWillExpire}"} content, ok := app.storage.GetCustomContentKey(id)
customMessage.Conditionals = conditionals
} else if id == "UserPage" { if contentInfo.Variables == nil {
variables = []string{"{username}"} contentInfo.Variables = []string{}
customMessage.Variables = variables }
} else if id == "UserLogin" { if contentInfo.Conditionals == nil {
variables = []string{} contentInfo.Conditionals = []string{}
customMessage.Variables = variables }
} else if id == "PostSignupCard" { if contentInfo.Placeholders == nil {
variables = []string{"{username}", "{myAccountURL}"} contentInfo.Placeholders = map[string]any{}
customMessage.Variables = variables
} }
content = customMessage.Content // Generate content from real email, if the user hasn't already customised this message.
noContent := content == "" if content.Content == "" {
if !noContent { var msg *Message
variables = customMessage.Variables
}
switch id { switch id {
case "Announcement": // FIXME: Add announcement to customContent
// Just send the email html
content = ""
case "UserCreated": case "UserCreated":
if noContent { msg, err = app.email.constructCreated("", "", time.Time{}, Invite{}, true)
msg, err = app.email.constructCreated("", "", "", Invite{}, app, true)
}
values = app.email.createdValues("xxxxxx", username, emailAddress, Invite{}, app, false)
case "InviteExpiry": case "InviteExpiry":
if noContent { msg, err = app.email.constructExpiry(Invite{}, true)
msg, err = app.email.constructExpiry("", Invite{}, app, true)
}
values = app.email.expiryValues("xxxxxx", Invite{}, app, false)
case "PasswordReset": case "PasswordReset":
if noContent { msg, err = app.email.constructReset(PasswordReset{}, true)
msg, err = app.email.constructReset(PasswordReset{}, app, true)
}
values = app.email.resetValues(PasswordReset{Pin: "12-34-56", Username: username}, app, false)
case "UserDeleted": case "UserDeleted":
if noContent { msg, err = app.email.constructDeleted("", "", true)
msg, err = app.email.constructDeleted("", app, true)
}
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
case "UserDisabled": case "UserDisabled":
if noContent { msg, err = app.email.constructDisabled("", "", true)
msg, err = app.email.constructDisabled("", app, true)
}
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
case "UserEnabled": case "UserEnabled":
if noContent { msg, err = app.email.constructEnabled("", "", true)
msg, err = app.email.constructEnabled("", app, true)
}
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
case "UserExpiryAdjusted": case "UserExpiryAdjusted":
if noContent { msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", true)
msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", app, true) case "ExpiryReminder":
} msg, err = app.email.constructExpiryReminder("", time.Now().AddDate(0, 0, 3), true)
values = app.email.expiryAdjustedValues(username, time.Now(), app.storage.lang.Email[lang].Strings.get("reason"), app, false, true)
case "InviteEmail": case "InviteEmail":
if noContent { msg, err = app.email.constructInvite(Invite{Code: ""}, true)
msg, err = app.email.constructInvite("", Invite{}, app, true)
}
values = app.email.inviteValues("xxxxxx", Invite{}, app, false)
case "WelcomeEmail": case "WelcomeEmail":
if noContent { msg, err = app.email.constructWelcome("", time.Time{}, true)
msg, err = app.email.constructWelcome("", time.Time{}, app, true)
}
values = app.email.welcomeValues(username, time.Now(), app, false, true)
case "EmailConfirmation": case "EmailConfirmation":
if noContent { msg, err = app.email.constructConfirmation("", "", "", true)
msg, err = app.email.constructConfirmation("", "", "", app, true)
}
values = app.email.confirmationValues("xxxxxx", username, "xxxxxx", app, false)
case "UserExpired": case "UserExpired":
if noContent { msg, err = app.email.constructUserExpired("", true)
msg, err = app.email.constructUserExpired(app, true) case "Announcement":
} case "UserPage":
values = app.email.userExpiredValues(app, false) case "UserLogin":
case "UserLogin", "UserPage", "PostSignupCard": case "PostSignupCard":
values = map[string]interface{}{} // These don't have any example content
msg = nil
} }
if err != nil { if err != nil {
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" && id != "PostSignupCard" { if msg != nil {
content = msg.Text content.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
} var mail *Message = nil
if variables == nil { if contentInfo.ContentType == CustomMessage {
variables = []string{} mail, err = app.email.construct(EmptyCustomContent, CustomContent{
} Name: EmptyCustomContent.Name,
app.storage.SetCustomContentKey(id, customMessage) Enabled: true,
var mail *Message Content: "<div class=\"preview-content\"></div>",
if id != "UserLogin" && id != "UserPage" && id != "PostSignupCard" { }, map[string]any{})
mail, err = app.email.constructTemplate("", "<div class=\"preview-content\"></div>", app)
if err != nil { if err != nil {
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
} else if id == "PostSignupCard" { } else if id == "PostSignupCard" {
// Jankiness follows. // Specific workaround for the currently-unique "Post signup card".
// Source content from "Success Message" setting. // Source content from "Success Message" setting.
if noContent { if content.Content == "" {
content = "# " + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("successHeader") + "\n" + app.config.Section("ui").Key("success_message").String() 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) { if app.config.Section("user_page").Key("enabled").MustBool(false) {
content += "\n\n<br>\n" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.template("userPageSuccessMessage", tmpl{ content.Content += "\n\n<br>\n" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.template("userPageSuccessMessage", tmpl{
"myAccount": "[" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("myAccount") + "]({myAccountURL})", "myAccount": "[" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("myAccount") + "]({myAccountURL})",
}) })
} }
@@ -271,13 +207,15 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
HTML: "<div class=\"card ~neutral dark:~d_neutral @low\"><div class=\"preview-content\"></div><br><button class=\"button ~urge dark:~d_urge @low full-width center supra submit\">" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("continue") + "</a></div>", HTML: "<div class=\"card ~neutral dark:~d_neutral @low\"><div class=\"preview-content\"></div><br><button class=\"button ~urge dark:~d_urge @low full-width center supra submit\">" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("continue") + "</a></div>",
} }
mail.Markdown = mail.HTML mail.Markdown = mail.HTML
} else { } else if contentInfo.ContentType == CustomCard {
mail = &Message{ mail = &Message{
HTML: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>", HTML: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
} }
mail.Markdown = mail.HTML 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. // @Summary Returns a new Telegram verification PIN, and the bot username.
@@ -316,8 +254,10 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
return return
} }
tgUser := TelegramUser{ tgUser := TelegramUser{
TelegramVerifiedToken: TelegramVerifiedToken{
ChatID: tgToken.ChatID, ChatID: tgToken.ChatID,
Username: tgToken.Username, Username: tgToken.Username,
},
Contact: true, Contact: true,
} }
if lang, ok := app.telegram.languages[tgToken.ChatID]; ok { if lang, ok := app.telegram.languages[tgToken.ChatID]; ok {
@@ -325,20 +265,21 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
} }
app.storage.SetTelegramKey(req.ID, tgUser) app.storage.SetTelegramKey(req.ID, tgUser)
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{ for _, tps := range app.thirdPartyServices {
jellyseerr.FieldTelegram: tgUser.ChatID, if err := tps.SetContactMethods(req.ID, nil, nil, &tgUser, &common.ContactPreferences{
jellyseerr.FieldTelegramEnabled: tgUser.Contact, Telegram: &tgUser.Contact,
}); err != nil { }); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err) app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
}
} }
linkExistingOmbiDiscordTelegram(app) app.InvalidateWebUserCache()
respondBool(200, true, gc) respondBool(200, true, gc)
} }
// @Summary Sets whether to notify a user through telegram/discord/matrix/email or not. // @Summary Sets whether to notify a user through telegram/discord/matrix/email or not.
// @Produce json // @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 200 {object} boolResponse
// @Success 400 {object} boolResponse // @Success 400 {object} boolResponse
// @Success 500 {object} boolResponse // @Success 500 {object} boolResponse
@@ -346,24 +287,24 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
// @Security Bearer // @Security Bearer
// @tags Other // @tags Other
func (app *appContext) SetContactMethods(gc *gin.Context) { func (app *appContext) SetContactMethods(gc *gin.Context) {
var req SetContactMethodsDTO var req SetContactPreferencesDTO
gc.BindJSON(&req) gc.BindJSON(&req)
if req.ID == "" { if req.ID == "" {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
app.setContactMethods(req, gc) app.setContactPreferences(req, gc)
} }
func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Context) { func (app *appContext) setContactPreferences(req SetContactPreferencesDTO, gc *gin.Context) {
jsPrefs := map[jellyseerr.NotificationsField]any{} contactPrefs := common.ContactPreferences{}
if tgUser, ok := app.storage.GetTelegramKey(req.ID); ok { if tgUser, ok := app.storage.GetTelegramKey(req.ID); ok {
change := tgUser.Contact != req.Telegram change := tgUser.Contact != req.Telegram
tgUser.Contact = req.Telegram tgUser.Contact = req.Telegram
app.storage.SetTelegramKey(req.ID, tgUser) app.storage.SetTelegramKey(req.ID, tgUser)
if change { if change {
app.debug.Printf(lm.SetContactPrefForService, lm.Telegram, tgUser.Username, req.Telegram) 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 { if dcUser, ok := app.storage.GetDiscordKey(req.ID); ok {
@@ -372,7 +313,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
app.storage.SetDiscordKey(req.ID, dcUser) app.storage.SetDiscordKey(req.ID, dcUser)
if change { if change {
app.debug.Printf(lm.SetContactPrefForService, lm.Discord, dcUser.Username, req.Discord) 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 { if mxUser, ok := app.storage.GetMatrixKey(req.ID); ok {
@@ -381,6 +322,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
app.storage.SetMatrixKey(req.ID, mxUser) app.storage.SetMatrixKey(req.ID, mxUser)
if change { if change {
app.debug.Printf(lm.SetContactPrefForService, lm.Matrix, mxUser.UserID, req.Matrix) app.debug.Printf(lm.SetContactPrefForService, lm.Matrix, mxUser.UserID, req.Matrix)
contactPrefs.Matrix = &req.Matrix
} }
} }
if email, ok := app.storage.GetEmailsKey(req.ID); ok { if email, ok := app.storage.GetEmailsKey(req.ID); ok {
@@ -389,15 +331,16 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
app.storage.SetEmailsKey(req.ID, email) app.storage.SetEmailsKey(req.ID, email)
if change { if change {
app.debug.Printf(lm.SetContactPrefForService, lm.Email, email.Addr, req.Email) 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) for _, tps := range app.thirdPartyServices {
if err != nil { if err := tps.SetContactMethods(req.ID, nil, nil, nil, &contactPrefs); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err) app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
} }
} }
app.InvalidateWebUserCache()
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@@ -626,6 +569,7 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
Lang: "en-us", Lang: "en-us",
Contact: true, Contact: true,
}) })
app.InvalidateWebUserCache()
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@@ -680,11 +624,12 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
app.storage.SetDiscordKey(req.JellyfinID, user) app.storage.SetDiscordKey(req.JellyfinID, user)
if err := app.js.ModifyNotifications(req.JellyfinID, map[jellyseerr.NotificationsField]any{ for _, tps := range app.thirdPartyServices {
jellyseerr.FieldDiscord: req.DiscordID, if err := tps.SetContactMethods(req.JellyfinID, nil, &user, nil, &common.ContactPreferences{
jellyseerr.FieldDiscordEnabled: true, Discord: &user.Contact,
}); err != nil { }); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err) app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
}
} }
app.storage.SetActivityKey(shortuuid.New(), Activity{ app.storage.SetActivityKey(shortuuid.New(), Activity{
@@ -697,6 +642,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
}, gc, false) }, gc, false)
linkExistingOmbiDiscordTelegram(app) linkExistingOmbiDiscordTelegram(app)
app.InvalidateWebUserCache()
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@@ -717,12 +663,14 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
} */ } */
app.storage.DeleteDiscordKey(req.ID) app.storage.DeleteDiscordKey(req.ID)
// May not actually remove Discord ID, but should disable interaction. contact := false
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier, for _, tps := range app.thirdPartyServices {
jellyseerr.FieldDiscordEnabled: false, if err := tps.SetContactMethods(req.ID, nil, EmptyDiscordUser(), nil, &common.ContactPreferences{
Discord: &contact,
}); err != nil { }); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err) app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
}
} }
app.storage.SetActivityKey(shortuuid.New(), Activity{ app.storage.SetActivityKey(shortuuid.New(), Activity{
@@ -734,6 +682,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
Time: time.Now(), Time: time.Now(),
}, gc, false) }, gc, false)
app.InvalidateWebUserCache()
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@@ -754,11 +703,14 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
} */ } */
app.storage.DeleteTelegramKey(req.ID) app.storage.DeleteTelegramKey(req.ID)
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{ contact := false
jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier,
jellyseerr.FieldTelegramEnabled: false, for _, tps := range app.thirdPartyServices {
if err := tps.SetContactMethods(req.ID, nil, nil, EmptyTelegramUser(), &common.ContactPreferences{
Telegram: &contact,
}); err != nil { }); err != nil {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err) app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
}
} }
app.storage.SetActivityKey(shortuuid.New(), Activity{ app.storage.SetActivityKey(shortuuid.New(), Activity{
@@ -770,6 +722,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
Time: time.Now(), Time: time.Now(),
}, gc, false) }, gc, false)
app.InvalidateWebUserCache()
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@@ -799,5 +752,6 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) {
Time: time.Now(), Time: time.Now(),
}, gc, false) }, gc, false)
app.InvalidateWebUserCache()
respondBool(200, true, gc) respondBool(200, true, gc)
} }
+58 -11
View File
@@ -8,7 +8,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/common" "github.com/hrfee/jfa-go/common"
lm "github.com/hrfee/jfa-go/logmessages" lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/jfa-go/ombi" ombiLib "github.com/hrfee/jfa-go/ombi"
"github.com/hrfee/mediabrowser" "github.com/hrfee/mediabrowser"
) )
@@ -147,7 +147,8 @@ func (app *appContext) DeleteOmbiProfile(gc *gin.Context) {
} }
type OmbiWrapper struct { 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) { 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 return
} }
func (ombi *OmbiWrapper) AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (err error) { func (ombi *OmbiWrapper) SetContactMethods(jellyfinID string, email *string, discord *DiscordUser, telegram *TelegramUser, contactPrefs *common.ContactPreferences) (err error) {
var ombiUser map[string]interface{} ombiUser, err := ombi.OmbiUserByJfID(jellyfinID)
ombiUser, err = ombi.getUser(req.Username, req.Email)
if err != nil { if err != nil {
return return
} }
if discordEnabled || telegramEnabled { if contactPrefs == nil {
dID := "" contactPrefs = &common.ContactPreferences{
tUser := "" 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 { 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 { if telegram != nil {
tUser = telegram.Username pref.Value = telegram.Username
} }
data = append(data, pref)
}
if len(data) > 0 {
var resp string var resp string
resp, err = ombi.SetNotificationPrefs(ombiUser, dID, tUser) resp, err = ombi.SetNotificationPrefs(ombiUser, data)
if err != nil { if err != nil {
if resp != "" { if resp != "" {
err = fmt.Errorf("%v, %s", err, resp) err = fmt.Errorf("%v, %s", err, resp)
+80 -1
View File
@@ -2,6 +2,8 @@ package main
import ( import (
"fmt" "fmt"
"net/http"
"net/url"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -69,6 +71,68 @@ func (app *appContext) GetProfiles(gc *gin.Context) {
gc.JSON(200, out) 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. // @Summary Set the default profile to use.
// @Produce json // @Produce json
// @Param profileChangeDTO body profileChangeDTO true "Default profile object" // @Param profileChangeDTO body profileChangeDTO true "Default profile object"
@@ -119,7 +183,7 @@ func (app *appContext) CreateProfile(gc *gin.Context) {
} }
profile := Profile{ profile := Profile{
FromUser: user.Name, FromUser: user.Name,
Policy: user.Policy, ProfileDTO: ProfileDTO{Policy: user.Policy},
Homescreen: req.Homescreen, Homescreen: req.Homescreen,
} }
app.debug.Printf(lm.CreateProfileFromUser, user.Name) app.debug.Printf(lm.CreateProfileFromUser, user.Name)
@@ -132,6 +196,21 @@ func (app *appContext) CreateProfile(gc *gin.Context) {
return 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, user.ID, err)
} else {
profile.Jellyseerr.Notifications = n.NotificationsTemplate
profile.Jellyseerr.Enabled = true
}
}
}
app.storage.SetProfileKey(req.Name, profile) app.storage.SetProfileKey(req.Name, profile)
// Refresh discord bots, profile list // Refresh discord bots, profile list
if discordEnabled { if discordEnabled {
+8 -5
View File
@@ -107,7 +107,7 @@ func (app *appContext) MyDetails(gc *gin.Context) {
// @Summary Sets whether to notify yourself through telegram/discord/matrix/email or not. // @Summary Sets whether to notify yourself through telegram/discord/matrix/email or not.
// @Produce json // @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 200 {object} boolResponse
// @Success 400 {object} boolResponse // @Success 400 {object} boolResponse
// @Success 500 {object} boolResponse // @Success 500 {object} boolResponse
@@ -115,14 +115,14 @@ func (app *appContext) MyDetails(gc *gin.Context) {
// @Security Bearer // @Security Bearer
// @tags User Page // @tags User Page
func (app *appContext) SetMyContactMethods(gc *gin.Context) { func (app *appContext) SetMyContactMethods(gc *gin.Context) {
var req SetContactMethodsDTO var req SetContactPreferencesDTO
gc.BindJSON(&req) gc.BindJSON(&req)
req.ID = gc.GetString("jfId") req.ID = gc.GetString("jfId")
if req.ID == "" { if req.ID == "" {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
app.setContactMethods(req, gc) app.setContactPreferences(req, gc)
} }
// @Summary Logout by deleting refresh token from cookies. // @Summary Logout by deleting refresh token from cookies.
@@ -264,7 +264,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
} }
app.debug.Printf(lm.EmailConfirmationRequired, id) app.debug.Printf(lm.EmailConfirmationRequired, id)
respond(401, "confirmEmail", gc) respond(401, "confirmEmail", gc)
msg, err := app.email.constructConfirmation("", name, key, app, false) msg, err := app.email.constructConfirmation("", name, key, false)
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructConfirmationEmail, id, err) app.err.Printf(lm.FailedConstructConfirmationEmail, id, err)
} else if err := app.email.send(msg, req.Email); err != nil { } else if err := app.email.send(msg, req.Email); err != nil {
@@ -394,8 +394,10 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
return return
} }
tgUser := TelegramUser{ tgUser := TelegramUser{
TelegramVerifiedToken: TelegramVerifiedToken{
ChatID: token.ChatID, ChatID: token.ChatID,
Username: token.Username, Username: token.Username,
},
Contact: true, Contact: true,
} }
if lang, ok := app.telegram.languages[tgUser.ChatID]; ok { if lang, ok := app.telegram.languages[tgUser.ChatID]; ok {
@@ -643,7 +645,7 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
Username: pwr.Username, Username: pwr.Username,
Expiry: pwr.Expiry, Expiry: pwr.Expiry,
Internal: true, Internal: true,
}, app, false, }, false,
) )
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err) app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
@@ -796,6 +798,7 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour) inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
app.storage.SetInvitesKey(inv.Code, inv) app.storage.SetInvitesKey(inv.Code, inv)
} }
app.InvalidateWebUserCache()
gc.JSON(200, GetMyReferralRespDTO{ gc.JSON(200, GetMyReferralRespDTO{
Code: inv.Code, Code: inv.Code,
RemainingUses: inv.RemainingUses, RemainingUses: inv.RemainingUses,
+130 -60
View File
@@ -10,7 +10,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt" "github.com/golang-jwt/jwt"
"github.com/hrfee/jfa-go/jellyseerr" "github.com/hrfee/jfa-go/common"
lm "github.com/hrfee/jfa-go/logmessages" lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser" "github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3" "github.com/lithammer/shortuuid/v3"
@@ -54,12 +54,29 @@ func (app *appContext) NewUserFromAdmin(gc *gin.Context) {
nu.Log() nu.Log()
} }
var emailStore *EmailAddress = nil
if emailEnabled && req.Email != "" { if emailEnabled && req.Email != "" {
emailStore := EmailAddress{ emailStore = &EmailAddress{
Addr: req.Email, Addr: req.Email,
Contact: true, 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 welcomeMessageSentIfNecessary := true
@@ -189,7 +206,7 @@ func (app *appContext) NewUserFromInvite(gc *gin.Context) {
app.debug.Printf(lm.EmailConfirmationRequired, req.Username) app.debug.Printf(lm.EmailConfirmationRequired, req.Username)
respond(401, "confirmEmail", gc) 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 { if err != nil {
app.err.Printf(lm.FailedConstructConfirmationEmail, req.Code, err) app.err.Printf(lm.FailedConstructConfirmationEmail, req.Code, err)
} else if err := app.email.send(msg, req.Email); err != nil { } else if err := app.email.send(msg, req.Email); err != nil {
@@ -262,18 +279,20 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
} }
app.contactMethods[i].DeleteVerifiedToken(c.PIN) app.contactMethods[i].DeleteVerifiedToken(c.PIN)
c.User.SetJellyfin(nu.User.ID) 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) 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 { if (emailEnabled && req.Email != "") || invite.UserLabel != "" || referralsEnabled {
emailStore := EmailAddress{ emailStore := EmailAddress{
Addr: req.Email, Addr: req.Email,
Contact: (req.Email != ""), Contact: (req.Email != ""),
Label: invite.UserLabel, Label: invite.UserLabel,
} }
contactPrefs.Email = &(emailStore.Contact)
if profile != nil { if profile != nil {
profile.ReferralTemplateKey = profile.ReferralTemplateKey profile.ReferralTemplateKey = profile.ReferralTemplateKey
} }
@@ -290,7 +309,7 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
continue continue
} }
go func(addr string) { 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 { if err != nil {
app.err.Printf(lm.FailedConstructCreationAdmin, req.Code, err) app.err.Printf(lm.FailedConstructCreationAdmin, req.Code, err)
} else { } else {
@@ -334,18 +353,22 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
var discordUser *DiscordUser = nil var discordUser *DiscordUser = nil
var telegramUser *TelegramUser = 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) { 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, // FIXME: figure these out in a nicer way? this relies on the current ordering,
// which may not be fixed. // which may not be fixed.
if discordEnabled { if discordEnabled {
if req.completeContactMethods[0].User != nil { if req.completeContactMethods[0].User != nil {
discordUser = req.completeContactMethods[0].User.(*DiscordUser) discordUser = req.completeContactMethods[0].User.(*DiscordUser)
contactPrefs.Discord = &discordUser.Contact
} }
if telegramEnabled && req.completeContactMethods[1].User != nil { if telegramEnabled && req.completeContactMethods[1].User != nil {
telegramUser = req.completeContactMethods[1].User.(*TelegramUser) telegramUser = req.completeContactMethods[1].User.(*TelegramUser)
contactPrefs.Telegram = &telegramUser.Contact
} }
} else if telegramEnabled && req.completeContactMethods[0].User != nil { } else if telegramEnabled && req.completeContactMethods[0].User != nil {
telegramUser = req.completeContactMethods[0].User.(*TelegramUser) telegramUser = req.completeContactMethods[0].User.(*TelegramUser)
contactPrefs.Telegram = &telegramUser.Contact
} }
} }
@@ -354,7 +377,7 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
continue continue
} }
// User already created, now we can link contact methods // 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 { if err != nil {
app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err) app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err)
} }
@@ -380,19 +403,6 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
"SetPolicy": map[string]string{}, "SetPolicy": map[string]string{},
} }
sendMail := messagesEnabled 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 activityType := ActivityDisabled
if req.Enabled { if req.Enabled {
activityType = ActivityEnabled activityType = ActivityEnabled
@@ -404,6 +414,18 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
app.err.Printf(lm.FailedGetUser, user.ID, lm.Jellyfin, err) app.err.Printf(lm.FailedGetUser, user.ID, lm.Jellyfin, err)
continue 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) err, _, _ = app.SetUserDisabled(user, !req.Enabled)
if err != nil { if err != nil {
errors["SetPolicy"][user.ID] = err.Error() errors["SetPolicy"][user.ID] = err.Error()
@@ -449,15 +471,6 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
gc.BindJSON(&req) gc.BindJSON(&req)
errors := map[string]string{} errors := map[string]string{}
sendMail := messagesEnabled 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 { for _, userID := range req.Users {
user, err := app.jf.UserByID(userID, false) user, err := app.jf.UserByID(userID, false)
if err != nil { if err != nil {
@@ -465,6 +478,15 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
errors[userID] = err.Error() 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 deleted := false
err, deleted = app.DeleteUser(user) err, deleted = app.DeleteUser(user)
if err != nil { if err != nil {
@@ -526,6 +548,24 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
base := time.Now() base := time.Now()
if expiry, ok := app.storage.GetUserExpiryKey(id); ok { if expiry, ok := app.storage.GetUserExpiryKey(id); ok {
base = expiry.Expiry 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) app.debug.Printf(lm.ExtendCreateExpiry, id)
expiry := UserExpiry{} expiry := UserExpiry{}
@@ -541,7 +581,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
if err != nil { if err != nil {
return 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 { if err != nil {
app.err.Printf(lm.FailedConstructExpiryAdjustmentMessage, uid, err) app.err.Printf(lm.FailedConstructExpiryAdjustmentMessage, uid, err)
return return
@@ -552,6 +592,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
}(id, expiry.Expiry) }(id, expiry.Expiry)
} }
} }
app.InvalidateWebUserCache()
respondBool(204, true, gc) respondBool(204, true, gc)
} }
@@ -563,6 +604,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
// @tags Users // @tags Users
func (app *appContext) RemoveExpiry(gc *gin.Context) { func (app *appContext) RemoveExpiry(gc *gin.Context) {
app.storage.DeleteUserExpiryKey(gc.Param("id")) app.storage.DeleteUserExpiryKey(gc.Param("id"))
app.InvalidateWebUserCache()
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@@ -624,6 +666,7 @@ func (app *appContext) EnableReferralForUsers(gc *gin.Context) {
inv.UseReferralExpiry = useExpiry inv.UseReferralExpiry = useExpiry
app.storage.SetInvitesKey(inv.Code, inv) app.storage.SetInvitesKey(inv.Code, inv)
} }
app.InvalidateWebUserCache()
} }
// @Summary Disable referrals for the given user(s). // @Summary Disable referrals for the given user(s).
@@ -647,6 +690,7 @@ func (app *appContext) DisableReferralForUsers(gc *gin.Context) {
user.ReferralTemplateKey = "" user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(u, user) app.storage.SetEmailsKey(u, user)
} }
app.InvalidateWebUserCache()
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@@ -675,7 +719,10 @@ func (app *appContext) Announce(gc *gin.Context) {
app.err.Printf(lm.FailedGetUser, userID, lm.Jellyfin, err) app.err.Printf(lm.FailedGetUser, userID, lm.Jellyfin, err)
continue 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 { if err != nil {
app.err.Printf(lm.FailedConstructAnnouncementMessage, userID, err) app.err.Printf(lm.FailedConstructAnnouncementMessage, userID, err)
respondBool(500, false, gc) respondBool(500, false, gc)
@@ -688,7 +735,10 @@ func (app *appContext) Announce(gc *gin.Context) {
} }
// app.info.Printf(lm.SentAnnouncementMessage, "*", "?") // app.info.Printf(lm.SentAnnouncementMessage, "*", "?")
} else { } 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 { if err != nil {
app.err.Printf(lm.FailedConstructAnnouncementMessage, "*", err) app.err.Printf(lm.FailedConstructAnnouncementMessage, "*", err)
respondBool(500, false, gc) respondBool(500, false, gc)
@@ -808,7 +858,7 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) {
app.internalPWRs[pwr.PIN] = pwr app.internalPWRs[pwr.PIN] = pwr
sendAddress := app.getAddressOrName(id) sendAddress := app.getAddressOrName(id)
if sendAddress == "" || len(req.Users) == 1 { if sendAddress == "" || len(req.Users) == 1 {
resp.Link, err = app.GenResetLink(pwr.PIN) resp.Link, err = GenResetLink(pwr.PIN)
linkCount++ linkCount++
if sendAddress == "" { if sendAddress == "" {
resp.Manual = true resp.Manual = true
@@ -821,7 +871,7 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) {
Username: pwr.Username, Username: pwr.Username,
Expiry: pwr.Expiry, Expiry: pwr.Expiry,
Internal: true, Internal: true,
}, app, false, }, false,
) )
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructPWRMessage, id, err) app.err.Printf(lm.FailedConstructPWRMessage, id, err)
@@ -841,6 +891,8 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) {
respondBool(204, true, gc) 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 { func (app *appContext) userSummary(jfUser mediabrowser.User) respUser {
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true) adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
allowAll := app.config.Section("ui").Key("allow_all").MustBool(false) allowAll := app.config.Section("ui").Key("allow_all").MustBool(false)
@@ -900,7 +952,7 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser {
// @Success 200 {object} PageCountDTO // @Success 200 {object} PageCountDTO
// @Router /users/count [get] // @Router /users/count [get]
// @Security Bearer // @Security Bearer
// @tags Activity // @tags Activity,Statistics
func (app *appContext) GetUserCount(gc *gin.Context) { func (app *appContext) GetUserCount(gc *gin.Context) {
resp := PageCountDTO{} resp := PageCountDTO{}
users, err := app.jf.GetUsers(false) users, err := app.jf.GetUsers(false)
@@ -941,7 +993,7 @@ func (app *appContext) GetUsers(gc *gin.Context) {
// @Failure 500 {object} stringResponse // @Failure 500 {object} stringResponse
// @Router /users [post] // @Router /users [post]
// @Security Bearer // @Security Bearer
// @tags Users // @tags Users,Statistics
func (app *appContext) SearchUsers(gc *gin.Context) { func (app *appContext) SearchUsers(gc *gin.Context) {
req := ServerSearchReqDTO{} req := ServerSearchReqDTO{}
gc.BindJSON(&req) gc.BindJSON(&req)
@@ -980,6 +1032,38 @@ func (app *appContext) SearchUsers(gc *gin.Context) {
gc.JSON(200, resp) 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)
}
// @Summary Set whether or not a user can access jfa-go. Redundant if the user is a Jellyfin admin. // @Summary Set whether or not a user can access jfa-go. Redundant if the user is a Jellyfin admin.
// @Produce json // @Produce json
// @Param setAccountsAdminDTO body setAccountsAdminDTO true "Map of userIDs to whether or not they have access." // @Param setAccountsAdminDTO body setAccountsAdminDTO true "Map of userIDs to whether or not they have access."
@@ -1009,6 +1093,7 @@ func (app *appContext) SetAccountsAdmin(gc *gin.Context) {
app.info.Printf(lm.UserAdminAdjusted, id, admin) app.info.Printf(lm.UserAdminAdjusted, id, admin)
} }
} }
app.InvalidateWebUserCache()
respondBool(204, true, gc) respondBool(204, true, gc)
} }
@@ -1041,45 +1126,29 @@ func (app *appContext) ModifyLabels(gc *gin.Context) {
app.storage.SetEmailsKey(id, emailStore) app.storage.SetEmailsKey(id, emailStore)
} }
} }
app.InvalidateWebUserCache()
respondBool(204, true, gc) respondBool(204, true, gc)
} }
func (app *appContext) modifyEmail(jfID string, addr string) { func (app *appContext) modifyEmail(jfID string, addr string) {
contactPrefChanged := false
emailStore, ok := app.storage.GetEmailsKey(jfID) emailStore, ok := app.storage.GetEmailsKey(jfID)
// Auto enable contact by email for newly added addresses // Auto enable contact by email for newly added addresses
if !ok || emailStore.Addr == "" { if !ok || emailStore.Addr == "" {
emailStore = EmailAddress{ emailStore = EmailAddress{
Contact: true, Contact: true,
} }
contactPrefChanged = true
} }
emailStore.Addr = addr emailStore.Addr = addr
app.storage.SetEmailsKey(jfID, emailStore) app.storage.SetEmailsKey(jfID, emailStore)
if app.config.Section("ombi").Key("enabled").MustBool(false) {
ombiUser, err := app.getOmbiUser(jfID) for _, tps := range app.thirdPartyServices {
if err == nil { if err := tps.SetContactMethods(jfID, &addr, nil, nil, &common.ContactPreferences{
ombiUser["emailAddress"] = addr Email: &(emailStore.Contact),
err = app.ombi.ModifyUser(ombiUser) }); err != nil {
if err != nil { app.err.Printf(lm.FailedSetEmailAddress, tps.Name(), jfID, err)
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)
}
} }
} }
app.InvalidateWebUserCache()
} }
// @Summary Modify user's email addresses. // @Summary Modify user's email addresses.
@@ -1290,5 +1359,6 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
if len(errors["policy"]) == len(req.ApplyTo) || len(errors["homescreen"]) == len(req.ApplyTo) { if len(errors["policy"]) == len(req.ApplyTo) || len(errors["homescreen"]) == len(req.ApplyTo) {
code = 500 code = 500
} }
app.InvalidateUserCaches()
gc.JSON(code, errors) gc.JSON(code, errors)
} }
+6 -15
View File
@@ -36,23 +36,14 @@ func respondBool(code int, val bool, gc *gin.Context) {
gc.Abort() gc.Abort()
} }
func (app *appContext) loadStrftime() { func prettyTime(dt time.Time) (date, time string) {
app.datePattern = app.config.Section("messages").Key("date_format").String() date = timefmt.Format(dt, datePattern)
app.timePattern = `%H:%M` time = timefmt.Format(dt, timePattern)
if val, _ := app.config.Section("messages").Key("use_24h").Bool(); !val {
app.timePattern = `%I:%M %p`
}
return return
} }
func (app *appContext) prettyTime(dt time.Time) (date, time string) { func formatDatetime(dt time.Time) string {
date = timefmt.Format(dt, app.datePattern) d, t := prettyTime(dt)
time = timefmt.Format(dt, app.timePattern)
return
}
func (app *appContext) formatDatetime(dt time.Time) string {
d, t := app.prettyTime(dt)
return d + " " + t return d + " " + t
} }
@@ -310,7 +301,7 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
if req["restart-program"] != nil && req["restart-program"].(bool) { if req["restart-program"] != nil && req["restart-program"].(bool) {
app.Restart() app.Restart()
} }
app.loadConfig() app.ReloadConfig()
// Patch new settings for next GetConfig // Patch new settings for next GetConfig
app.PatchConfigBase() app.PatchConfigBase()
// Reinitialize password validator on config change, as opposed to every applicable request like in python. // Reinitialize password validator on config change, as opposed to every applicable request like in python.
+4
View File
@@ -31,6 +31,7 @@ func (app *appContext) loadArgs(firstCall bool) {
SWAGGER = flag.Bool("swagger", false, "Enable swagger at /swagger/index.html") 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.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() flag.Parse()
if *help { if *help {
@@ -52,11 +53,14 @@ func (app *appContext) loadArgs(firstCall bool) {
if NO_API_AUTH_DO_NOT_USE && *DEBUG { if NO_API_AUTH_DO_NOT_USE && *DEBUG {
NO_API_AUTH_DO_NOT_USE = false NO_API_AUTH_DO_NOT_USE = false
forceJfID := NO_API_AUTH_FORCE_JFID
NO_API_AUTH_FORCE_JFID = ""
buf := bufio.NewReader(os.Stdin) buf := bufio.NewReader(os.Stdin)
app.err.Print(lm.NoAPIAuthPrompt) app.err.Print(lm.NoAPIAuthPrompt)
sentence, err := buf.ReadBytes('\n') sentence, err := buf.ReadBytes('\n')
if err == nil && strings.ContainsRune(string(sentence), 'y') { if err == nil && strings.ContainsRune(string(sentence), 'y') {
NO_API_AUTH_DO_NOT_USE = true NO_API_AUTH_DO_NOT_USE = true
NO_API_AUTH_FORCE_JFID = forceJfID
} }
} }
} }
+11
View File
@@ -40,8 +40,12 @@ func (app *appContext) logIpErr(gc *gin.Context, user bool, out string) {
} }
func (app *appContext) webAuth() gin.HandlerFunc { func (app *appContext) webAuth() gin.HandlerFunc {
if NO_API_AUTH_DO_NOT_USE {
return app.bogusAuthenticate
} else {
return app.authenticate return app.authenticate
} }
}
func (app *appContext) authLog(v any) { app.debug.PrintfCustomLevel(4, lm.FailedAuthRequest, v) } 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() 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) { func checkToken(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method %v", token.Header["alg"]) return nil, fmt.Errorf("Unexpected signing method %v", token.Header["alg"])
+6 -4
View File
@@ -14,6 +14,7 @@ import (
const ( const (
BACKUP_PREFIX = "jfa-go-db" BACKUP_PREFIX = "jfa-go-db"
BACKUP_PREFIX_OLD = "jfa-go-db-"
BACKUP_COMMIT_PREFIX = "-c-" BACKUP_COMMIT_PREFIX = "-c-"
BACKUP_DATE_PREFIX = "-d-" BACKUP_DATE_PREFIX = "-d-"
BACKUP_UPLOAD_PREFIX = "upload-" BACKUP_UPLOAD_PREFIX = "upload-"
@@ -33,7 +34,7 @@ func (b Backup) Equals(a Backup) bool {
return a.Date.Equal(b.Date) && a.Commit == b.Commit && a.Upload == b.Upload return a.Date.Equal(b.Date) && a.Commit == b.Commit && a.Upload == b.Upload
} }
// Pre 21/03/25 format: "{BACKUP_PREFIX}{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-2006-01-02T15-04-05.bak" // 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" // 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 { func (b Backup) String() string {
@@ -213,7 +214,6 @@ func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) {
count += 1 count += 1
backupsByCommit[b.Commit] = count backupsByCommit[b.Commit] = count
} }
fmt.Printf("remaining:%+v\n", backupsByCommit)
} }
// fmt.Printf("toDelete: %d, backCount: %d, keep: %d, length: %d\n", toDelete, backups.count, toKeep, len(backups.files)) // fmt.Printf("toDelete: %d, backCount: %d, keep: %d, length: %d\n", toDelete, backups.count, toKeep, len(backups.files))
if toDelete > 0 && toDelete <= backups.count { if toDelete > 0 && toDelete <= backups.count {
@@ -274,8 +274,10 @@ func (app *appContext) loadPendingBackup() {
} }
app.info.Printf(lm.MoveOldDB, oldPath) app.info.Printf(lm.MoveOldDB, oldPath)
app.ConnectDB() if err := app.storage.Connect(app.config); err != nil {
defer app.storage.db.Close() app.err.Fatalf(lm.FailedConnectDB, app.storage.db_path, err)
}
defer app.storage.Close()
f, err := os.Open(LOADBAK) f, err := os.Open(LOADBAK)
if err != nil { if err != nil {
+2 -2
View File
@@ -17,13 +17,13 @@ func testBackupParse(f string, a Backup, t *testing.T) {
} }
func TestBackupParserOld(t *testing.T) { func TestBackupParserOld(t *testing.T) {
Q1 := BACKUP_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX Q1 := BACKUP_PREFIX_OLD + "2023-12-21T21-08-00" + BACKUP_SUFFIX
A1 := Backup{} A1 := Backup{}
A1.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00") A1.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
testBackupParse(Q1, A1, t) testBackupParse(Q1, A1, t)
} }
func TestBackupParserOldUpload(t *testing.T) { func TestBackupParserOldUpload(t *testing.T) {
Q2 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX Q2 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX_OLD + "2023-12-21T21-08-00" + BACKUP_SUFFIX
A2 := Backup{ A2 := Backup{
Upload: true, Upload: true,
} }
+19
View File
@@ -11,10 +11,21 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time"
lm "github.com/hrfee/jfa-go/logmessages" 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. // TimeoutHandler recovers from an http timeout or panic.
type TimeoutHandler func() type TimeoutHandler func()
@@ -155,3 +166,11 @@ func decodeResp(resp *http.Response) (string, error) {
} }
return buf.String(), nil 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.
}
+19
View File
@@ -8,6 +8,7 @@ type SectionMeta struct {
DependsTrue string `json:"depends_true,omitempty" yaml:"depends_true,omitempty"` DependsTrue string `json:"depends_true,omitempty" yaml:"depends_true,omitempty"`
DependsFalse string `json:"depends_false,omitempty" yaml:"depends_false,omitempty"` DependsFalse string `json:"depends_false,omitempty" yaml:"depends_false,omitempty"`
WikiLink string `json:"wiki_link,omitempty" yaml:"wiki_link,omitempty"` WikiLink string `json:"wiki_link,omitempty" yaml:"wiki_link,omitempty"`
Aliases []string `json:"aliases,omitempty" yaml:"aliases,omitempty"`
} }
type Option [2]string type Option [2]string
@@ -40,6 +41,7 @@ type Setting struct {
Style string `json:"style,omitempty" yaml:"style,omitempty"` Style string `json:"style,omitempty" yaml:"style,omitempty"`
Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"`
WikiLink string `json:"wiki_link,omitempty" yaml:"wiki_link,omitempty"` WikiLink string `json:"wiki_link,omitempty" yaml:"wiki_link,omitempty"`
Aliases []string `json:"aliases,omitempty" yaml:"aliases,omitempty"`
} }
type Section struct { type Section struct {
@@ -48,8 +50,25 @@ type Section struct {
Settings []Setting `json:"settings" yaml:"settings"` 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 { type Config struct {
Sections []Section `json:"sections" yaml:"sections"` 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) { func (c *Config) removeSection(section string) {
+213 -160
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"io/fs" "io/fs"
"net" "net"
"net/http"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
@@ -18,18 +19,24 @@ import (
"gopkg.in/ini.v1" "gopkg.in/ini.v1"
) )
type Config struct {
*ini.File
proxyTransport *http.Transport
proxyConfig *easyproxy.ProxyConfig
}
var emailEnabled = false var emailEnabled = false
var messagesEnabled = false var messagesEnabled = false
var telegramEnabled = false var telegramEnabled = false
var discordEnabled = false var discordEnabled = false
var matrixEnabled = false var matrixEnabled = false
// URL subpaths. Ignore the "Current" field. // 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 /. // IMPORTANT: When linking straight to a page, rather than appending further to the URL (like accessing an API route), append a /.
var PAGES = PagePaths{} var PAGES = PagePaths{}
func (app *appContext) GetPath(sect, key string) (fs.FS, string) { func (config *Config) GetPath(sect, key string) (fs.FS, string) {
val := app.config.Section(sect).Key(key).MustString("") val := config.Section(sect).Key(key).MustString("")
if strings.HasPrefix(val, "jfa-go:") { if strings.HasPrefix(val, "jfa-go:") {
return localFS, strings.TrimPrefix(val, "jfa-go:") return localFS, strings.TrimPrefix(val, "jfa-go:")
} }
@@ -37,15 +44,15 @@ func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
return os.DirFS(dir), file return os.DirFS(dir), file
} }
func (app *appContext) MustSetValue(section, key, val string) { func (config *Config) MustSetValue(section, key, val string) {
app.config.Section(section).Key(key).SetValue(app.config.Section(section).Key(key).MustString(val)) config.Section(section).Key(key).SetValue(config.Section(section).Key(key).MustString(val))
} }
func (app *appContext) MustSetURLPath(section, key, val string) { func (config *Config) MustSetURLPath(section, key, val string) {
if !strings.HasPrefix(val, "/") && val != "" { if !strings.HasPrefix(val, "/") && val != "" {
val = "/" + val val = "/" + val
} }
app.MustSetValue(section, key, val) config.MustSetValue(section, key, val)
} }
func FixFullURL(v string) string { func FixFullURL(v string) string {
@@ -69,26 +76,26 @@ func FormatSubpath(path string, removeSingleSlash bool) string {
return strings.TrimSuffix(path, "/") return strings.TrimSuffix(path, "/")
} }
func (app *appContext) MustCorrectURL(section, key, value string) { func (config *Config) MustCorrectURL(section, key, value string) {
v := app.config.Section(section).Key(key).String() v := config.Section(section).Key(key).String()
if v == "" { if v == "" {
v = value v = value
} }
v = FixFullURL(v) v = FixFullURL(v)
app.config.Section(section).Key(key).SetValue(v) config.Section(section).Key(key).SetValue(v)
} }
// ExternalDomain returns the Host for the request, using the fixed app.externalDomain value unless app.UseProxyHost is true. // ExternalDomain returns the Host for the request, using the fixed externalDomain value unless UseProxyHost is true.
func (app *appContext) ExternalDomain(gc *gin.Context) string { func ExternalDomain(gc *gin.Context) string {
if !app.UseProxyHost || gc.Request.Host == "" { if !UseProxyHost || gc.Request.Host == "" {
return app.externalDomain return externalDomain
} }
return gc.Request.Host return gc.Request.Host
} }
// ExternalDomainNoPort attempts to return app.ExternalDomain() with the port removed. If the internally-used method fails, it is assumed the domain has no port anyway. // 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 { func (app *appContext) ExternalDomainNoPort(gc *gin.Context) string {
domain := app.ExternalDomain(gc) domain := ExternalDomain(gc)
host, _, err := net.SplitHostPort(domain) host, _, err := net.SplitHostPort(domain)
if err != nil { if err != nil {
return domain return domain
@@ -96,11 +103,11 @@ func (app *appContext) ExternalDomainNoPort(gc *gin.Context) string {
return host return host
} }
// ExternalURI returns the External URI of jfa-go's root directory (by default, where the admin page is), using the fixed app.externalURI value unless app.UseProxyHost is true and gc is not nil. // 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, app.externalURI is returned. // When nil is passed, externalURI is returned.
func (app *appContext) ExternalURI(gc *gin.Context) string { func ExternalURI(gc *gin.Context) string {
if gc == nil { if gc == nil {
return app.externalURI return externalURI
} }
var proto string var proto string
@@ -111,10 +118,10 @@ func (app *appContext) ExternalURI(gc *gin.Context) string {
} }
// app.debug.Printf("Request: %+v\n", gc.Request) // app.debug.Printf("Request: %+v\n", gc.Request)
if app.UseProxyHost && gc.Request.Host != "" { if UseProxyHost && gc.Request.Host != "" {
return proto + gc.Request.Host + PAGES.Base return proto + gc.Request.Host + PAGES.Base
} }
return app.externalURI return externalURI
} }
func (app *appContext) EvaluateRelativePath(gc *gin.Context, path string) string { func (app *appContext) EvaluateRelativePath(gc *gin.Context, path string) string {
@@ -129,174 +136,197 @@ func (app *appContext) EvaluateRelativePath(gc *gin.Context, path string) string
proto = "http://" proto = "http://"
} }
return proto + app.ExternalDomain(gc) + path return proto + ExternalDomain(gc) + path
} }
func (app *appContext) loadConfig() error { // 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 var err error
app.config, err = ini.ShadowLoad(app.configPath) config := &Config{}
config.File, err = ini.ShadowLoad(configPathOrContents)
if err != nil { if err != nil {
return err return config, err
} }
// URLs // URLs
app.MustSetURLPath("ui", "url_base", "") config.MustSetURLPath("ui", "url_base", "")
app.MustSetURLPath("url_paths", "admin", "") config.MustSetURLPath("url_paths", "admin", "")
app.MustSetURLPath("url_paths", "user_page", "/my/account") config.MustSetURLPath("url_paths", "user_page", "/my/account")
app.MustSetURLPath("url_paths", "form", "/invite") config.MustSetURLPath("url_paths", "form", "/invite")
PAGES.Base = FormatSubpath(app.config.Section("ui").Key("url_base").String(), true) PAGES.Base = FormatSubpath(config.Section("ui").Key("url_base").String(), true)
PAGES.Admin = FormatSubpath(app.config.Section("url_paths").Key("admin").String(), true) PAGES.Admin = FormatSubpath(config.Section("url_paths").Key("admin").String(), true)
PAGES.MyAccount = FormatSubpath(app.config.Section("url_paths").Key("user_page").String(), true) PAGES.MyAccount = FormatSubpath(config.Section("url_paths").Key("user_page").String(), true)
PAGES.Form = FormatSubpath(app.config.Section("url_paths").Key("form").String(), true) PAGES.Form = FormatSubpath(config.Section("url_paths").Key("form").String(), true)
if !(app.config.Section("user_page").Key("enabled").MustBool(true)) { if !(config.Section("user_page").Key("enabled").MustBool(true)) {
PAGES.MyAccount = "disabled" PAGES.MyAccount = "disabled"
} }
if PAGES.Base == PAGES.Form || PAGES.Base == "/accounts" || PAGES.Base == "/settings" || PAGES.Base == "/activity" { if PAGES.Base == PAGES.Form || PAGES.Base == "/accounts" || PAGES.Base == "/settings" || PAGES.Base == "/activity" {
app.err.Printf(lm.BadURLBase, PAGES.Base) logs.err.Printf(lm.BadURLBase, PAGES.Base)
} }
app.info.Printf(lm.SubpathBlockMessage, PAGES.Base, PAGES.Admin, PAGES.MyAccount, PAGES.Form) logs.info.Printf(lm.SubpathBlockMessage, PAGES.Base, PAGES.Admin, PAGES.MyAccount, PAGES.Form)
app.MustCorrectURL("jellyfin", "server", "") config.MustCorrectURL("jellyfin", "server", "")
app.MustCorrectURL("jellyfin", "public_server", app.config.Section("jellyfin").Key("server").String()) config.MustCorrectURL("jellyfin", "public_server", config.Section("jellyfin").Key("server").String())
app.MustCorrectURL("ui", "redirect_url", app.config.Section("jellyfin").Key("public_server").String()) config.MustCorrectURL("ui", "redirect_url", config.Section("jellyfin").Key("public_server").String())
for _, key := range app.config.Section("files").Keys() { for _, key := range config.Section("files").Keys() {
if name := key.Name(); name != "html_templates" && name != "lang_files" { 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"} { 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"} { 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"))))
} }
// If true, app.ExternalDomain() will return one based on the reported Host (ideally reported in "Host" or "X-Forwarded-Host" by the reverse proxy), falling back to app.externalDomain if not set. // 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.
app.UseProxyHost = app.config.Section("ui").Key("use_proxy_host").MustBool(false) UseProxyHost = config.Section("ui").Key("use_proxy_host").MustBool(false)
app.externalURI = strings.TrimSuffix(strings.TrimSuffix(app.config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/") externalURI = strings.TrimSuffix(strings.TrimSuffix(config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/")
if !strings.HasSuffix(app.externalURI, PAGES.Base) { if !strings.HasSuffix(externalURI, PAGES.Base) {
app.err.Println(lm.NoURLSuffix) logs.err.Println(lm.NoURLSuffix)
} }
if app.externalURI == "" { if externalURI == "" {
if app.UseProxyHost { if UseProxyHost {
app.err.Println(lm.NoExternalHost + lm.LoginWontSave + lm.SetExternalHostDespiteUseProxyHost) logs.err.Println(lm.NoExternalHost + lm.LoginWontSave + lm.SetExternalHostDespiteUseProxyHost)
} else { } else {
app.err.Println(lm.NoExternalHost + lm.LoginWontSave) logs.err.Println(lm.NoExternalHost + lm.LoginWontSave)
} }
} }
u, err := url.Parse(app.externalURI) u, err := url.Parse(externalURI)
if err == nil { 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") // FIXME: Remove all these, eventually
app.MustSetValue("password_resets", "email_text", "jfa-go:"+"email.txt") // config.MustSetValue("password_resets", "email_html", "jfa-go:"+"email.html")
// config.MustSetValue("password_resets", "email_text", "jfa-go:"+"email.txt")
app.MustSetValue("invite_emails", "email_html", "jfa-go:"+"invite-email.html") // config.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_text", "jfa-go:"+"invite-email.txt")
app.MustSetValue("email_confirmation", "email_html", "jfa-go:"+"confirmation.html") // config.MustSetValue("email_confirmation", "email_html", "jfa-go:"+"confirmation.html")
app.MustSetValue("email_confirmation", "email_text", "jfa-go:"+"confirmation.txt") // config.MustSetValue("email_confirmation", "email_text", "jfa-go:"+"confirmation.txt")
app.MustSetValue("notifications", "expiry_html", "jfa-go:"+"expired.html") // config.MustSetValue("notifications", "expiry_html", "jfa-go:"+"expired.html")
app.MustSetValue("notifications", "expiry_text", "jfa-go:"+"expired.txt") // config.MustSetValue("notifications", "expiry_text", "jfa-go:"+"expired.txt")
app.MustSetValue("notifications", "created_html", "jfa-go:"+"created.html") // config.MustSetValue("notifications", "created_html", "jfa-go:"+"created.html")
app.MustSetValue("notifications", "created_text", "jfa-go:"+"created.txt") // config.MustSetValue("notifications", "created_text", "jfa-go:"+"created.txt")
app.MustSetValue("deletion", "email_html", "jfa-go:"+"deleted.html") // config.MustSetValue("deletion", "email_html", "jfa-go:"+"deleted.html")
app.MustSetValue("deletion", "email_text", "jfa-go:"+"deleted.txt") // config.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, "/"), "!"))
// Deletion template is good enough for these as well. // Deletion template is good enough for these as well.
app.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html") // config.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html")
app.MustSetValue("disable_enable", "disabled_text", "jfa-go:"+"deleted.txt") // config.MustSetValue("disable_enable", "disabled_text", "jfa-go:"+"deleted.txt")
app.MustSetValue("disable_enable", "enabled_html", "jfa-go:"+"deleted.html") // config.MustSetValue("disable_enable", "enabled_html", "jfa-go:"+"deleted.html")
app.MustSetValue("disable_enable", "enabled_text", "jfa-go:"+"deleted.txt") // config.MustSetValue("disable_enable", "enabled_text", "jfa-go:"+"deleted.txt")
app.MustSetValue("welcome_email", "email_html", "jfa-go:"+"welcome.html") // config.MustSetValue("welcome_email", "email_html", "jfa-go:"+"welcome.html")
app.MustSetValue("welcome_email", "email_text", "jfa-go:"+"welcome.txt") // config.MustSetValue("welcome_email", "email_text", "jfa-go:"+"welcome.txt")
app.MustSetValue("template_email", "email_html", "jfa-go:"+"template.html") // config.MustSetValue("template_email", "email_html", "jfa-go:"+"template.html")
app.MustSetValue("template_email", "email_text", "jfa-go:"+"template.txt") // config.MustSetValue("template_email", "email_text", "jfa-go:"+"template.txt")
app.MustSetValue("user_expiry", "behaviour", "disable_user") config.MustSetValue("user_expiry", "behaviour", "disable_user")
app.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html") // config.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", "email_text", "jfa-go:"+"user-expired.txt")
app.MustSetValue("user_expiry", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html") // config.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_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") fnameSettingSuffix := []string{"html", "text"}
app.MustSetValue("matrix", "show_on_reg", "true") 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") config.MustSetValue("activity_log", "keep_n_records", "1000")
app.MustSetValue("backups", "path", filepath.Join(app.dataPath, "backups")) config.MustSetValue("activity_log", "delete_after_days", "90")
app.MustSetValue("backups", "keep_n_backups", "20")
app.MustSetValue("backups", "keep_previous_version_backup", "true")
app.config.Section("jellyfin").Key("version").SetValue(version) sc := config.Section("discord").Key("start_command").MustString("start")
app.config.Section("jellyfin").Key("device").SetValue("jfa-go") config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
app.MustSetValue("jellyfin", "cache_timeout", "30") config.MustSetValue("email", "collect", "true")
app.MustSetValue("jellyfin", "web_cache_async_timeout", "1") collect := config.Section("email").Key("collect").MustBool(true)
app.MustSetValue("jellyfin", "web_cache_sync_timeout", "10") 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))
LOGIP = app.config.Section("advanced").Key("log_ips").MustBool(false) config.MustSetValue("matrix", "topic", "Jellyfin notifications")
LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false) config.MustSetValue("matrix", "show_on_reg", "true")
app.MustSetValue("advanced", "auth_retry_count", "6") config.MustSetValue("discord", "show_on_reg", "true")
app.MustSetValue("advanced", "auth_retry_gap", "10")
app.MustSetValue("ui", "port", "8056") config.MustSetValue("telegram", "show_on_reg", "true")
app.MustSetValue("advanced", "tls_port", "8057")
app.MustSetValue("advanced", "value_log_size", "512") 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"} pwrMethods := []string{"allow_pwr_username", "allow_pwr_email", "allow_pwr_contact_method"}
allDisabled := true allDisabled := true
for _, v := range pwrMethods { 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 allDisabled = false
} }
} }
if allDisabled { if allDisabled {
app.info.Println(lm.EnableAllPWRMethods) logs.info.Println(lm.EnableAllPWRMethods)
for _, v := range pwrMethods { 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) messagesEnabled = config.Section("messages").Key("enabled").MustBool(false)
telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false) telegramEnabled = config.Section("telegram").Key("enabled").MustBool(false)
discordEnabled = app.config.Section("discord").Key("enabled").MustBool(false) discordEnabled = config.Section("discord").Key("enabled").MustBool(false)
matrixEnabled = app.config.Section("matrix").Key("enabled").MustBool(false) matrixEnabled = config.Section("matrix").Key("enabled").MustBool(false)
if !messagesEnabled { if !messagesEnabled {
emailEnabled = false emailEnabled = false
telegramEnabled = false telegramEnabled = false
discordEnabled = false discordEnabled = false
matrixEnabled = false matrixEnabled = false
} else if app.config.Section("email").Key("method").MustString("") == "" { } else if config.Section("email").Key("method").MustString("") == "" {
emailEnabled = false emailEnabled = false
} else { } else {
emailEnabled = true emailEnabled = true
@@ -305,31 +335,64 @@ func (app *appContext) loadConfig() error {
messagesEnabled = false messagesEnabled = false
} }
if app.proxyEnabled = app.config.Section("advanced").Key("proxy").MustBool(false); app.proxyEnabled { if proxyEnabled := config.Section("advanced").Key("proxy").MustBool(false); proxyEnabled {
app.proxyConfig = easyproxy.ProxyConfig{} config.proxyConfig = &easyproxy.ProxyConfig{}
app.proxyConfig.Protocol = easyproxy.HTTP config.proxyConfig.Protocol = easyproxy.HTTP
if strings.Contains(app.config.Section("advanced").Key("proxy_protocol").MustString("http"), "socks") { if strings.Contains(config.Section("advanced").Key("proxy_protocol").MustString("http"), "socks") {
app.proxyConfig.Protocol = easyproxy.SOCKS5 config.proxyConfig.Protocol = easyproxy.SOCKS5
} }
app.proxyConfig.Addr = app.config.Section("advanced").Key("proxy_address").MustString("") config.proxyConfig.Addr = config.Section("advanced").Key("proxy_address").MustString("")
app.proxyConfig.User = app.config.Section("advanced").Key("proxy_user").MustString("") config.proxyConfig.User = config.Section("advanced").Key("proxy_user").MustString("")
app.proxyConfig.Password = app.config.Section("advanced").Key("proxy_password").MustString("") config.proxyConfig.Password = config.Section("advanced").Key("proxy_password").MustString("")
app.proxyTransport, err = easyproxy.NewTransport(app.proxyConfig) config.proxyTransport, err = easyproxy.NewTransport(*(config.proxyConfig))
if err != nil { 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, // As explained in lm.FailedInitProxy, sleep here might grab the admin's attention,
// Since we don't crash on this failing. // Since we don't crash on this failing.
time.Sleep(15 * time.Second) time.Sleep(15 * time.Second)
app.proxyEnabled = false config.proxyConfig = nil
config.proxyTransport = nil
} else { } else {
app.proxyEnabled = true logs.info.Printf(lm.InitProxy, config.proxyConfig.Addr)
app.info.Printf(lm.InitProxy, app.proxyConfig.Addr)
} }
} }
app.MustSetValue("updates", "enabled", "true") config.MustSetValue("updates", "enabled", "true")
releaseChannel := app.config.Section("updates").Key("channel").String()
if app.config.Section("updates").Key("enabled").MustBool(false) { 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 v := version
if releaseChannel == "stable" { if releaseChannel == "stable" {
if version == "git" { if version == "git" {
@@ -338,9 +401,9 @@ func (app *appContext) loadConfig() error {
} else if releaseChannel == "unstable" { } else if releaseChannel == "unstable" {
v = "git" v = "git"
} }
app.updater = newUpdater(baseURL, namespace, repo, v, commit, updater) app.updater = NewUpdater(baseURL, namespace, repo, v, commit, updater)
if app.proxyEnabled { if config.proxyTransport != nil {
app.updater.SetTransport(app.proxyTransport) app.updater.SetTransport(config.proxyTransport)
} }
} }
if releaseChannel == "" { if releaseChannel == "" {
@@ -349,32 +412,22 @@ func (app *appContext) loadConfig() error {
} else { } else {
releaseChannel = "stable" releaseChannel = "stable"
} }
app.MustSetValue("updates", "channel", releaseChannel) config.MustSetValue("updates", "channel", releaseChannel)
} }
substituteStrings = app.config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("") app.email = NewEmailer(config, app.storage, app.LoggerSet)
if substituteStrings != "" {
v := app.config.Section("ui").Key("success_message")
v.SetValue(strings.ReplaceAll(v.String(), "Jellyfin", substituteStrings))
} }
oldFormLang := app.config.Section("ui").Key("language").MustString("") func (app *appContext) ReloadConfig() {
if oldFormLang != "" { var err error = nil
app.storage.lang.chosenUserLang = oldFormLang app.config, err = NewConfig(app.configPath, app.dataPath, app.LoggerSet)
if err != nil {
app.err.Fatalf(lm.FailedLoadConfig, app.configPath, err)
} }
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) app.config.ReloadDependents(app)
app.info.Printf(lm.LoadConfig, app.configPath)
return nil
} }
func (app *appContext) PatchConfigBase() { func (app *appContext) PatchConfigBase() {
+130 -35
View File
@@ -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: sections:
- section: updates - section: updates
meta: meta:
@@ -516,7 +573,7 @@ sections:
meta: meta:
name: Captcha name: Captcha
description: Settings related to user creation CAPTCHAs. 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: settings:
- setting: enabled - setting: enabled
name: Enabled name: Enabled
@@ -670,7 +727,7 @@ sections:
meta: meta:
name: Messages/Notifications name: Messages/Notifications
description: General settings for emails/messages. 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: settings:
- setting: enabled - setting: enabled
name: Enabled name: Enabled
@@ -719,9 +776,27 @@ sections:
- ["en-us", "English (US)"] - ["en-us", "English (US)"]
value: en-us value: en-us
description: Default email language. Submit a PR on github if you'd like to translate. 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 - setting: no_username
name: Use email addresses as username name: Use email addresses as username
depends_true: method depends_true: collect
type: bool type: bool
value: false value: false
description: Use email address from invite form as username on Jellyfin. description: Use email address from invite form as username on Jellyfin.
@@ -733,6 +808,7 @@ sections:
- ["smtp", "SMTP"] - ["smtp", "SMTP"]
- ["mailgun", "Mailgun"] - ["mailgun", "Mailgun"]
value: smtp value: smtp
depends_true: messages|enabled
description: Method of sending email to use. description: Method of sending email to use.
- setting: address - setting: address
name: Sent from (address) name: Sent from (address)
@@ -753,25 +829,6 @@ sections:
type: bool type: bool
value: false value: false
description: Send emails as plain text instead of HTML. 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 - setting: test_note
name: 'Test your settings:' name: 'Test your settings:'
type: note type: note
@@ -780,7 +837,7 @@ sections:
description: Go over to the accounts tab, select your user (ensuring you've assigned it an email address) and send yourself an announcement. 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 - section: mailgun
meta: meta:
name: Mailgun (Email) name: Mailgun
description: Mailgun API connection settings description: Mailgun API connection settings
depends_true: email|method depends_true: email|method
settings: settings:
@@ -794,7 +851,7 @@ sections:
value: your api key value: your api key
- section: smtp - section: smtp
meta: meta:
name: SMTP (Email) name: SMTP
description: SMTP Server connection settings. description: SMTP Server connection settings.
depends_true: email|method depends_true: email|method
settings: settings:
@@ -860,7 +917,7 @@ sections:
meta: meta:
name: Discord name: Discord
description: Settings for Discord invites/signup/notifications 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: settings:
- setting: enabled - setting: enabled
name: Enabled name: Enabled
@@ -924,7 +981,7 @@ sections:
requires_restart: true requires_restart: true
depends_true: provide_invite depends_true: provide_invite
type: text type: text
description: Channel to invite new users to. description: Name of channel to invite new users to.
- setting: apply_role - setting: apply_role
name: Apply Role on connection name: Apply Role on connection
requires_restart: true requires_restart: true
@@ -955,7 +1012,7 @@ sections:
name: Telegram name: Telegram
description: Settings for Telegram signup/notifications. See the jfa-go wiki for description: Settings for Telegram signup/notifications. See the jfa-go wiki for
info on setting this up. 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: settings:
- setting: enabled - setting: enabled
name: Enabled name: Enabled
@@ -1004,7 +1061,7 @@ sections:
name: Matrix name: Matrix
description: Settings for Matrix invites/signup/notifications. See the jfa-go description: Settings for Matrix invites/signup/notifications. See the jfa-go
wiki for info on setting this up. 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: settings:
- setting: enabled - setting: enabled
name: Enabled name: Enabled
@@ -1236,7 +1293,7 @@ sections:
description: Path to custom email text template for announcements/custom messages. description: Path to custom email text template for announcements/custom messages.
- section: notifications - section: notifications
meta: meta:
name: Admin invite notifications name: Admin notifications
description: Allows toggling "user created" and "invite expired" notifications description: Allows toggling "user created" and "invite expired" notifications
to be sent to the admin per-invite. to be sent to the admin per-invite.
depends_true: messages|enabled depends_true: messages|enabled
@@ -1276,13 +1333,13 @@ sections:
description: Path to user creation notification email in plaintext. description: Path to user creation notification email in plaintext.
- section: ombi - section: ombi
meta: meta:
name: Ombi Integration name: Ombi
description: Connect to Ombi to automatically create both Ombi and Jellyfin accounts 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 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 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 > User Profiles. To handle password resets for Ombi & Jellyfin, enable "Use
reset link instead of PIN". 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: settings:
- setting: enabled - setting: enabled
name: Enabled name: Enabled
@@ -1305,12 +1362,16 @@ sections:
description: API Key. Get this from the first tab in Ombi settings. description: API Key. Get this from the first tab in Ombi settings.
- section: jellyseerr - section: jellyseerr
meta: meta:
name: Jellyseerr Integration name: Jellyseerr
description: Connect to Jellyseerr to automatically trigger the import of users description: Connect to Jellyseerr to automatically trigger the import of users
on account creation, and to automatically link contact methods (email, discord 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 and telegram). A template must be added to a User Profile for accounts to be
created. created.
wiki_link: https://wiki.jfa-go.com/docs/external-services/jellyseerr/ wiki_link: https://wiki.jfa-go.com/docs/external-services/jellyseerr/
aliases:
- Jellyseerr
- Overseerr
- Seerr
settings: settings:
- setting: enabled - setting: enabled
name: Enabled name: Enabled
@@ -1346,6 +1407,7 @@ sections:
depends_true: enabled depends_true: enabled
description: Existing users (and those created outside jfa-go) will have their description: Existing users (and those created outside jfa-go) will have their
contact info imported to Jellyseerr. contact info imported to Jellyseerr.
deprecated: true
- setting: constraints_note - setting: constraints_note
name: 'Unique Emails:' name: 'Unique Emails:'
type: note type: note
@@ -1425,7 +1487,7 @@ sections:
name: Email confirmation name: Email confirmation
description: If enabled, a user will be sent an email confirmation link to ensure description: If enabled, a user will be sent an email confirmation link to ensure
their password is right before they can make an account. their password is right before they can make an account.
depends_true: email|method depends_true: email|collect
settings: settings:
- setting: enabled - setting: enabled
name: Enabled name: Enabled
@@ -1448,7 +1510,7 @@ sections:
description: Path to custom email in plain text description: Path to custom email in plain text
- section: user_expiry - section: user_expiry
meta: meta:
name: User Expiry name: Account Expiry
description: When set on an invite, users will be deleted or disabled a specified 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 amount of time after they create their account. Expiries can also be set and
extended for invididual users, optionally with a message why. extended for invididual users, optionally with a message why.
@@ -1474,6 +1536,10 @@ sections:
value: true value: true
depends_true: messages|enabled depends_true: messages|enabled
description: Send an email when a user's account expires. 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 - setting: subject
name: Email subject name: Email subject
depends_true: messages|enabled depends_true: messages|enabled
@@ -1509,6 +1575,23 @@ sections:
depends_true: messages|enabled depends_true: messages|enabled
type: text type: text
description: Path to custom email in plain 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 - section: disable_enable
meta: meta:
name: Account Disabling/Enabling name: Account Disabling/Enabling
@@ -1569,7 +1652,7 @@ sections:
description: jfa-go will send a POST request to these URLs when an event occurs, 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 with relevant information. Request information is logged when debug logging
is enabled. is enabled.
wiki_link: https://wiki.jfa-go.com/docs/webhooks/ wiki_link: https://wiki.jfa-go.com/docs/dev/webhooks/
settings: settings:
- setting: created - setting: created
name: User Created name: User Created
@@ -1587,30 +1670,36 @@ sections:
requires_restart: true requires_restart: true
type: text type: text
description: Location of stored invites (json). description: Location of stored invites (json).
deprecated: true
- setting: password_resets - setting: password_resets
name: Password Resets name: Password Resets
requires_restart: true requires_restart: true
type: text type: text
description: Location of stored non-Jellyfin password resets (json). description: Location of stored non-Jellyfin password resets (json).
deprecated: true
- setting: emails - setting: emails
name: Email Addresses name: Email Addresses
requires_restart: true requires_restart: true
type: text type: text
description: Location of stored email addresses (json). description: Location of stored email addresses (json).
deprecated: true
- setting: users - setting: users
name: User storage name: User storage
type: text type: text
description: Stores users temporarily when a user expiry is set. description: Stores users temporarily when a user expiry is set.
deprecated: true
- setting: ombi_template - setting: ombi_template
name: Ombi user template name: Ombi user template
type: text type: text
description: Location of stored Ombi user template. description: Location of stored Ombi user template.
deprecated: true
- setting: user_profiles - setting: user_profiles
name: User Profiles name: User Profiles
requires_restart: true requires_restart: true
type: text type: text
description: Location of stored user profiles (encompasses template and configuration description: Location of stored user profiles (encompasses template and configuration
and displayprefs) (json) and displayprefs) (json)
deprecated: true
- setting: html_templates - setting: html_templates
name: Custom HTML Template Directory name: Custom HTML Template Directory
requires_restart: true requires_restart: true
@@ -1628,19 +1717,23 @@ sections:
type: text type: text
description: JSON file generated by program in settings, different from email_html/email_text. description: JSON file generated by program in settings, different from email_html/email_text.
See wiki for more info. See wiki for more info.
deprecated: true
- setting: custom_user_page_content - setting: custom_user_page_content
name: Custom user page content name: Custom user page content
type: text type: text
description: JSON file generated by program in settings, containing user page description: JSON file generated by program in settings, containing user page
messages. See wiki for more info. messages. See wiki for more info.
deprecated: true
- setting: telegram_users - setting: telegram_users
name: Telegram users name: Telegram users
type: text type: text
description: Stores telegram user IDs and language preferences. description: Stores telegram user IDs and language preferences.
deprecated: true
- setting: matrix_users - setting: matrix_users
name: Matrix users name: Matrix users
type: text type: text
description: Stores matrix user IDs and language preferences. description: Stores matrix user IDs and language preferences.
deprecated: true
- setting: matrix_sql - setting: matrix_sql
name: Matrix encryption DB name: Matrix encryption DB
type: text type: text
@@ -1649,7 +1742,9 @@ sections:
name: Discord users name: Discord users
type: text type: text
description: Stores discord user IDs and language preferences. description: Stores discord user IDs and language preferences.
deprecated: true
- setting: announcements - setting: announcements
name: Announcement templates name: Announcement templates
type: text type: text
description: Stores custom announcement templates. description: Stores custom announcement templates.
deprecated: true
+5 -12
View File
@@ -221,15 +221,8 @@ sup.\~critical, .text-critical {
padding-bottom: 0.1rem; padding-bottom: 0.1rem;
} }
.settings-section-button {
width: 100%;
height: 2.5rem;
}
.settings-section-button:hover, .settings-section-button:focus { .settings-section-button:hover, .settings-section-button:focus {
box-sizing: border-box; box-sizing: border-box;
width: 100%;
height: 2.5rem;
background-color: var(--color-neutral-normal-fill); background-color: var(--color-neutral-normal-fill);
filter: brightness(var(--settings-section-button-filter)) !important; filter: brightness(var(--settings-section-button-filter)) !important;
} }
@@ -242,7 +235,7 @@ sup.\~critical, .text-critical {
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.textarea { .textarea:not(code-input *) {
resize: vertical; resize: vertical;
} }
@@ -254,7 +247,7 @@ sup.\~critical, .text-critical {
overflow-y: visible; overflow-y: visible;
} }
select, textarea { select, textarea:not(code-input *) {
color: inherit; color: inherit;
border: 0 solid var(--color-neutral-300); border: 0 solid var(--color-neutral-300);
appearance: none; appearance: none;
@@ -262,7 +255,7 @@ select, textarea {
-moz-appearance: none; -moz-appearance: none;
} }
html.dark textarea { html.dark textarea:not(code-input *) {
background-color: #202020 background-color: #202020
} }
@@ -320,7 +313,7 @@ p.top {
bottom: 115%; bottom: 115%;
} }
pre { pre:not(code-input *) {
white-space: pre-wrap; /* css-3 */ white-space: pre-wrap; /* css-3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */ white-space: -pre-wrap; /* Opera 4-6 */
@@ -466,7 +459,7 @@ section.section:not(.\~neutral) {
@layer components { @layer components {
.switch { .switch {
@apply flex flex-row gap-1 items-center; @apply flex flex-row gap-2 items-center;
} }
} }
+18
View File
@@ -0,0 +1,18 @@
const colors = require("tailwindcss/colors");
const dark = require("../css/dark");
export const colorSet = {
neutral: colors.slate,
positive: colors.green,
urge: colors.violet,
warning: colors.yellow,
info: colors.blue,
critical: colors.red,
d_neutral: dark.d_neutral,
d_positive: dark.d_positive,
d_urge: dark.d_urge,
d_warning: dark.d_warning,
d_info: dark.d_info,
d_critical: dark.d_critical,
discord: "#5865F2"
};
+8 -5
View File
@@ -6,7 +6,7 @@
.tooltip .content { .tooltip .content {
visibility: hidden; visibility: hidden;
opacity: 0; opacity: 0;
max-width: 10rem; max-width: 16rem;
min-width: 6rem; min-width: 6rem;
background-color: rgba(0, 0, 0, 0.6); background-color: rgba(0, 0, 0, 0.6);
color: #fff; color: #fff;
@@ -22,15 +22,18 @@
} }
.tooltip.below .content { .tooltip.below .content {
top: 2.5rem; top: calc(100% + 0.125rem);
left: 0; left: 50%;
right: 0; right: 0;
transform: translateX(-50%);
} }
.tooltip.above .content { .tooltip.above .content {
bottom: 2.5rem; top: unset;
left: 0; bottom: calc(100% + 0.125rem);
left: 50%;
right: 0; right: 0;
transform: translateX(-50%);
} }
.tooltip.darker .content { .tooltip.darker .content {
+403
View File
@@ -0,0 +1,403 @@
package main
import (
"fmt"
"maps"
"slices"
)
func defaultVars(vars ...string) []string {
return slices.Concat(vars, []string{
"username",
})
}
func defaultVals(vals map[string]any) map[string]any {
maps.Copy(vals, map[string]any{
"username": "Username",
})
return vals
}
func vendorHeader(config *Config, lang *emailLang) string { return "jfa-go" }
func serverHeader(config *Config, lang *emailLang) string {
if substituteStrings == "" {
return "Jellyfin"
} else {
return substituteStrings
}
}
func messageFooter(config *Config, lang *emailLang) string {
return config.Section("messages").Key("message").String()
}
var customContent = map[string]CustomContentInfo{
"EmailConfirmation": {
Name: "EmailConfirmation",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].EmailConfirmation["name"] },
Subject: func(config *Config, lang *emailLang) string {
return config.Section("email_confirmation").Key("subject").MustString(lang.EmailConfirmation.get("title"))
},
Variables: defaultVars(
"confirmationURL",
),
Placeholders: defaultVals(map[string]any{
"confirmationURL": "https://sub2.test.url/invite/xxxxxx?key=xxxxxx",
}),
SourceFile: ContentSourceFileInfo{
Section: "email_confirmation",
SettingPrefix: "email_",
DefaultValue: "confirmation",
},
},
"ExpiryReminder": {
Name: "ExpiryReminder",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].ExpiryReminder["name"] },
Subject: func(config *Config, lang *emailLang) string {
return config.Section("user_expiry").Key("reminder_subject").MustString(lang.ExpiryReminder.get("title"))
},
Variables: defaultVars(
"expiresIn",
"date",
"time",
),
Placeholders: defaultVals(map[string]any{
"expiresIn": "3d 4h 32m",
"date": "20/08/25",
"time": "14:19",
}),
SourceFile: ContentSourceFileInfo{
Section: "user_expiry",
SettingPrefix: "reminder_email_",
DefaultValue: "expiry-reminder",
},
},
"InviteEmail": {
Name: "InviteEmail",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].InviteEmail["name"] },
Subject: func(config *Config, lang *emailLang) string {
return config.Section("invite_emails").Key("subject").MustString(lang.InviteEmail.get("title"))
},
Variables: []string{
"date",
"time",
"expiresInMinutes",
"inviteURL",
},
Placeholders: defaultVals(map[string]any{
"date": "01/01/01",
"time": "00:00",
"expiresInMinutes": "16d 13h 19m",
"inviteURL": "https://sub2.test.url/invite/xxxxxx",
}),
SourceFile: ContentSourceFileInfo{
Section: "invite_emails",
SettingPrefix: "email_",
DefaultValue: "invite-email",
},
},
"InviteExpiry": {
Name: "InviteExpiry",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].InviteExpiry["name"] },
Subject: func(config *Config, lang *emailLang) string {
return lang.InviteExpiry.get("title")
},
HeaderText: vendorHeader,
FooterText: func(config *Config, lang *emailLang) string {
return lang.InviteExpiry.get("notificationNotice")
},
Variables: []string{
"code",
"time",
},
Placeholders: map[string]any{
"code": "\"xxxxxx\"",
"time": "01/01/01 00:00",
},
SourceFile: ContentSourceFileInfo{
Section: "notifications",
SettingPrefix: "expiry_",
DefaultValue: "expired",
},
},
"PasswordReset": {
Name: "PasswordReset",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].PasswordReset["name"] },
Subject: func(config *Config, lang *emailLang) string {
return config.Section("password_resets").Key("subject").MustString(lang.PasswordReset.get("title"))
},
Variables: defaultVars(
"date",
"time",
"expiresInMinutes",
"pin",
),
Placeholders: defaultVals(map[string]any{
"date": "01/01/01",
"time": "00:00",
"expiresInMinutes": "16d 13h 19m",
"pin": "12-34-56",
}),
SourceFile: ContentSourceFileInfo{
Section: "password_resets",
SettingPrefix: "email_",
// This was the first email type added, hence the undescriptive filename.
DefaultValue: "password-reset",
},
},
"UserCreated": {
Name: "UserCreated",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserCreated["name"] },
Subject: func(config *Config, lang *emailLang) string {
return lang.UserCreated.get("title")
},
HeaderText: vendorHeader,
FooterText: func(config *Config, lang *emailLang) string {
return lang.UserCreated.get("notificationNotice")
},
Variables: []string{
"code",
"name",
"address",
"time",
},
Placeholders: map[string]any{
"name": "Subject Username",
"code": "\"xxxxxx\"",
"address": "Email Address",
"time": "01/01/01 00:00",
},
SourceFile: ContentSourceFileInfo{
Section: "notifications",
SettingPrefix: "created_",
DefaultValue: "created",
},
},
"UserDeleted": {
Name: "UserDeleted",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserDeleted["name"] },
Subject: func(config *Config, lang *emailLang) string {
return config.Section("deletion").Key("subject").MustString(lang.UserDeleted.get("title"))
},
Variables: defaultVars(
"reason",
),
Placeholders: defaultVals(map[string]any{
"reason": "Reason",
}),
SourceFile: ContentSourceFileInfo{
Section: "deletion",
SettingPrefix: "email_",
DefaultValue: "deleted",
},
},
"UserDisabled": {
Name: "UserDisabled",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserDisabled["name"] },
Subject: func(config *Config, lang *emailLang) string {
return config.Section("disable_enable").Key("subject_disabled").MustString(lang.UserDisabled.get("title"))
},
Variables: defaultVars(
"reason",
),
Placeholders: defaultVals(map[string]any{
"reason": "Reason",
}),
SourceFile: ContentSourceFileInfo{
Section: "disable_enable",
SettingPrefix: "disabled_",
// Template is shared between deletion enabling and disabling.
DefaultValue: "deleted",
},
},
"UserEnabled": {
Name: "UserEnabled",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserEnabled["name"] },
Subject: func(config *Config, lang *emailLang) string {
return config.Section("disable_enable").Key("subject_enabled").MustString(lang.UserEnabled.get("title"))
},
Variables: defaultVars(
"reason",
),
Placeholders: defaultVals(map[string]any{
"reason": "Reason",
}),
SourceFile: ContentSourceFileInfo{
Section: "disable_enable",
SettingPrefix: "enabled_",
// Template is shared between deletion enabling and disabling.
DefaultValue: "deleted",
},
},
"UserExpired": {
Name: "UserExpired",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserExpired["name"] },
Subject: func(config *Config, lang *emailLang) string {
return config.Section("user_expiry").Key("subject").MustString(lang.UserExpired.get("title"))
},
Variables: defaultVars(),
Placeholders: defaultVals(map[string]any{}),
SourceFile: ContentSourceFileInfo{
Section: "user_expiry",
SettingPrefix: "email_",
DefaultValue: "user-expired",
},
},
"UserExpiryAdjusted": {
Name: "UserExpiryAdjusted",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].UserExpiryAdjusted["name"] },
Subject: func(config *Config, lang *emailLang) string {
return config.Section("user_expiry").Key("adjustment_subject").MustString(lang.UserExpiryAdjusted.get("title"))
},
Variables: defaultVars(
"newExpiry",
"reason",
),
Placeholders: defaultVals(map[string]any{
"newExpiry": "01/01/01 00:00",
"reason": "Reason",
}),
SourceFile: ContentSourceFileInfo{
Section: "user_expiry",
SettingPrefix: "adjustment_email_",
DefaultValue: "expiry-adjusted",
},
},
"WelcomeEmail": {
Name: "WelcomeEmail",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string { return dict.Email[lang].WelcomeEmail["name"] },
Subject: func(config *Config, lang *emailLang) string {
return config.Section("welcome_email").Key("subject").MustString(lang.WelcomeEmail.get("title"))
},
Variables: defaultVars(
"jellyfinURL",
"yourAccountWillExpire",
),
Conditionals: []string{
"yourAccountWillExpire",
},
Placeholders: defaultVals(map[string]any{
"jellyfinURL": "https://example.io",
"yourAccountWillExpire": "17/08/25 14:19",
}),
SourceFile: ContentSourceFileInfo{
Section: "welcome_email",
SettingPrefix: "email_",
DefaultValue: "welcome",
},
},
"TemplateEmail": {
Name: "TemplateEmail",
DisplayName: func(dict *Lang, lang string) string {
return "EmptyCustomContent"
},
ContentType: CustomTemplate,
SourceFile: ContentSourceFileInfo{
Section: "template_email",
SettingPrefix: "email_",
DefaultValue: "template",
},
},
"UserLogin": {
Name: "UserLogin",
ContentType: CustomCard,
DisplayName: func(dict *Lang, lang string) string {
if _, ok := dict.Admin[lang]; !ok {
lang = dict.chosenAdminLang
}
return dict.Admin[lang].Strings["userPageLogin"]
},
Variables: []string{},
},
"UserPage": {
Name: "UserPage",
ContentType: CustomCard,
DisplayName: func(dict *Lang, lang string) string {
if _, ok := dict.Admin[lang]; !ok {
lang = dict.chosenAdminLang
}
return dict.Admin[lang].Strings["userPagePage"]
},
Variables: defaultVars(),
Placeholders: defaultVals(map[string]any{}),
},
"PostSignupCard": {
Name: "PostSignupCard",
ContentType: CustomCard,
DisplayName: func(dict *Lang, lang string) string {
if _, ok := dict.Admin[lang]; !ok {
lang = dict.chosenAdminLang
}
return dict.Admin[lang].Strings["postSignupCard"]
},
Description: func(dict *Lang, lang string) string {
if _, ok := dict.Admin[lang]; !ok {
lang = dict.chosenAdminLang
}
return dict.Admin[lang].Strings["postSignupCardDescription"]
},
Variables: defaultVars(
"myAccountURL",
),
Placeholders: defaultVals(map[string]any{
"myAccountURL": "https://sub2.test.url/my/account",
}),
},
}
var EmptyCustomContent = CustomContentInfo{
Name: "EmptyCustomContent",
ContentType: CustomMessage,
DisplayName: func(dict *Lang, lang string) string {
return "EmptyCustomContent"
},
Subject: func(config *Config, lang *emailLang) string {
return "EmptyCustomContent"
},
HeaderText: serverHeader,
FooterText: messageFooter,
Description: nil,
Variables: []string{},
Placeholders: map[string]any{},
}
var AnnouncementCustomContent = func(subject string) CustomContentInfo {
cci := EmptyCustomContent
cci.Subject = func(config *Config, lang *emailLang) string { return subject }
cci.Variables = defaultVars()
cci.Placeholders = defaultVals(map[string]any{})
return cci
}
// Validates customContent and sets default fields if needed.
var _runtimeValidation = func() bool {
for name, cc := range customContent {
if name != cc.Name {
panic(fmt.Errorf("customContent key and name not matching: %s != %s", name, cc.Name))
}
if cc.DisplayName == nil {
panic(fmt.Errorf("no customContent[%s] DisplayName set", name))
}
if cc.HeaderText == nil {
cc.HeaderText = serverHeader
customContent[name] = cc
}
if cc.FooterText == nil {
cc.FooterText = messageFooter
customContent[name] = cc
}
}
return true
}()
+70 -10
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
dg "github.com/bwmarrin/discordgo" dg "github.com/bwmarrin/discordgo"
"github.com/hrfee/jfa-go/common"
lm "github.com/hrfee/jfa-go/logmessages" lm "github.com/hrfee/jfa-go/logmessages"
"github.com/timshannon/badgerhold/v4" "github.com/timshannon/badgerhold/v4"
) )
@@ -28,6 +29,18 @@ type DiscordDaemon struct {
commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string) commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string)
commandIDs []string commandIDs []string
commandDescriptions []*dg.ApplicationCommand commandDescriptions []*dg.ApplicationCommand
retryOpts *common.MustAuthenticateOptions
}
func EmptyDiscordUser() *DiscordUser {
return &DiscordUser{
ID: "",
Username: "",
Discriminator: "",
Lang: "",
Contact: false,
JellyfinID: "",
}
} }
func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) { func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
@@ -59,6 +72,16 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
dd.users[user.ID] = user 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 return dd, nil
} }
@@ -99,14 +122,28 @@ func (d *DiscordDaemon) MustGetUser(channelID, userID, discrim, username string)
return d.NewUnknownUser(channelID, userID, discrim, username) return d.NewUnknownUser(channelID, userID, discrim, username)
} }
func (d *DiscordDaemon) run() { func (d *DiscordDaemon) Run() {
d.bot.AddHandler(d.commandHandler) 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 { if err := d.bot.Open(); err != nil {
if retry == nil || retry.LogFailures {
d.app.err.Printf(lm.FailedStartDaemon, lm.Discord, err) d.app.err.Printf(lm.FailedStartDaemon, lm.Discord, err)
}
if retry != nil {
retry.Counter += 1
if retry.Counter >= retry.RetryCount {
return return
} }
time.Sleep(retry.RetryGap)
d.run(retry)
return
}
}
// Wait for everything to populate, it's slow sometimes. // Wait for everything to populate, it's slow sometimes.
for d.bot.State == nil { for d.bot.State == nil {
continue continue
@@ -135,15 +172,18 @@ func (d *DiscordDaemon) run() {
d.InviteChannel.Name = invChannel d.InviteChannel.Name = invChannel
} }
} }
err = d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start")) d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start"))
defer d.deregisterCommands() defer d.deregisterCommands()
defer d.bot.Close() defer d.bot.Close()
go d.registerCommands() ro := common.MustAuthenticateOptions{}
ro = *(d.retryOpts)
ro.Counter = 0
go d.registerCommands(&ro)
<-d.ShutdownChannel <-d.ShutdownChannel
d.ShutdownChannel <- "Down" 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. // 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.
@@ -333,7 +373,7 @@ func (d *DiscordDaemon) Shutdown() {
close(d.ShutdownChannel) close(d.ShutdownChannel)
} }
func (d *DiscordDaemon) registerCommands() { func (d *DiscordDaemon) registerCommands(retry *common.MustAuthenticateOptions) {
d.commandDescriptions = []*dg.ApplicationCommand{ d.commandDescriptions = []*dg.ApplicationCommand{
{ {
Name: d.app.config.Section("discord").Key("start_command").MustString("start"), Name: d.app.config.Section("discord").Key("start_command").MustString("start"),
@@ -430,7 +470,27 @@ func (d *DiscordDaemon) registerCommands() {
// if err != nil { // if err != nil {
// d.app.err.Printf("Discord: Cannot create commands: %v", err) // 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) command, err := d.bot.ApplicationCommandCreate(d.bot.State.User.ID, d.guildID, cmd)
if err != nil { if err != nil {
d.app.err.Printf(lm.FailedRegisterDiscordCommand, cmd.Name, err) d.app.err.Printf(lm.FailedRegisterDiscordCommand, cmd.Name, err)
@@ -438,7 +498,7 @@ func (d *DiscordDaemon) registerCommands() {
d.app.debug.Printf(lm.RegisterDiscordCommand, cmd.Name) d.app.debug.Printf(lm.RegisterDiscordCommand, cmd.Name)
d.commandIDs[i] = command.ID d.commandIDs[i] = command.ID
} }
} } */
} }
func (d *DiscordDaemon) deregisterCommands() { func (d *DiscordDaemon) deregisterCommands() {
@@ -686,7 +746,7 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
var msg *Message var msg *Message
if err == nil { if err == nil {
msg, err = d.app.email.constructInvite(invite.Code, invite, d.app, false) msg, err = d.app.email.constructInvite(invite, false)
if err != nil { if err != nil {
// Print extra message, ideally we'd just print this, or get rid of it though. // Print extra message, ideally we'd just print this, or get rid of it though.
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, invite.Code, err) invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, invite.Code, err)
+226 -529
View File
@@ -10,6 +10,7 @@ import (
"html/template" "html/template"
"io" "io"
"io/fs" "io/fs"
"maps"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -41,6 +42,9 @@ type Emailer struct {
fromAddr, fromName string fromAddr, fromName string
lang emailLang lang emailLang
sender EmailClient sender EmailClient
config *Config
storage *Storage
LoggerSet
} }
// Message stores content. // Message stores content.
@@ -51,7 +55,7 @@ type Message struct {
Markdown string `json:"markdown"` 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) d = timefmt.Format(expiry, datePattern)
t = timefmt.Format(expiry, timePattern) t = timefmt.Format(expiry, timePattern)
currentTime := time.Now() currentTime := time.Now()
@@ -73,16 +77,19 @@ func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool, datePattern
} }
// NewEmailer configures and returns a new emailer. // NewEmailer configures and returns a new emailer.
func NewEmailer(app *appContext) *Emailer { func NewEmailer(config *Config, storage *Storage, logs LoggerSet) *Emailer {
emailer := &Emailer{ emailer := &Emailer{
fromAddr: app.config.Section("email").Key("address").String(), fromAddr: config.Section("email").Key("address").String(),
fromName: app.config.Section("email").Key("from").String(), fromName: config.Section("email").Key("from").String(),
lang: app.storage.lang.Email[app.storage.lang.chosenEmailLang], 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" { if method == "smtp" {
enc := sMail.EncryptionSTARTTLS enc := sMail.EncryptionSTARTTLS
switch app.config.Section("smtp").Key("encryption").String() { switch emailer.config.Section("smtp").Key("encryption").String() {
case "ssl_tls": case "ssl_tls":
enc = sMail.EncryptionSSLTLS enc = sMail.EncryptionSSLTLS
case "starttls": case "starttls":
@@ -90,22 +97,18 @@ func NewEmailer(app *appContext) *Emailer {
case "none": case "none":
enc = sMail.EncryptionNone enc = sMail.EncryptionNone
} }
username := app.config.Section("smtp").Key("username").MustString("") username := emailer.config.Section("smtp").Key("username").MustString("")
password := app.config.Section("smtp").Key("password").String() password := emailer.config.Section("smtp").Key("password").String()
if username == "" && password != "" { if username == "" && password != "" {
username = emailer.fromAddr username = emailer.fromAddr
} }
var proxyConf *easyproxy.ProxyConfig = nil authType := sMail.AuthType(emailer.config.Section("smtp").Key("auth_type").MustInt(4))
if app.proxyEnabled { 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)
proxyConf = &app.proxyConfig
}
authType := sMail.AuthType(app.config.Section("smtp").Key("auth_type").MustInt(4))
err := emailer.NewSMTP(app.config.Section("smtp").Key("server").String(), app.config.Section("smtp").Key("port").MustInt(465), username, password, enc, app.config.Section("smtp").Key("ssl_cert").MustString(""), app.config.Section("smtp").Key("hello_hostname").String(), app.config.Section("smtp").Key("cert_validation").MustBool(true), authType, proxyConf)
if err != nil { if err != nil {
app.err.Printf(lm.FailedInitSMTP, err) emailer.err.Printf(lm.FailedInitSMTP, err)
} }
} else if method == "mailgun" { } 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" { } else if method == "dummy" {
emailer.sender = &DummyClient{} emailer.sender = &DummyClient{}
} }
@@ -161,7 +164,7 @@ func (emailer *Emailer) NewSMTP(server string, port int, username, password stri
var cert []byte var cert []byte
cert, err = os.ReadFile(certPath) cert, err = os.ReadFile(certPath)
if rootCAs.AppendCertsFromPEM(cert) == false { 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{ sender.Client.TLSConfig = &tls.Config{
@@ -243,22 +246,48 @@ type templ interface {
Execute(wr io.Writer, data interface{}) error 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) { func (emailer *Emailer) construct(contentInfo CustomContentInfo, cc CustomContent, data map[string]any) (*Message, error) {
var tpl templ msg := &Message{
if substituteStrings == "" { Subject: contentInfo.Subject(emailer.config, &emailer.lang),
data["jellyfin"] = "Jellyfin"
} else {
data["jellyfin"] = substituteStrings
} }
// Template the subject for bonus points
if subject, err := templateEmail(msg.Subject, contentInfo.Variables, contentInfo.Conditionals, 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 := templateEmail(cc.Content, contentInfo.Variables, contentInfo.Conditionals, 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 var keys []string
plaintext := app.config.Section("email").Key("plaintext").MustBool(false) plaintext := emailer.config.Section("email").Key("plaintext").MustBool(false)
if plaintext { if plaintext {
if telegramEnabled || discordEnabled { if telegramEnabled || discordEnabled {
keys = []string{"text"} keys = []string{"text"}
text, markdown = "", "" msg.Text, msg.Markdown = "", ""
} else { } else {
keys = []string{"text"} keys = []string{"text"}
text = "" msg.Text = ""
} }
} else { } else {
if telegramEnabled || discordEnabled { if telegramEnabled || discordEnabled {
@@ -271,9 +300,9 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string,
var filesystem fs.FS var filesystem fs.FS
var fpath string var fpath string
if key == "markdown" { if key == "markdown" {
filesystem, fpath = app.GetPath(section, keyFragment+"text") filesystem, fpath = emailer.config.GetPath(contentInfo.SourceFile.Section, contentInfo.SourceFile.SettingPrefix+"text")
} else { } else {
filesystem, fpath = app.GetPath(section, keyFragment+key) filesystem, fpath = emailer.config.GetPath(contentInfo.SourceFile.Section, contentInfo.SourceFile.SettingPrefix+key)
} }
if key == "html" { if key == "html" {
tpl, err = template.ParseFS(filesystem, fpath) tpl, err = template.ParseFS(filesystem, fpath)
@@ -281,7 +310,7 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string,
tpl, err = textTemplate.ParseFS(filesystem, fpath) tpl, err = textTemplate.ParseFS(filesystem, fpath)
} }
if err != nil { 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". // For constructTemplate, if "md" is found in data it's used in stead of "text".
foundMarkdown := false foundMarkdown := false
@@ -294,616 +323,284 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string,
var tplData bytes.Buffer var tplData bytes.Buffer
err = tpl.Execute(&tplData, data) err = tpl.Execute(&tplData, data)
if err != nil { if err != nil {
return return msg, err
} }
if foundMarkdown { if foundMarkdown {
data["plaintext"], data["md"] = data["md"], data["plaintext"] data["plaintext"], data["md"] = data["md"], data["plaintext"]
} }
if key == "html" { if key == "html" {
html = tplData.String() msg.HTML = tplData.String()
} else if key == "text" { } else if key == "text" {
text = tplData.String() msg.Text = tplData.String()
} else { } 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{} { func (emailer *Emailer) baseValues(name string, username string, placeholders bool, values map[string]any) (CustomContentInfo, map[string]any) {
template := map[string]interface{}{ 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"), "clickBelow": emailer.lang.EmailConfirmation.get("clickBelow"),
"ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"), "ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"),
"confirmEmail": emailer.lang.EmailConfirmation.get("confirmEmail"), "confirmEmail": emailer.lang.EmailConfirmation.get("confirmEmail"),
"message": "", })
"username": username, if !placeholders {
} inviteLink := ExternalURI(nil)
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(nil)
if code == "" { // Personal email change if code == "" { // Personal email change
inviteLink = fmt.Sprintf("%s/my/confirm/%s", inviteLink, url.PathEscape(key)) inviteLink = fmt.Sprintf("%s/my/confirm/%s", inviteLink, url.PathEscape(key))
} else { // Invite email confirmation } else { // Invite email confirmation
inviteLink = fmt.Sprintf("%s%s/%s?key=%s", inviteLink, PAGES.Form, 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["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) { func (emailer *Emailer) constructInvite(invite Invite, placeholders 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{} {
expiry := invite.ValidTill expiry := invite.ValidTill
d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern) d, t, expiresIn := emailer.formatExpiry(expiry, false)
message := app.config.Section("messages").Key("message").String() inviteLink := fmt.Sprintf("%s%s/%s", ExternalURI(nil), PAGES.Form, invite.Code)
inviteLink := fmt.Sprintf("%s%s/%s", app.ExternalURI(nil), PAGES.Form, code) contentInfo, template := emailer.baseValues("InviteEmail", "", placeholders, map[string]any{
template := map[string]interface{}{
"hello": emailer.lang.InviteEmail.get("hello"), "hello": emailer.lang.InviteEmail.get("hello"),
"youHaveBeenInvited": emailer.lang.InviteEmail.get("youHaveBeenInvited"), "youHaveBeenInvited": emailer.lang.InviteEmail.get("youHaveBeenInvited"),
"toJoin": emailer.lang.InviteEmail.get("toJoin"), "toJoin": emailer.lang.InviteEmail.get("toJoin"),
"linkButton": emailer.lang.InviteEmail.get("linkButton"), "linkButton": emailer.lang.InviteEmail.get("linkButton"),
"message": "",
"date": d, "date": d,
"time": t, "time": t,
"expiresInMinutes": expiresIn, "expiresInMinutes": expiresIn,
"inviteURL": inviteLink,
"inviteExpiry": emailer.lang.InviteEmail.get("inviteExpiry"),
})
if !placeholders {
template["inviteExpiry"] = emailer.lang.InviteEmail.template("inviteExpiry", template)
} }
if noSub { cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
template["inviteExpiry"] = emailer.lang.InviteEmail.get("inviteExpiry") return emailer.construct(contentInfo, cc, template)
empty := []string{"inviteURL"}
for _, v := range empty {
template[v] = "{" + v + "}"
}
} else {
template["inviteExpiry"] = emailer.lang.InviteEmail.template("inviteExpiry", tmpl{"date": d, "time": t, "expiresInMinutes": expiresIn})
template["inviteURL"] = inviteLink
template["message"] = message
}
return template
} }
func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext, noSub bool) (*Message, error) { func (emailer *Emailer) constructExpiry(invite Invite, placeholders bool) (*Message, error) {
email := &Message{ expiry := formatDatetime(invite.ValidTill)
Subject: app.config.Section("invite_emails").Key("subject").MustString(emailer.lang.InviteEmail.get("title")), contentInfo, template := emailer.baseValues("InviteExpiry", "", placeholders, map[string]any{
}
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"), "inviteExpired": emailer.lang.InviteExpiry.get("inviteExpired"),
"notificationNotice": emailer.lang.InviteExpiry.get("notificationNotice"), "expiredAt": emailer.lang.InviteExpiry.get("expiredAt"),
"code": "\"" + code + "\"", "code": "\"" + invite.Code + "\"",
"time": expiry, "time": expiry,
})
if !placeholders {
template["expiredAt"] = emailer.lang.InviteExpiry.template("expiredAt", template)
} }
if noSub { cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
template["expiredAt"] = emailer.lang.InviteExpiry.get("expiredAt") return emailer.construct(contentInfo, cc, template)
} 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) { func (emailer *Emailer) constructCreated(username, address string, when time.Time, invite Invite, placeholders bool) (*Message, error) {
email := &Message{ // NOTE: This was previously invite.Created, not sure why.
Subject: emailer.lang.InviteExpiry.get("title"), created := formatDatetime(when)
} contentInfo, template := emailer.baseValues("UserCreated", username, placeholders, map[string]any{
var err error "aUserWasCreated": emailer.lang.UserCreated.get("aUserWasCreated"),
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"), "nameString": emailer.lang.Strings.get("name"),
"addressString": emailer.lang.Strings.get("emailAddress"), "addressString": emailer.lang.Strings.get("emailAddress"),
"timeString": emailer.lang.UserCreated.get("time"), "timeString": emailer.lang.UserCreated.get("time"),
"notificationNotice": "", "code": "\"" + invite.Code + "\"",
"code": "\"" + 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"
} }
if noSub {
template["aUserWasCreated"] = emailer.lang.UserCreated.get("aUserWasCreated")
empty := []string{"name", "address", "time"}
for _, v := range empty {
template[v] = "{" + v + "}"
} }
} else { cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
created := app.formatDatetime(invite.Created) return emailer.construct(contentInfo, cc, template)
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) { func (emailer *Emailer) constructReset(pwr PasswordReset, placeholders bool) (*Message, error) {
email := &Message{ if placeholders {
Subject: emailer.lang.UserCreated.get("title"), pwr.Username = "{username}"
} }
template := emailer.createdValues(code, username, address, invite, app, noSub) d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true)
var err error linkResetEnabled := emailer.config.Section("password_resets").Key("link_reset").MustBool(false)
message := app.storage.MustGetCustomContentKey("UserCreated") contentInfo, template := emailer.baseValues("PasswordReset", pwr.Username, placeholders, map[string]any{
if message.Enabled { "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": pwr.Username}),
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{}{
"someoneHasRequestedReset": emailer.lang.PasswordReset.get("someoneHasRequestedReset"), "someoneHasRequestedReset": emailer.lang.PasswordReset.get("someoneHasRequestedReset"),
"ifItWasYou": emailer.lang.PasswordReset.get("ifItWasYou"),
"ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"), "ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"),
"pinString": emailer.lang.PasswordReset.get("pin"), "pinString": emailer.lang.PasswordReset.get("pin"),
"link_reset": false, "codeExpiry": emailer.lang.PasswordReset.get("codeExpiry"),
"message": "", "link_reset": linkResetEnabled && !placeholders,
"username": pwr.Username,
"date": d, "date": d,
"time": t, "time": t,
"expiresInMinutes": expiresIn, "expiresInMinutes": expiresIn,
} "pin": pwr.Pin,
linkResetEnabled := app.config.Section("password_resets").Key("link_reset").MustBool(false) })
if linkResetEnabled { if linkResetEnabled {
template["ifItWasYou"] = emailer.lang.PasswordReset.get("ifItWasYouLink") template["ifItWasYou"] = emailer.lang.PasswordReset.get("ifItWasYouLink")
} else {
template["ifItWasYou"] = emailer.lang.PasswordReset.get("ifItWasYou")
} }
if noSub { if !placeholders {
template["helloUser"] = emailer.lang.Strings.get("helloUser") template["codeExpiry"] = emailer.lang.PasswordReset.template("codeExpiry", template)
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 linkResetEnabled { if linkResetEnabled {
pinLink, err := app.GenResetLink(pwr.Pin) pinLink, err := GenResetLink(pwr.Pin)
if err == nil { if err != nil {
// Strip /invite form end of this URL, ik its ugly. template["link_reset"] = false
template["link_reset"] = true emailer.info.Printf(lm.FailedGeneratePWRLink, err)
} else {
template["pin"] = pinLink template["pin"] = pinLink
// Only used in html email. // Only used in html email.
template["pin_code"] = pwr.Pin template["pin_code"] = pwr.Pin
} else {
app.info.Printf(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) { func (emailer *Emailer) constructDeleted(username, reason string, placeholders bool) (*Message, error) {
email := &Message{ if placeholders {
Subject: app.config.Section("password_resets").Key("subject").MustString(emailer.lang.PasswordReset.get("title")), username = "{username}"
reason = "{reason}"
} }
template := emailer.resetValues(pwr, app, noSub) contentInfo, template := emailer.baseValues("UserDeleted", username, placeholders, map[string]any{
var err error "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
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{}{
"yourAccountWas": emailer.lang.UserDeleted.get("yourAccountWasDeleted"), "yourAccountWas": emailer.lang.UserDeleted.get("yourAccountWasDeleted"),
"reasonString": emailer.lang.Strings.get("reason"), "reasonString": emailer.lang.Strings.get("reason"),
"message": "", "reason": reason,
} })
if noSub { cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
empty := []string{"reason"} return emailer.construct(contentInfo, cc, template)
for _, v := range empty {
template[v] = "{" + v + "}"
}
} else {
template["reason"] = reason
template["message"] = app.config.Section("messages").Key("message").String()
}
return template
} }
func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub bool) (*Message, error) { func (emailer *Emailer) constructDisabled(username, reason string, placeholders bool) (*Message, error) {
email := &Message{ if placeholders {
Subject: app.config.Section("deletion").Key("subject").MustString(emailer.lang.UserDeleted.get("title")), username = "{username}"
reason = "{reason}"
} }
var err error contentInfo, template := emailer.baseValues("UserDisabled", username, placeholders, map[string]any{
template := emailer.deletedValues(reason, app, noSub) "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
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{}{
"yourAccountWas": emailer.lang.UserDisabled.get("yourAccountWasDisabled"), "yourAccountWas": emailer.lang.UserDisabled.get("yourAccountWasDisabled"),
"reasonString": emailer.lang.Strings.get("reason"), "reasonString": emailer.lang.Strings.get("reason"),
"message": "", "reason": reason,
} })
if noSub { cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
empty := []string{"reason"} return emailer.construct(contentInfo, cc, template)
for _, v := range empty {
template[v] = "{" + v + "}"
}
} else {
template["reason"] = reason
template["message"] = app.config.Section("messages").Key("message").String()
}
return template
} }
func (emailer *Emailer) constructDisabled(reason string, app *appContext, noSub bool) (*Message, error) { func (emailer *Emailer) constructEnabled(username, reason string, placeholders bool) (*Message, error) {
email := &Message{ if placeholders {
Subject: app.config.Section("disable_enable").Key("subject_disabled").MustString(emailer.lang.UserDisabled.get("title")), username = "{username}"
reason = "{reason}"
} }
var err error contentInfo, template := emailer.baseValues("UserEnabled", username, placeholders, map[string]any{
template := emailer.disabledValues(reason, app, noSub) "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
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{}{
"yourAccountWas": emailer.lang.UserEnabled.get("yourAccountWasEnabled"), "yourAccountWas": emailer.lang.UserEnabled.get("yourAccountWasEnabled"),
"reasonString": emailer.lang.Strings.get("reason"), "reasonString": emailer.lang.Strings.get("reason"),
"message": "", "reason": reason,
} })
if noSub { cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
empty := []string{"reason"} return emailer.construct(contentInfo, cc, template)
for _, v := range empty {
template[v] = "{" + v + "}"
}
} else {
template["reason"] = reason
template["message"] = app.config.Section("messages").Key("message").String()
}
return template
} }
func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub bool) (*Message, error) { func (emailer *Emailer) constructExpiryAdjusted(username string, expiry time.Time, reason string, placeholders bool) (*Message, error) {
email := &Message{ if placeholders {
Subject: app.config.Section("disable_enable").Key("subject_enabled").MustString(emailer.lang.UserEnabled.get("title")), username = "{username}"
} }
var err error exp := formatDatetime(expiry)
template := emailer.enabledValues(reason, app, noSub) contentInfo, template := emailer.baseValues("UserExpiryAdjusted", username, placeholders, map[string]any{
message := app.storage.MustGetCustomContentKey("UserEnabled") "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
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{}{
"yourExpiryWasAdjusted": emailer.lang.UserExpiryAdjusted.get("yourExpiryWasAdjusted"), "yourExpiryWasAdjusted": emailer.lang.UserExpiryAdjusted.get("yourExpiryWasAdjusted"),
"ifPreviouslyDisabled": emailer.lang.UserExpiryAdjusted.get("ifPreviouslyDisabled"), "ifPreviouslyDisabled": emailer.lang.UserExpiryAdjusted.get("ifPreviouslyDisabled"),
"reasonString": emailer.lang.Strings.get("reason"), "reasonString": emailer.lang.Strings.get("reason"),
"newExpiry": "", "reason": reason,
"message": "", "newExpiry": exp,
} })
if noSub { cc := emailer.storage.MustGetCustomContentKey("UserExpiryAdjusted")
template["helloUser"] = emailer.lang.Strings.get("helloUser") if !placeholders {
empty := []string{"reason", "newExpiry"} if !cc.Enabled {
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{ template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
"date": exp, "date": exp,
}) })
} }
} }
} return emailer.construct(contentInfo, cc, template)
return template
} }
func (emailer *Emailer) constructExpiryAdjusted(username string, expiry time.Time, reason string, app *appContext, noSub bool) (*Message, error) { func (emailer *Emailer) constructExpiryReminder(username string, expiry time.Time, placeholders bool) (*Message, error) {
email := &Message{ if placeholders {
Subject: app.config.Section("user_expiry").Key("adjustment_subject").MustString(emailer.lang.UserExpiryAdjusted.get("title")), username = "{username}"
} }
var err error d, t, expiresIn := emailer.formatExpiry(expiry, false)
var template map[string]interface{} contentInfo, template := emailer.baseValues("ExpiryReminder", username, placeholders, map[string]any{
message := app.storage.MustGetCustomContentKey("UserExpiryAdjusted") "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
if message.Enabled { "yourAccountIsDueToExpire": emailer.lang.ExpiryReminder.get("yourAccountIsDueToExpire"),
template = emailer.expiryAdjustedValues(username, expiry, reason, app, noSub, true) "expiresIn": expiresIn,
} else { "date": d,
template = emailer.expiryAdjustedValues(username, expiry, reason, app, noSub, false) "time": t,
}
if noSub {
template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
"date": "{newExpiry}",
}) })
cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
if !placeholders {
if !cc.Enabled && !expiry.IsZero() {
template["yourAccountIsDueToExpire"] = emailer.lang.ExpiryReminder.template("yourAccountIsDueToExpire", template)
} }
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 emailer.construct(contentInfo, cc, template)
return nil, err
}
return email, nil
} }
func (emailer *Emailer) welcomeValues(username string, expiry time.Time, app *appContext, noSub bool, custom bool) map[string]interface{} { func (emailer *Emailer) constructWelcome(username string, expiry time.Time, placeholders bool) (*Message, error) {
template := map[string]interface{}{ var exp any = formatDatetime(expiry)
if placeholders {
username = "{username}"
exp = "{yourAccountWillExpire}"
}
contentInfo, template := emailer.baseValues("WelcomeEmail", username, placeholders, map[string]any{
"welcome": emailer.lang.WelcomeEmail.get("welcome"), "welcome": emailer.lang.WelcomeEmail.get("welcome"),
"youCanLoginWith": emailer.lang.WelcomeEmail.get("youCanLoginWith"), "youCanLoginWith": emailer.lang.WelcomeEmail.get("youCanLoginWith"),
"jellyfinURLString": emailer.lang.WelcomeEmail.get("jellyfinURL"), "jellyfinURLString": emailer.lang.WelcomeEmail.get("jellyfinURL"),
"jellyfinURL": emailer.config.Section("jellyfin").Key("public_server").String(),
"usernameString": emailer.lang.Strings.get("username"), "usernameString": emailer.lang.Strings.get("username"),
"message": "", })
"yourAccountWillExpire": "", if !expiry.IsZero() || placeholders {
}
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{ template["yourAccountWillExpire"] = emailer.lang.WelcomeEmail.template("yourAccountWillExpire", tmpl{
"date": exp, "date": exp,
}) })
} }
cc := emailer.storage.MustGetCustomContentKey("WelcomeEmail")
if !placeholders {
if cc.Enabled && !expiry.IsZero() {
template["yourAccountWillExpire"] = exp
} }
} }
return template return emailer.construct(contentInfo, cc, template)
} }
func (emailer *Emailer) constructWelcome(username string, expiry time.Time, app *appContext, noSub bool) (*Message, error) { func (emailer *Emailer) constructUserExpired(username string, placeholders bool) (*Message, error) {
email := &Message{ contentInfo, template := emailer.baseValues("UserExpired", username, placeholders, map[string]any{
Subject: app.config.Section("welcome_email").Key("subject").MustString(emailer.lang.WelcomeEmail.get("title")),
}
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 {
template["yourAccountWillExpire"] = emailer.lang.WelcomeEmail.template("yourAccountWillExpire", tmpl{
"date": "{yourAccountWillExpire}",
})
}
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)
}
if err != nil {
return nil, err
}
return email, nil
}
func (emailer *Emailer) userExpiredValues(app *appContext, noSub bool) map[string]interface{} {
template := map[string]interface{}{
"yourAccountHasExpired": emailer.lang.UserExpired.get("yourAccountHasExpired"), "yourAccountHasExpired": emailer.lang.UserExpired.get("yourAccountHasExpired"),
"contactTheAdmin": emailer.lang.UserExpired.get("contactTheAdmin"), "contactTheAdmin": emailer.lang.UserExpired.get("contactTheAdmin"),
"message": "", })
} cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
if !noSub { return emailer.construct(contentInfo, cc, template)
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
} }
// calls the send method in the underlying emailClient. // calls the send method in the underlying emailClient.
+491
View File
@@ -0,0 +1,491 @@
package main
import (
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/fatih/color"
"github.com/hrfee/jfa-go/logger"
"github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4"
)
var db *badgerhold.Store
func dbClose(e *Emailer) {
e.storage.db.Close()
e.storage.db = nil
db = nil
}
func Fatal(err any) {
fmt.Printf("Fatal log function called: %+v\n", err)
}
// NewTestEmailer initialises most of what the emailer depends on, which happens to be most of the app.
func NewTestEmailer() (*Emailer, error) {
emailer := &Emailer{
fromAddr: "from@addr",
fromName: "fromName",
LoggerSet: LoggerSet{
info: logger.NewLogger(os.Stdout, "[TEST INFO] ", log.Ltime, color.FgHiWhite),
err: logger.NewLogger(os.Stdout, "[TEST ERROR] ", log.Ltime|log.Lshortfile, color.FgRed),
debug: logger.NewLogger(os.Stdout, "[TEST DEBUG] ", log.Ltime|log.Lshortfile, color.FgYellow),
},
sender: &DummyClient{},
}
// Assume our working directory is the root of the repo
wd, _ := os.Getwd()
loadFilesystems(filepath.Join(wd, "build"), logger.NewEmptyLogger())
dConfig, err := fs.ReadFile(localFS, "config-default.ini")
if err != nil {
return emailer, err
}
// Force emailer to construct markdown
discordEnabled = true
noInfoLS := emailer.LoggerSet
noInfoLS.info = logger.NewEmptyLogger()
emailer.config, err = NewConfig(dConfig, "/tmp/jfa-go-test", noInfoLS)
if err != nil {
return emailer, err
}
emailer.storage = NewStorage("/tmp/db", emailer.debug, func(k string) DebugLogAction { return LogAll })
emailer.storage.loadLang(langFS)
emailer.storage.lang.chosenAdminLang = emailer.config.Section("ui").Key("language-admin").MustString("en-us")
emailer.storage.lang.chosenEmailLang = emailer.config.Section("email").Key("language").MustString("en-us")
emailer.storage.lang.chosenPWRLang = emailer.config.Section("password_resets").Key("language").MustString("en-us")
emailer.storage.lang.chosenTelegramLang = emailer.config.Section("telegram").Key("language").MustString("en-us")
opts := badgerhold.DefaultOptions
opts.Dir = "/tmp/jfa-go-test-db"
opts.ValueDir = opts.Dir
opts.SyncWrites = false
opts.Logger = nil
emailer.storage.db, err = badgerhold.Open(opts)
// emailer.info.Printf("DB Opened")
db = emailer.storage.db
if err != nil {
return emailer, err
}
emailer.lang = emailer.storage.lang.Email[emailer.storage.lang.chosenEmailLang]
emailer.info.SetFatalFunc(Fatal)
emailer.err.SetFatalFunc(Fatal)
return emailer, err
}
func testDummyEmailerInit(t *testing.T) *Emailer {
e, err := NewTestEmailer()
if err != nil {
t.Fatalf("error: %v", err)
}
return e
}
func TestDummyEmailerInit(t *testing.T) {
dbClose(testDummyEmailerInit(t))
}
func testContent(e *Emailer, cci CustomContentInfo, t *testing.T, testFunc func(t *testing.T)) {
e.storage.DeleteCustomContentKey(cci.Name)
t.Run(cci.Name, testFunc)
cc := CustomContent{
Name: cci.Name,
Enabled: true,
}
cc.Content = "start test content "
for _, v := range cci.Variables {
cc.Content += "{" + v + "}"
}
cc.Content += " end test content"
e.storage.SetCustomContentKey(cci.Name, cc)
t.Run(cci.Name+" Custom", testFunc)
e.storage.DeleteCustomContentKey(cci.Name)
}
// constructConfirmation(code, username, key string, placeholders bool)
func TestConfirmation(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
// non-blank key, link should therefore not be a /my/confirm one
if db == nil {
t.Fatalf("db nil")
}
testContent(e, customContent["EmailConfirmation"], t, func(t *testing.T) {
code := shortuuid.New()
username := shortuuid.New()
key := shortuuid.New()
msg, err := e.constructConfirmation(code, username, key, false)
t.Run("FromInvite", func(t *testing.T) {
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if strings.Contains(content, "/my/confirm") {
t.Fatalf("/my/confirm link generated instead of invite confirm link: %s", content)
}
if !strings.Contains(content, code) {
t.Fatalf("code not found in output: %s", content)
}
if !strings.Contains(content, key) {
t.Fatalf("key not found in output: %s", content)
}
if !strings.Contains(content, username) {
t.Fatalf("username not found in output: %s", content)
}
}
})
code = ""
msg, err = e.constructConfirmation(code, username, key, false)
t.Run("FromMyAccount", func(t *testing.T) {
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, "/my/confirm") {
t.Fatalf("/my/confirm link not generated: %s", content)
}
if !strings.Contains(content, key) {
t.Fatalf("key not found in output: %s", content)
}
if !strings.Contains(content, username) {
t.Fatalf("username not found in output: %s", content)
}
}
})
})
}
// constructInvite(invite Invite, placeholders bool)
func TestInvite(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
if db == nil {
t.Fatalf("db nil")
}
// Fix date/time format
datePattern = "%d/%m/%y"
timePattern = "%H:%M"
testContent(e, customContent["InviteEmail"], t, func(t *testing.T) {
inv := Invite{
Code: shortuuid.New(),
Created: time.Now(),
ValidTill: time.Now().Add(30 * time.Minute),
}
msg, err := e.constructInvite(inv, false)
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, inv.Code) {
t.Fatalf("code not found in output: %s", content)
}
if !strings.Contains(content, "30m") {
t.Fatalf("expiry not found in output: %s", content)
}
}
})
}
// constructExpiry(code string, invite Invite, placeholders bool)
func TestExpiry(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
if db == nil {
t.Fatalf("db nil")
}
// Fix date/time format
datePattern = "%d/%m/%y"
timePattern = "%H:%M"
testContent(e, customContent["InviteExpiry"], t, func(t *testing.T) {
inv := Invite{
Code: shortuuid.New(),
Created: time.Time{},
ValidTill: time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC),
}
// So we can easily check is the expiry time is included (which is 0001-01-01).
for strings.Contains(inv.Code, "1") {
inv.Code = shortuuid.New()
}
msg, err := e.constructExpiry(inv, false)
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, inv.Code) {
t.Fatalf("code not found in output: %s", content)
}
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
t.Fatalf("expiry not found in output: %s", content)
}
}
})
}
// constructCreated(code, username, address string, invite Invite, placeholders bool)
func TestCreated(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
if db == nil {
t.Fatalf("db nil")
}
// Fix date/time format
datePattern = "%d/%m/%y"
timePattern = "%H:%M"
testContent(e, customContent["UserCreated"], t, func(t *testing.T) {
inv := Invite{
Code: shortuuid.New(),
Created: time.Time{},
ValidTill: time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC),
}
username := shortuuid.New()
address := shortuuid.New()
msg, err := e.constructCreated(username, address, inv.ValidTill, inv, false)
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, inv.Code) {
t.Fatalf("code not found in output: %s", content)
}
if !strings.Contains(content, username) {
t.Fatalf("username not found in output: %s", content)
}
if !strings.Contains(content, address) {
t.Fatalf("address not found in output: %s", content)
}
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
t.Fatalf("expiry not found in output: %s", content)
}
}
})
}
// constructReset(pwr PasswordReset, placeholders bool)
func TestReset(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
if db == nil {
t.Fatalf("db nil")
}
// Fix date/time format
datePattern = "%d/%m/%y"
timePattern = "%H:%M"
testContent(e, customContent["PasswordReset"], t, func(t *testing.T) {
pwr := PasswordReset{
Pin: shortuuid.New(),
Username: shortuuid.New(),
Expiry: time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC),
Internal: false,
}
msg, err := e.constructReset(pwr, false)
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, pwr.Pin) {
t.Fatalf("pin not found in output: %s", content)
}
if !strings.Contains(content, pwr.Username) {
t.Fatalf("username not found in output: %s", content)
}
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
t.Fatalf("expiry not found in output: %s", content)
}
}
})
}
// constructDeleted(reason string, placeholders bool)
func TestDeleted(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
if db == nil {
t.Fatalf("db nil")
}
testContent(e, customContent["UserDeleted"], t, func(t *testing.T) {
reason := shortuuid.New()
username := shortuuid.New()
msg, err := e.constructDeleted(username, reason, false)
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, reason) {
t.Fatalf("reason not found in output: %s", content)
}
if !strings.Contains(content, username) {
t.Fatalf("username not found in output: %s", content)
}
}
})
}
// constructDisabled(reason string, placeholders bool)
func TestDisabled(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
if db == nil {
t.Fatalf("db nil")
}
testContent(e, customContent["UserDeleted"], t, func(t *testing.T) {
reason := shortuuid.New()
username := shortuuid.New()
msg, err := e.constructDisabled(username, reason, false)
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, reason) {
t.Fatalf("reason not found in output: %s", content)
}
if !strings.Contains(content, username) {
t.Fatalf("username not found in output: %s", content)
}
}
})
}
// constructEnabled(reason string, placeholders bool)
func TestEnabled(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
if db == nil {
t.Fatalf("db nil")
}
testContent(e, customContent["UserDeleted"], t, func(t *testing.T) {
reason := shortuuid.New()
username := shortuuid.New()
msg, err := e.constructEnabled(username, reason, false)
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, reason) {
t.Fatalf("reason not found in output: %s", content)
}
if !strings.Contains(content, username) {
t.Fatalf("username not found in output: %s", content)
}
}
})
}
// constructExpiryAdjusted(username string, expiry time.Time, reason string, placeholders bool)
func TestExpiryAdjusted(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
if db == nil {
t.Fatalf("db nil")
}
// Fix date/time format
datePattern = "%d/%m/%y"
timePattern = "%H:%M"
testContent(e, customContent["UserExpiryAdjusted"], t, func(t *testing.T) {
username := shortuuid.New()
expiry := time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC)
reason := shortuuid.New()
msg, err := e.constructExpiryAdjusted(username, expiry, reason, false)
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, username) {
t.Fatalf("username not found in output: %s", content)
}
if !strings.Contains(content, reason) {
t.Fatalf("reason not found in output: %s", content)
}
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
t.Fatalf("expiry not found in output: %s", content)
}
}
})
}
// constructExpiryReminder(username string, expiry time.Time, placeholders bool)
func TestExpiryReminder(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
if db == nil {
t.Fatalf("db nil")
}
// Fix date/time format
datePattern = "%d/%m/%y"
timePattern = "%H:%M"
testContent(e, customContent["ExpiryReminder"], t, func(t *testing.T) {
username := shortuuid.New()
expiry := time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC)
msg, err := e.constructExpiryReminder(username, expiry, false)
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, username) {
t.Fatalf("username not found in output: %s", content)
}
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
t.Fatalf("expiry not found in output: %s", content)
}
}
})
}
// constructWelcome(username string, expiry time.Time, placeholders bool)
func TestWelcome(t *testing.T) {
e := testDummyEmailerInit(t)
defer dbClose(e)
if db == nil {
t.Fatalf("db nil")
}
// Fix date/time format
datePattern = "%d/%m/%y"
timePattern = "%H:%M"
testContent(e, customContent["WelcomeEmail"], t, func(t *testing.T) {
username := shortuuid.New()
expiry := time.Date(2025, 1, 2, 8, 37, 1, 1, time.UTC)
msg, err := e.constructWelcome(username, expiry, false)
t.Run("NoExpiry", func(t *testing.T) {
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, username) {
t.Fatalf("username not found in output: %s", content)
}
// time.Time{} is 0001-01-01... so look for a 1 in there at least.
if !strings.Contains(content, "02/01/25") || !strings.Contains(content, "08:37") {
t.Fatalf("expiry not found in output: %s", content)
}
}
})
username = shortuuid.New()
expiry = time.Time{}
msg, err = e.constructWelcome(username, expiry, false)
t.Run("WithExpiry", func(t *testing.T) {
if err != nil {
t.Fatalf("failed construct: %+v", err)
}
for _, content := range []string{msg.Text, msg.HTML} {
if !strings.Contains(content, username) {
t.Fatalf("username not found in output: %s", content)
}
if strings.Contains(content, "01/01/01") || strings.Contains(content, "00:00") {
t.Fatalf("empty expiry found in output: %s", content)
}
}
})
})
}
+9 -23
View File
@@ -4,20 +4,17 @@
package main package main
import ( import (
"io/fs"
"log"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/hrfee/jfa-go/logger"
) )
const binaryType = "external" const binaryType = "external"
func BuildTagsExternal() { buildTags = append(buildTags, "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 '/'. // 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 { return filepath.Join(elem...) }
func FSJoin(elem ...string) string { func FSJoin(elem ...string) string {
@@ -32,23 +29,12 @@ func FSJoin(elem ...string) string {
return strings.TrimSuffix(path, sep) return strings.TrimSuffix(path, sep)
} }
type dirFS string func loadFilesystems(rootDir string, logger *logger.Logger) {
logger.Println("Using external storage")
func (dir dirFS) Open(name string) (fs.File, error) { if rootDir == "" {
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() executable, _ := os.Executable()
localFS = dirFS(filepath.Join(filepath.Dir(executable), "data")) rootDir = filepath.Dir(executable)
langFS = dirFS(filepath.Join(filepath.Dir(executable), "data", "lang")) }
localFS = dirFS(filepath.Join(rootDir, "data"))
langFS = dirFS(filepath.Join(rootDir, "data", "lang"))
} }
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"io/fs"
"os"
)
type genericFS interface {
fs.FS
fs.ReadDirFS
fs.ReadFileFS
}
var localFS genericFS
var langFS genericFS
type dirFS string
func (dir dirFS) Open(name string) (fs.File, error) {
return os.Open(string(dir) + "/" + name)
}
func (dir dirFS) ReadFile(name string) ([]byte, error) {
return os.ReadFile(string(dir) + "/" + name)
}
func (dir dirFS) ReadDir(name string) ([]fs.DirEntry, error) {
return os.ReadDir(string(dir) + "/" + name)
}
+8
View File
@@ -11,6 +11,7 @@ import (
type GenericDaemon struct { type GenericDaemon struct {
Stopped bool Stopped bool
ShutdownChannel chan string ShutdownChannel chan string
TriggerChannel chan bool
Interval time.Duration Interval time.Duration
period time.Duration period time.Duration
jobs []func(app *appContext) jobs []func(app *appContext)
@@ -27,6 +28,7 @@ func NewGenericDaemon(interval time.Duration, app *appContext, jobs ...func(app
d := GenericDaemon{ d := GenericDaemon{
Stopped: false, Stopped: false,
ShutdownChannel: make(chan string), ShutdownChannel: make(chan string),
TriggerChannel: make(chan bool),
Interval: interval, Interval: interval,
period: interval, period: interval,
app: app, app: app,
@@ -46,6 +48,8 @@ func (d *GenericDaemon) run() {
case <-d.ShutdownChannel: case <-d.ShutdownChannel:
d.ShutdownChannel <- "Down" d.ShutdownChannel <- "Down"
return return
case <-d.TriggerChannel:
break
case <-time.After(d.period): case <-time.After(d.period):
break break
} }
@@ -61,6 +65,10 @@ func (d *GenericDaemon) run() {
} }
} }
func (d *GenericDaemon) Trigger() {
d.TriggerChannel <- true
}
func (d *GenericDaemon) Shutdown() { func (d *GenericDaemon) Shutdown() {
d.Stopped = true d.Stopped = true
d.ShutdownChannel <- "Down" d.ShutdownChannel <- "Down"
+62 -60
View File
@@ -1,8 +1,6 @@
module github.com/hrfee/jfa-go module github.com/hrfee/jfa-go
go 1.23.0 go 1.24.0
toolchain go1.24.0
replace github.com/hrfee/jfa-go/docs => ./docs replace github.com/hrfee/jfa-go/docs => ./docs
@@ -30,47 +28,46 @@ require (
github.com/fsnotify/fsnotify v1.9.0 github.com/fsnotify/fsnotify v1.9.0
github.com/getlantern/systray v1.2.2 github.com/getlantern/systray v1.2.2
github.com/gin-contrib/pprof v1.5.3 github.com/gin-contrib/pprof v1.5.3
github.com/gin-gonic/gin v1.10.1 github.com/gin-gonic/gin v1.11.0
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible 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/golang-jwt/jwt v3.2.2+incompatible
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a
github.com/hrfee/jfa-go/common v0.0.0-20250716174732-bcb6346f8115 github.com/hrfee/jfa-go/common v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/docs v0.0.0-20250716174732-bcb6346f8115 github.com/hrfee/jfa-go/docs v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/easyproxy v0.0.0-20250716174732-bcb6346f8115 github.com/hrfee/jfa-go/easyproxy v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/jellyseerr v0.0.0-20250716174732-bcb6346f8115 github.com/hrfee/jfa-go/jellyseerr v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/linecache v0.0.0-20250716174732-bcb6346f8115 github.com/hrfee/jfa-go/linecache v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/logger v0.0.0-20250716174732-bcb6346f8115 github.com/hrfee/jfa-go/logger v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/logmessages v0.0.0-20250716174732-bcb6346f8115 github.com/hrfee/jfa-go/logmessages v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/ombi v0.0.0-20250716174732-bcb6346f8115 github.com/hrfee/jfa-go/ombi v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/mediabrowser v0.3.29 github.com/hrfee/mediabrowser v0.3.30
github.com/itchyny/timefmt-go v0.1.6 github.com/itchyny/timefmt-go v0.1.7
github.com/lithammer/shortuuid/v3 v3.0.7 github.com/lithammer/shortuuid/v3 v3.0.7
github.com/mailgun/mailgun-go/v4 v4.23.0 github.com/mailgun/mailgun-go/v4 v4.23.0
github.com/mattn/go-sqlite3 v1.14.28 github.com/mattn/go-sqlite3 v1.14.32
github.com/robert-nix/ansihtml v1.0.1 github.com/robert-nix/ansihtml v1.0.1
github.com/steambap/captcha v1.4.1 github.com/steambap/captcha v1.4.1
github.com/swaggo/files v1.0.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/timshannon/badgerhold/v4 v4.0.3
github.com/writeas/go-strip-markdown v2.0.1+incompatible github.com/writeas/go-strip-markdown v2.0.1+incompatible
github.com/xhit/go-simple-mail/v2 v2.16.0 github.com/xhit/go-simple-mail/v2 v2.16.0
gopkg.in/ini.v1 v1.67.0 gopkg.in/ini.v1 v1.67.0
gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mautrix v0.26.0
maunium.net/go/mautrix v0.24.2
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect
github.com/bytedance/sonic v1.13.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.3.0 // 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/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect
github.com/dgraph-io/ristretto v1.0.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect
github.com/getlantern/errors v1.0.4 // indirect github.com/getlantern/errors v1.0.4 // indirect
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 // indirect github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 // indirect
@@ -78,68 +75,73 @@ require (
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 // indirect github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 // indirect
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 // indirect github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-chi/chi/v5 v5.2.2 // indirect github.com/go-chi/chi/v5 v5.2.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonpointer v0.22.3 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.3 // indirect
github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/spec v0.22.1 // indirect
github.com/go-openapi/swag v0.23.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/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/go-stack/stack v1.8.1 // indirect github.com/go-stack/stack v1.8.1 // indirect
github.com/go-test/deep v1.1.0 // indirect github.com/go-test/deep v1.1.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/flatbuffers v25.9.23+incompatible // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.1 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b // indirect github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b // indirect
github.com/mailgun/errors v0.4.0 // indirect github.com/mailgun/errors v0.4.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb // indirect github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
github.com/pkg/errors v0.9.1 // 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/rs/zerolog v1.34.0 // indirect
github.com/sirupsen/logrus v1.9.3 // 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/technoweenie/multipartstreamer v1.0.1 // indirect
github.com/tidwall/gjson v1.18.0 // 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/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect github.com/tidwall/sjson v1.2.5 // indirect
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
go.mau.fi/util v0.8.8 // indirect go.mau.fi/util v0.9.3 // indirect
go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect go.uber.org/mock v0.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect go.uber.org/zap v1.27.1 // indirect
golang.org/x/arch v0.19.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.40.0 // indirect golang.org/x/arch v0.23.0 // indirect
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect golang.org/x/crypto v0.45.0 // indirect
golang.org/x/image v0.29.0 // indirect golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/net v0.42.0 // indirect golang.org/x/image v0.33.0 // indirect
golang.org/x/sys v0.34.0 // indirect golang.org/x/mod v0.30.0 // indirect
golang.org/x/text v0.27.0 // indirect golang.org/x/net v0.47.0 // indirect
golang.org/x/tools v0.35.0 // indirect golang.org/x/sync v0.18.0 // indirect
google.golang.org/protobuf v1.36.6 // 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
) )
+126 -200
View File
@@ -12,19 +12,14 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdko
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 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/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/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/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= 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/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/bytedance/sonic v1.12.4 h1:9Csb3c9ZJhfUWeMtpCDCq6BUoH5ogfDFLUgQ/jG+R0k= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/sonic v1.12.4/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E= github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/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.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
@@ -32,12 +27,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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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/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.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/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/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
@@ -49,23 +40,17 @@ 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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.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 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs=
github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w= 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 v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
github.com/dgraph-io/ristretto v1.0.0 h1:SYG07bONKMlFDUYu5pEu3DGAh8c2OFNzKm6G9J4Si84= github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
github.com/dgraph-io/ristretto v1.0.0/go.mod h1:jTi2FiYEhQ1NsMmA7DeBykizjOuY88NhKBkepyu1jPc= github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-farm v0.0.0-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-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= 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.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 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/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 h1:CgF8+TNFvlnxEbplSgS70ZI4IUFEzVkY+ICNqTVE/AM=
github.com/emersion/go-autostart v0.0.0-20250403115856-34830d6457d2/go.mod h1:buzQsO8HHkZX2Q45fdfGH1xejPjuDQaXH8btcYMFzPM= github.com/emersion/go-autostart v0.0.0-20250403115856-34830d6457d2/go.mod h1:buzQsO8HHkZX2Q45fdfGH1xejPjuDQaXH8btcYMFzPM=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -76,14 +61,10 @@ 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 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 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.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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 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/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc= github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc= github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= github.com/getlantern/context v0.0.0-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 h1:oEZYEpZo28Wdx+5FZo4aU7JFXu0WG/4wJWese5reQSA=
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201/go.mod h1:Y9WZUHEb+mpra02CbQ/QczLUe6f0Dezxaw5DCJlJQGo= github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201/go.mod h1:Y9WZUHEb+mpra02CbQ/QczLUe6f0Dezxaw5DCJlJQGo=
@@ -110,30 +91,21 @@ github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeME
github.com/gin-contrib/gzip v0.0.1/go.mod h1:fGBJBCdt6qCZuCAOwWuFhBB4OOq9EFqlo5dEaFhhu5w= 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 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= 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 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/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-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.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/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= 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-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.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.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.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -141,37 +113,50 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= 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.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= github.com/go-openapi/jsonreference v0.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.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.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc=
github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= github.com/go-openapi/jsonreference v0.19.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.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= 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.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.19.4/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= 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.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.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.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.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-openapi/swag/conv v0.25.3 h1:PcB18wwfba7MN5BVlBIV+VxvUUeC2kEuCEyJ2/t2X7E=
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= github.com/go-openapi/swag/conv v0.25.3/go.mod h1:n4Ibfwhn8NJnPXNRhBO5Cqb9ez7alBR40JS4rbASUPU=
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= 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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 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 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 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.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.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 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
@@ -179,12 +164,11 @@ github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaEL
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM= github.com/go-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 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 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 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/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 h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
@@ -194,10 +178,7 @@ 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/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-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-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -211,20 +192,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.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 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.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.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4/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-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/flatbuffers 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 v23.5.9+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI= github.com/google/flatbuffers v25.9.23+incompatible h1:rGZKv+wOb6QPzIdkM2KxhBZCDrA0DeN6DNmRDrqIsQU=
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/flatbuffers v25.9.23+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.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.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -234,8 +209,8 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.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.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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/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.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -245,15 +220,11 @@ 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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hrfee/mediabrowser v0.3.28 h1:KkSgODXxUnZLrkmjSWpma8mXwEVxlOtI51uS2QP/e+c= github.com/hrfee/mediabrowser v0.3.30 h1:llJo4hxWchbwROnkfhlYsrvtZ6/8WDTp3QxAvbgjUfI=
github.com/hrfee/mediabrowser v0.3.28/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= github.com/hrfee/mediabrowser v0.3.30/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/hrfee/mediabrowser v0.3.29 h1:xTqGS9u8HuolZAhouYHxutnE0fF/8aVCInbByKZEzIo=
github.com/hrfee/mediabrowser v0.3.29/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/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.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.6/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= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -262,16 +233,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/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.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.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -291,19 +256,12 @@ github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b h1:xZ59n
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b/go.mod h1:uDd4sYVYsqcxAB8j+Q7uhL6IJCs/r1kxib1HV4bgOMg= 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 h1:6LFBvod6VIW83CMIOT9sYNp28TCX0NejFPP4dSX++i8=
github.com/mailgun/errors v0.4.0/go.mod h1:xGBaaKdEdQT0/FhwvoXv4oBaqqmVZz9P1XEnvD/onc0= 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 h1:jPEMJzzin2s7lvehcfv/0UkyBu18GvcURPr2+xtZRbk=
github.com/mailgun/mailgun-go/v4 v4.23.0/go.mod h1:imTtizoFtpfZqPqGP8vltVBB6q9yWcv6llBhfFeElZU= 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-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-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/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/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.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.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 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -315,10 +273,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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -330,28 +286,24 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= 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/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 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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 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/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274 h1:qli3BGQK0tYDkSEvZ/FzZTi9ZrOX86Q6CIhKLGc489A= github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a h1:VweslR2akb/ARhXfqSfRbj1vpWwYXf3eeAUyw/ndms0=
github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb h1:3PrKuO92dUTMrQ9dx0YNejC6U/Si6jqKmyQ9vWjwqR4=
github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/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 h1:VTiyQ6/+AxSJoSSLsMecnkh8i0ZqOEdiRl/odOc64fc=
github.com/robert-nix/ansihtml v1.0.1/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yDXcNeHHKZINE= 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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 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 v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
@@ -373,6 +325,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.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 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.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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -381,25 +334,28 @@ 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.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.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 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 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= 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.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.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= 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.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.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.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= 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 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog= github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 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.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.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
@@ -408,8 +364,6 @@ 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 h1:W6pd2qckoXw2cl8eH0ZCV/9CXNaXvaM26tzFi5Tj+v8=
github.com/timshannon/badgerhold/v4 v4.0.3/go.mod h1:IkZIr0kcZLMdD7YJfW/G6epb6ZXHD/h0XR2BTk/VZg8= 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-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 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA=
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= 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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@@ -419,10 +373,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-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 v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v1.1.5-pre/go.mod h1:tULtS6Gy1AE1yCENaw4Vb//HLH5njI2tfCQDUqRd8fI= github.com/ugorji/go/codec v1.1.5-pre/go.mod h1:tULtS6Gy1AE1yCENaw4Vb//HLH5njI2tfCQDUqRd8fI=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw= github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
@@ -434,43 +386,36 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 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= 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.9.3 h1:aqNF8KDIN8bFpFbybSk+mEBil7IHeBwlujfyTnvP0uU=
go.mau.fi/util v0.8.1/go.mod h1:T1u/rD2rzidVrBLyaUdPpZiJdP/rsyi+aTzn0D+Q6wc= go.mau.fi/util v0.9.3/go.mod h1:krWWfBM1jWTb5f8NCa2TLqWMQuM81X7TGQjhMjBeXmQ=
go.mau.fi/util v0.8.8 h1:OnuEEc/sIJFhnq4kFggiImUpcmnmL/xpvQMRu5Fiy5c=
go.mau.fi/util v0.8.8/go.mod h1:Y/kS3loxTEhy8Vill513EtPXr+CRDdae+Xj2BXXMy/c=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.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.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.9.0/go.mod h1:np4EoPGzoPs3O67xUVNoPPcmSvsfOxNlNA4F4AC+0Eo= 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.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.9.0/go.mod h1:2737Q0MuG8q1uILYm2YYVkAyLtOofiTNGg6VODnOiPo= go.opentelemetry.io/otel/trace v1.9.0/go.mod h1:2737Q0MuG8q1uILYm2YYVkAyLtOofiTNGg6VODnOiPo=
go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.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.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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 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 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 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.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.19.0 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU= golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.19.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= 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-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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@@ -478,20 +423,14 @@ 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-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-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-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-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc=
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/image v0.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.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78= golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-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-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-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@@ -501,9 +440,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-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-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -525,10 +463,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 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.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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/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-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -540,9 +476,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-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.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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-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-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -570,10 +505,8 @@ 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -585,10 +518,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.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.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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 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-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-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -603,10 +536,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -635,10 +566,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-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.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.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.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 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-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -658,8 +587,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/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.26.0 h1:valc2VmZF+oIY4bMq4Cd5H9cEKMRe8eP4FM7iiaYLxI=
maunium.net/go/mautrix v0.21.1/go.mod h1:7F/S6XAdyc/6DW+Q7xyFXRSPb6IjfqMb1OMepQ8C8OE= maunium.net/go/mautrix v0.26.0/go.mod h1:NWMv+243NX/gDrLofJ2nNXJPrG8vzoM+WUCWph85S6Q=
maunium.net/go/mautrix v0.24.2 h1:+AVT5kbcA/QuT5svrJKp4ivwoUmz+RRplMp3DnfpheI=
maunium.net/go/mautrix v0.24.2/go.mod h1:1ut900w++eE9by9yqCR2dQdMqwsHwZG5L+1bKB1EvSA=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+16
View File
@@ -0,0 +1,16 @@
{{ if .discordEnabled }}
<div id="modal-discord" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkDiscord }}</span>
<p class="content mb-4"> {{ .discordSendPINMessage }}</p>
<h1 class="text-center text-2xl mb-2 pin"></h1>
<div class="row center">
<a class="my-5 hover:underline">
<span class="mr-2">{{ .strings.joinTheServer }}</span>
<span id="discord-invite"></span>
</a>
</div>
<span class="button ~info @low full-width center mt-4" id="discord-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}
+18
View File
@@ -0,0 +1,18 @@
{{ if .matrixEnabled }}
<div id="modal-matrix" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkMatrix }}</span>
<p class="content mb-4"> {{ .strings.matrixEnterUser }}</p>
<input type="text" class="input ~neutral @high" placeholder="@user:riot.im" id="matrix-userid">
<div class="subheading link-center mt-4">
<span class="shield ~info mr-4">
<span class="icon">
<i class="ri-chat-3-line"></i>
</span>
</span>
{{ .matrixUser }}
</div>
<span class="button ~info @low full-width center mt-4" id="matrix-send">{{ .strings.submit }}</span>
</div>
</div>
{{ end }}
+18
View File
@@ -0,0 +1,18 @@
{{ if .telegramEnabled }}
<div id="modal-telegram" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkTelegram }}</span>
<p class="content mb-4">{{ .strings.sendPIN }}</p>
<p class="text-center text-2xl mb-2 pin"></p>
<a class="subheading link link-center" href="{{ .telegramURL }}" target="_blank">
<span class="shield ~info mr-4">
<span class="icon">
<i class="ri-telegram-line"></i>
</span>
</span>
&#64;<span class="username">{{ .telegramUsername }}</span>
</a>
<span class="button ~info @low full-width center mt-4" id="telegram-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}
+3 -52
View File
@@ -1,52 +1,3 @@
{{ if .discordEnabled }} {{ template "account-linking-discord.html" . }}
<div id="modal-discord" class="modal"> {{ template "account-linking-telegram.html" . }}
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3"> {{ template "account-linking-matrix.html" . }}
<span class="heading mb-4">{{ .strings.linkDiscord }}</span>
<p class="content mb-4"> {{ .discordSendPINMessage }}</p>
<h1 class="text-center text-2xl mb-2 pin"></h1>
<div class="row center">
<a class="my-5 hover:underline">
<span class="mr-2">{{ .strings.joinTheServer }}</span>
<span id="discord-invite"></span>
</a>
</div>
<span class="button ~info @low full-width center mt-4" id="discord-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}
{{ if .telegramEnabled }}
<div id="modal-telegram" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkTelegram }}</span>
<p class="content mb-4">{{ .strings.sendPIN }}</p>
<p class="text-center text-2xl mb-2 pin"></p>
<a class="subheading link-center" href="{{ .telegramURL }}" target="_blank">
<span class="shield ~info mr-4">
<span class="icon">
<i class="ri-telegram-line"></i>
</span>
</span>
&#64;{{ .telegramUsername }}
</a>
<span class="button ~info @low full-width center mt-4" id="telegram-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}
{{ if .matrixEnabled }}
<div id="modal-matrix" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkMatrix }}</span>
<p class="content mb-4"> {{ .strings.matrixEnterUser }}</p>
<input type="text" class="input ~neutral @high" placeholder="@user:riot.im" id="matrix-userid">
<div class="subheading link-center mt-4">
<span class="shield ~info mr-4">
<span class="icon">
<i class="ri-chat-3-line"></i>
</span>
</span>
{{ .matrixUser }}
</div>
<span class="button ~info @low full-width center mt-4" id="matrix-send">{{ .strings.submit }}</span>
</div>
</div>
{{ end }}
+86 -65
View File
@@ -1,6 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}"> <html lang="en" class="{{ .cssClass }}">
<head> <head>
{{ template "syntaxhighlighting.html" . }}
<script> <script>
window.usernameEnabled = {{ .username }}; window.usernameEnabled = {{ .username }};
window.langFile = JSON.parse({{ .language }}); window.langFile = JSON.parse({{ .language }});
@@ -68,11 +69,18 @@
</div> </div>
</div> </div>
<div id="modal-logs" class="modal"> <div id="modal-logs" class="modal">
<div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content content card"> <div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
<span class="heading">{{ .strings.logs }}<span class="modal-close">&times;</span></span> <span class="heading">{{ .strings.logs }}<span class="modal-close">&times;</span></span>
<pre class="monospace" id="log-area"></pre> <pre class="monospace" id="log-area"></pre>
</div> </div>
</div> </div>
<div id="modal-tasks" class="modal">
<div class="relative mx-auto my-[10%] w-min card flex flex-col gap-2">
<h1 class="heading">{{ .strings.tasks }}<span class="modal-close">&times;</span></h1>
<p class="content">{{ .strings.tasksDescription }}</p>
<div id="modal-tasks-list" class="flex flex-col gap-2"></div>
</div>
</div>
<div id="modal-modify-user" class="modal"> <div id="modal-modify-user" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-modify-user" href=""> <form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-modify-user" href="">
<span class="heading"><span id="header-modify-user"></span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-modify-user"></span> <span class="modal-close">&times;</span></span>
@@ -102,14 +110,18 @@
<input type="checkbox" id="modify-user-homescreen" checked> <input type="checkbox" id="modify-user-homescreen" checked>
<span>{{ .strings.applyHomescreenLayout }}</span> <span>{{ .strings.applyHomescreenLayout }}</span>
</label> </label>
{{ if .ombiEnabled }}
<label class="switch"> <label class="switch">
<input type="checkbox" id="modify-user-ombi" checked> <input type="checkbox" id="modify-user-ombi" checked>
<span>{{ .strings.applyOmbi }}</span> <span>{{ .strings.applyOmbi }}</span>
</label> </label>
{{ end }}
{{ if .jellyseerrEnabled }}
<label class="switch"> <label class="switch">
<input type="checkbox" id="modify-user-jellyseerr" checked> <input type="checkbox" id="modify-user-jellyseerr" checked>
<span>{{ .strings.applyJellyseerr }}</span> <span>{{ .strings.applyJellyseerr }}</span>
</label> </label>
{{ end }}
<label> <label>
<input type="submit" class="unfocused"> <input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span> <span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
@@ -186,56 +198,60 @@
</form> </form>
</div> </div>
<div id="modal-extend-expiry" class="modal"> <div id="modal-extend-expiry" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-extend-expiry" href=""> <form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 flex flex-col gap-2" id="form-extend-expiry" href="">
<span class="heading"><span id="header-extend-expiry"></span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-extend-expiry"></span> <span class="modal-close">&times;</span></span>
<div class="content mt-8"> <div class="flex flex-col gap-3">
<aside class="aside sm ~urge dark:~d_info mb-2 @low row unfocused" id="extend-expiry-date"></aside> <aside class="aside sm ~urge dark:~d_info @low unfocused" id="extend-expiry-date"></aside>
<div> <div class="flex flex-col gap-2">
<span class="text-xl supra row py-1">{{ .strings.setExpiry }}</span> <span class="text-xl supra">{{ .strings.setExpiry }}</span>
<div class="row"> <input type="text" id="extend-expiry-text" class="input ~neutral @low" placeholder="{{ .strings.enterExpiry }}">
<input type="text" id="extend-expiry-text" class="input ~neutral @low mb-2 mt-4" placeholder="{{ .strings.enterExpiry }}">
</div> </div>
</div> <div id="extend-expiry-field-inputs" class="flex flex-col gap-2">
<div id="extend-expiry-field-inputs"> <span class="text-xl supra">{{ .strings.extendExpiry }}</span>
<span class="text-xl supra row py-1">{{ .strings.extendExpiry }}</span> <div class="grid grid-cols-2 grid-rows-2 gap-2">
<div class="row"> <div class="flex flex-col gap-2">
<div class="col">
<label class="label supra" for="extend-expiry-months">{{ .strings.inviteMonths }}</label> <label class="label supra" for="extend-expiry-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral @low mb-2 mt-4"> <div class="select ~neutral @low">
<select id="extend-expiry-months"> <select id="extend-expiry-months">
<option>0</option> <option>0</option>
</select> </select>
</div> </div>
</div> </div>
<div class="col"> <div class="flex flex-col gap-2">
<label class="label supra" for="extend-expiry-days">{{ .strings.inviteDays }}</label> <label class="label supra" for="extend-expiry-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral @low mb-2 mt-4"> <div class="select ~neutral @low">
<select id="extend-expiry-days"> <select id="extend-expiry-days">
<option>0</option> <option>0</option>
</select> </select>
</div> </div>
</div> </div>
</div> <div class="flex flex-col gap-2">
<div class="row">
<div class="col">
<label class="label supra" for="extend-expiry-hours">{{ .strings.inviteHours }}</label> <label class="label supra" for="extend-expiry-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral @low mb-2 mt-4"> <div class="select ~neutral @low">
<select id="extend-expiry-hours"> <select id="extend-expiry-hours">
<option>0</option> <option>0</option>
</select> </select>
</div> </div>
</div> </div>
<div class="col"> <div class="flex flex-col gap-2">
<label class="label supra" for="extend-expiry-minutes">{{ .strings.inviteMinutes }}</label> <label class="label supra" for="extend-expiry-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral @low mb-2 mt-4"> <div class="select ~neutral @low">
<select id="extend-expiry-minutes"> <select id="extend-expiry-minutes">
<option>0</option> <option>0</option>
</select> </select>
</div> </div>
</div> </div>
</div> </div>
<label class="switch">
<input type="checkbox" id="expiry-use-previous">
<span>{{ .strings.extendFromPreviousExpiry }}</span>
<div class="tooltip left">
<i class="icon ri-information-line align-middle"></i>
<div class="content sm w-max">{{ .strings.extendFromPreviousExpiryDescription }}</div>
</div> </div>
<label class="switch mb-4"> </label>
</div>
<label class="switch">
<input type="checkbox" id="expiry-extend-enable" checked> <input type="checkbox" id="expiry-extend-enable" checked>
<span>{{ .strings.sendDeleteNotificationEmail }}</span> <span>{{ .strings.sendDeleteNotificationEmail }}</span>
</label> </label>
@@ -441,6 +457,7 @@
{{ end }} {{ end }}
<th>{{ .strings.from }}</th> <th>{{ .strings.from }}</th>
<th>{{ .strings.userProfilesLibraries }}</th> <th>{{ .strings.userProfilesLibraries }}</th>
<th></th>
<th><span class="button ~neutral @high" id="button-profile-create">{{ .strings.create }}</span></th> <th><span class="button ~neutral @high" id="button-profile-create">{{ .strings.create }}</span></th>
</tr> </tr>
</thead> </thead>
@@ -449,26 +466,44 @@
</div> </div>
</div> </div>
</div> </div>
<div id="modal-edit-profile" class="modal">
<form class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-2/3 card flex flex-col gap-2" id="form-edit-profile">
<span class="heading">{{ .strings.editProfile }} <span class="modal-close">&times;</span></span>
<p class="content">{{ .strings.editProfileDescription }}</p>
<div id="modal-edit-profile-editor"></div>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.submit }}</span>
</label>
</form>
</div>
<div id="modal-add-profile" class="modal"> <div id="modal-add-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-add-profile" href=""> <form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 flex flex-col gap-2" id="form-add-profile" href="">
<span class="heading">{{ .strings.addProfile }} <span class="modal-close">&times;</span></span> <h1 class="heading">{{ .strings.addProfile }} <span class="modal-close">&times;</span></h1>
<p class="content my-4">{{ .strings.addProfileDescription }}</p> <p class="content">{{ .strings.addProfileDescription }}</p>
<label> <label class="flex flex-col gap-2">
<span class="supra">{{ .strings.addProfileNameOf }} </span> <span class="supra">{{ .strings.addProfileNameOf }} </span>
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.name }}" id="add-profile-name"> <input type="text" class="field input ~neutral @high" placeholder="{{ .strings.name }}" id="add-profile-name">
<label> </label>
<label class="flex flex-col gap-2">
<span class="supra">{{ .strings.user }}</span> <span class="supra">{{ .strings.user }}</span>
<div class="select ~neutral @low mt-4 mb-2"> <div class="select ~neutral @low">
<select id="add-profile-user"></select> <select id="add-profile-user"></select>
</div> </div>
</label> </label>
<label class="switch mb-4"> <label class="switch">
<input type="checkbox" id="add-profile-homescreen" checked> <input type="checkbox" id="add-profile-homescreen" checked>
<span>{{ .strings.addProfileStoreHomescreenLayout }}</span> <span>{{ .strings.addProfileStoreHomescreenLayout }}</span>
</label> </label>
{{ if .jellyseerrEnabled }}
<label class="switch">
<input type="checkbox" id="add-profile-jellyseerr" checked>
<span>{{ .strings.addProfileStoreJellyseerr }}</span>
</label>
{{ end }}
<label> <label>
<input type="submit" class="unfocused"> <input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.create }}</span> <span class="button ~urge @low w-full center supra submit">{{ .strings.create }}</span>
</label> </label>
</form> </form>
</div> </div>
@@ -487,24 +522,7 @@
<span class="button ~urge @low full-width center mt-2" id="update-update">{{ .strings.update }}</span> <span class="button ~urge @low full-width center mt-2" id="update-update">{{ .strings.update }}</span>
</div> </div>
</div> </div>
{{ if .telegramEnabled }} {{ template "account-linking-telegram.html" . }}
<div id="modal-telegram" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkTelegram }}</span>
<p class="content mb-4">{{ .strings.sendPIN }}</p>
<h1 class="ac" id="telegram-pin"></h1>
<a class="subheading link-center" id="telegram-link" target="_blank">
<span class="shield ~info mr-2">
<span class="icon">
<i class="ri-telegram-line"></i>
</span>
</span>
&#64;<span id="telegram-username">
</a>
<span class="button ~info @low full-width center mt-4" id="telegram-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}
{{ if .discordEnabled }} {{ if .discordEnabled }}
<div id="modal-discord" class="modal"> <div id="modal-discord" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3"> <div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3">
@@ -557,7 +575,7 @@
<div id="tab-invites" class="flex flex-col gap-4"> <div id="tab-invites" class="flex flex-col gap-4">
<div class="card @low dark:~d_neutral flex flex-col gap-2 overflow-visible invites"> <div class="card @low dark:~d_neutral flex flex-col gap-2 overflow-visible invites">
<span class="heading">{{ .strings.invites }}</span> <span class="heading">{{ .strings.invites }}</span>
<div id="invites"></div> <div id="invites" class="flex flex-col gap-2"></div>
</div> </div>
<div class="card @low dark:~d_neutral flex flex-col gap-2"> <div class="card @low dark:~d_neutral flex flex-col gap-2">
<span class="heading">{{ .strings.create }}</span> <span class="heading">{{ .strings.create }}</span>
@@ -726,7 +744,7 @@
<input type="search" class="field ~neutral @low input search mr-2" id="accounts-search" placeholder="{{ .strings.search }}"> <input type="search" class="field ~neutral @low input search mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center inside-input rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span> <span class="button ~neutral @low center inside-input rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<div class="tooltip left"> <div class="tooltip left">
<button class="button ~info @low center h-full accounts-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}"> <button class="button ~info @low center h-full accounts-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i> <i class="ri-search-line"></i>
<span>{{ .strings.searchAll }}</span> <span>{{ .strings.searchAll }}</span>
</button> </button>
@@ -820,8 +838,9 @@
<span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span> <span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span>
<span class="text-sm font-light italic unfocused text-center" id="accounts-no-local-results">{{ .strings.noResultsFoundLocally }}</span> <span class="text-sm font-light italic unfocused text-center" id="accounts-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
<div class="flex flex-row"> <div class="flex flex-row">
<button class="button ~neutral @low accounts-search-clear flex flex-row gap-2"> <button class="button ~neutral @low accounts-search-clear gap-1">
<span>{{ .strings.clearSearch }}</span><i class="ri-close-line"></i> <i class="ri-close-line"></i>
<span>{{ .strings.clearSearch }}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -829,7 +848,7 @@
<div class="flex flex-row gap-2 justify-center"> <div class="flex flex-row gap-2 justify-center">
<button class="button ~neutral @low" id="accounts-load-more">{{ .strings.loadMore }}</button> <button class="button ~neutral @low" id="accounts-load-more">{{ .strings.loadMore }}</button>
<button class="button ~neutral @low accounts-load-all">{{ .strings.loadAll }}</button> <button class="button ~neutral @low accounts-load-all">{{ .strings.loadAll }}</button>
<button class="button ~info @low center accounts-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}"> <button class="button ~info @low center accounts-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i> <i class="ri-search-line"></i>
<span>{{ .strings.searchAllRecords }}</span> <span>{{ .strings.searchAllRecords }}</span>
</button> </button>
@@ -852,7 +871,7 @@
<input type="search" class="field ~neutral @low input search mr-2" id="activity-search" placeholder="{{ .strings.search }}"> <input type="search" class="field ~neutral @low input search mr-2" id="activity-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center inside-input rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span> <span class="button ~neutral @low center inside-input rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<div class="tooltip left"> <div class="tooltip left">
<button class="button ~info @low center h-full activity-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}"> <button class="button ~info @low center h-full activity-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i> <i class="ri-search-line"></i>
<span>{{ .strings.searchAll }}</span> <span>{{ .strings.searchAll }}</span>
</button> </button>
@@ -879,8 +898,9 @@
<span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span> <span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span>
<span class="text-sm font-light italic unfocused text-center" id="activity-no-local-results">{{ .strings.noResultsFoundLocally }}</span> <span class="text-sm font-light italic unfocused text-center" id="activity-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
<div class="flex flex-row"> <div class="flex flex-row">
<button class="button ~neutral @low activity-search-clear flex flex-row gap-2"> <button class="button ~neutral @low activity-search-clear gap-1">
<span>{{ .strings.clearSearch }}</span><i class="ri-close-line"></i> <i class="ri-close-line"></i>
<span>{{ .strings.clearSearch }}</span>
</button> </button>
<button class="button ~neutral @low unfocused" id="activity-keep-searching">{{ .strings.keepSearching }}</button> <button class="button ~neutral @low unfocused" id="activity-keep-searching">{{ .strings.keepSearching }}</button>
</div> </div>
@@ -891,7 +911,7 @@
<div class="flex flex-row gap-2 justify-center"> <div class="flex flex-row gap-2 justify-center">
<button class="button ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button> <button class="button ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button>
<button class="button ~neutral @low activity-load-all">{{ .strings.loadAll }}</button> <button class="button ~neutral @low activity-load-all">{{ .strings.loadAll }}</button>
<button class="button ~info @low center activity-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}"> <button class="button ~info @low center activity-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i> <i class="ri-search-line"></i>
<span>{{ .strings.searchAllRecords }}</span> <span>{{ .strings.searchAllRecords }}</span>
</button> </button>
@@ -909,26 +929,27 @@
</label> </label>
</div> </div>
<div class="flex flex-row justify-start md:justify-end gap-2 w-full"> <div class="flex flex-row justify-start md:justify-end gap-2 w-full">
<span class="button ~neutral @low gap-1 unfocused" id="settings-tasks"><i class="ri-calendar-schedule-line"></i>{{ .strings.tasks }}</span>
<span class="button ~neutral @low" id="settings-logs">{{ .strings.logs }}</span> <span class="button ~neutral @low" id="settings-logs">{{ .strings.logs }}</span>
<span class="button ~info @low" id="settings-backups">{{ .strings.backups }}</span> <span class="button ~info @low gap-1" id="settings-backups"><i class="icon ri-file-copy-line"></i>{{ .strings.backups }}</span>
<span class="button ~neutral @low" id="settings-restart">{{ .strings.settingsRestart }}</span> <span class="button ~neutral @low gap-1" id="settings-restart"><i class="icon ri-restart-line"></i>{{ .strings.settingsRestart }}</span>
<span class="button ~urge @low unfocused" id="settings-save">{{ .strings.settingsSave }}</span> <span class="button ~urge @low unfocused gap-1" id="settings-save"><i class="icon ri-save-line"></i>{{ .strings.settingsSave }}</span>
</div> </div>
</div> </div>
<div class="flex flex-col md:flex-row gap-3"> <div class="flex flex-col md:flex-row gap-3">
<div class="md:card @low dark:~d_neutral flex md:flex flex-col gap-2 flex-1" id="settings-sidebar"> <div class="@low dark:~d_neutral flex md:flex flex-col gap-2" id="settings-sidebar">
<div class="flex flex-row justify-between"> <div class="flex flex-row justify-between">
<input type="search" class="field ~neutral @low input settings-section-button justify-between" id="settings-search" placeholder="{{ .strings.search }}"> <input type="search" class="field ~neutral @low input settings-section-button justify-between" id="settings-search" placeholder="{{ .strings.search }}">
<button class="button ~neutral @low center -ml-10 rounded-s-none settings-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></button> <button class="button ~neutral @low center -ml-10 rounded-s-none settings-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></button>
</div> </div>
<aside class="aside sm ~urge dark:~d_info @low" id="settings-message">Note: <span class="badge ~critical">*</span> indicates a required field, <span class="badge ~info dark:~d_warning">R</span> indicates changes require a restart.</aside>
<div id="settings-loader" class="flex flex-row flex-wrap gap-2"> <div id="settings-loader" class="flex flex-row flex-wrap gap-2">
<span class="button ~neutral @low justify-center grow" id="setting-about"><span class="flex">{{ .strings.aboutProgram }} <i class="ri-information-line ml-2"></i></span></span> <span class="button ~neutral @low justify-center grow" id="setting-about"><span class="flex">{{ .strings.aboutProgram }} <i class="ri-information-line ml-2"></i></span></span>
<a class="button ~urge dark:~d_info @low justify-center grow" target="_blank" href="https://wiki.jfa-go.com"><span class="flex">{{ .strings.wiki }} <i class="ri-book-shelf-line ml-2"></i></a> <a class="button ~urge dark:~d_info @low justify-center grow" target="_blank" href="https://wiki.jfa-go.com"><span class="flex">{{ .strings.wiki }} <i class="ri-book-shelf-line ml-2"></i></a>
<span class="button ~neutral @low justify-center grow" id="setting-profiles"><span class="flex">{{ .strings.userProfiles }} <i class="ri-user-line ml-2"></i></span></span> <span class="button ~neutral @low justify-center grow" id="setting-profiles"><span class="flex">{{ .strings.userProfiles }} <i class="ri-user-line ml-2"></i></span></span>
</div> </div>
<div class="flex md:flex flex-col gap-2 overflow-y-scroll" id="settings-sidebar-items"></div>
</div> </div>
<div class="card ~neutral @low overflow flex-1" id="settings-panel"> <div class="card ~neutral @low overflow flex-1 grow" id="settings-panel">
<div class="settings-section unfocused h-[100%]" id="settings-not-found"> <div class="settings-section unfocused h-[100%]" id="settings-not-found">
<div class="flex flex-col h-[100%] justify-center items-center"> <div class="flex flex-col h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-2">{{ .strings.noResultsFound }}</span> <span class="text-2xl font-medium italic mb-2">{{ .strings.noResultsFound }}</span>
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<!--- This CSS is inlined so we should keep this here! --> <!--- This CSS is inlined so we should keep this here! -->
<link inline rel="stylesheet" type="text/css" href="web/css/v3bundle.css"> <link inline rel="stylesheet" type="text/css" href="web/css/v0.6.0bundle.css">
{{ template "header.html" . }} {{ template "header.html" . }}
<title>Crash report</title> <title>Crash report</title>
</head> </head>
+1
View File
@@ -27,6 +27,7 @@
window.reCAPTCHASiteKey = "{{ .reCAPTCHASiteKey }}"; window.reCAPTCHASiteKey = "{{ .reCAPTCHASiteKey }}";
window.userPageEnabled = {{ .userPageEnabled }}; window.userPageEnabled = {{ .userPageEnabled }};
window.userPageAddress = "{{ .userPageAddress }}"; window.userPageAddress = "{{ .userPageAddress }}";
window.collectEmail = {{ .collectEmail }};
{{ if index . "customSuccessCard" }} {{ if index . "customSuccessCard" }}
window.customSuccessCard = {{ .customSuccessCard }}; window.customSuccessCard = {{ .customSuccessCard }};
{{ else }} {{ else }}
+2
View File
@@ -68,8 +68,10 @@
<input type="text" class="input ~neutral @high mt-2 mb-4" placeholder="{{ .strings.username }}" id="create-username" aria-label="{{ .strings.username }}"> <input type="text" class="input ~neutral @high mt-2 mb-4" placeholder="{{ .strings.username }}" id="create-username" aria-label="{{ .strings.username }}">
</label> </label>
<div>
<label class="label supra" for="create-email">{{ .strings.emailAddress }}</label> <label class="label supra" for="create-email">{{ .strings.emailAddress }}</label>
<input type="email" class="input ~neutral @high mt-2 mb-4" placeholder="{{ .strings.emailAddress }}" id="create-email" aria-label="{{ .strings.emailAddress }}" value="{{ .email }}"> <input type="email" class="input ~neutral @high mt-2 mb-4" placeholder="{{ .strings.emailAddress }}" id="create-email" aria-label="{{ .strings.emailAddress }}" value="{{ .email }}">
</div>
{{ if .telegramEnabled }} {{ if .telegramEnabled }}
<span class="button ~info @low full-width center mb-4" id="link-telegram">{{ .strings.linkTelegram }} {{ if .telegramRequired }}({{ .strings.required }}){{ end }}</span> <span class="button ~info @low full-width center mb-4" id="link-telegram">{{ .strings.linkTelegram }} {{ if .telegramRequired }}({{ .strings.required }}){{ end }}</span>
{{ end }} {{ end }}
+1 -1
View File
@@ -106,7 +106,7 @@
<p class="support">{{ .lang.General.urlBaseNotice }}</p> <p class="support">{{ .lang.General.urlBaseNotice }}</p>
</label> </label>
<label class="label flex flex-col gap-2"> <label class="label flex flex-col gap-2">
<span>{{ .lang.General.externalURL }} ({{ .lang.required }})</span> <span>{{ .lang.General.externalURL }} ({{ .lang.Strings.required }})</span>
<input type="text" class="input ~neutral @low" id="ui-jfa_url" placeholder="https://jellyf.in/mysubfolder"> <input type="text" class="input ~neutral @low" id="ui-jfa_url" placeholder="https://jellyf.in/mysubfolder">
<p class="support">{{ .lang.General.externalURLNotice }}</p> <p class="support">{{ .lang.General.externalURLNotice }}</p>
</label> </label>
+3
View File
@@ -0,0 +1,3 @@
<link rel="stylesheet" type="text/css" href="{{ .pages.Base }}/css/{{ .cssVersion }}highlightjs-light.css" data-theme="light">
<link rel="stylesheet" type="text/css" href="{{ .pages.Base }}/css/{{ .cssVersion }}highlightjs-dark.css" data-theme="dark">
<link rel="stylesheet" type="text/css" href="{{ .pages.Base }}/css/{{ .cssVersion }}code-input.css">
+9 -45
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 52 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 59 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 73 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 100 KiB

+4 -6
View File
@@ -6,7 +6,8 @@ package main
import ( import (
"embed" "embed"
"io/fs" "io/fs"
"log"
"github.com/hrfee/jfa-go/logger"
) )
const binaryType = "internal" const binaryType = "internal"
@@ -19,9 +20,6 @@ var loFS embed.FS
//go:embed lang/common lang/admin lang/email lang/form lang/setup lang/pwreset lang/telegram //go:embed lang/common lang/admin lang/email lang/form lang/setup lang/pwreset lang/telegram
var laFS embed.FS var laFS embed.FS
var langFS rewriteFS
var localFS rewriteFS
type rewriteFS struct { type rewriteFS struct {
fs embed.FS fs embed.FS
prefix string prefix string
@@ -38,8 +36,8 @@ func FSJoin(elem ...string) string {
return out[:len(out)-1] return out[:len(out)-1]
} }
func loadFilesystems() { func loadFilesystems(rootDir string, logger *logger.Logger) {
langFS = rewriteFS{laFS, "lang/"} langFS = rewriteFS{laFS, "lang/"}
localFS = rewriteFS{loFS, "data/"} localFS = rewriteFS{loFS, "data/"}
log.Println("Using internal storage") logger.Println("Using internal storage")
} }
+30 -2
View File
@@ -2,14 +2,20 @@ package main
import ( import (
"strconv" "strconv"
"strings"
"time" "time"
"github.com/hrfee/jfa-go/jellyseerr" "github.com/hrfee/jfa-go/jellyseerr"
lm "github.com/hrfee/jfa-go/logmessages" lm "github.com/hrfee/jfa-go/logmessages"
) )
type JellyseerrInitialSyncStatus struct {
Done bool
}
// Ensure the Jellyseerr cache is up to date before calling.
func (app *appContext) SynchronizeJellyseerrUser(jfID string) { func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
user, imported, err := app.js.GetOrImportUser(jfID) user, imported, err := app.js.GetOrImportUser(jfID, true)
if err != nil { if err != nil {
app.debug.Printf(lm.FailedImportUser, lm.Jellyseerr, jfID, err) app.debug.Printf(lm.FailedImportUser, lm.Jellyseerr, jfID, err)
return return
@@ -28,7 +34,11 @@ func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
if ok && email.Addr != "" && user.Email != email.Addr { if ok && email.Addr != "" && user.Email != email.Addr {
err = app.js.ModifyMainUserSettings(jfID, jellyseerr.MainUserSettings{Email: email.Addr}) err = app.js.ModifyMainUserSettings(jfID, jellyseerr.MainUserSettings{Email: email.Addr})
if err != nil { if err != nil {
if strings.Contains(err.Error(), "INVALID_EMAIL") {
app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, jfID, err.Error()+"\""+email.Addr+"\"")
} else {
app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, jfID, err) app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, jfID, err)
}
} else { } else {
contactMethods[jellyseerr.FieldEmailEnabled] = email.Contact contactMethods[jellyseerr.FieldEmailEnabled] = email.Contact
} }
@@ -45,7 +55,7 @@ func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
chatID, _ := strconv.ParseInt(notif.TelegramChatID, 10, 64) chatID, _ := strconv.ParseInt(notif.TelegramChatID, 10, 64)
if ok && tgUser.ChatID != 0 && chatID != tgUser.ChatID { if ok && tgUser.ChatID != 0 && chatID != tgUser.ChatID {
u, _ := app.storage.GetTelegramKey(jfID) u, _ := app.storage.GetTelegramKey(jfID)
contactMethods[jellyseerr.FieldTelegram] = u.ChatID contactMethods[jellyseerr.FieldTelegram] = strconv.FormatInt(u.ChatID, 10)
contactMethods[jellyseerr.FieldTelegramEnabled] = tgUser.Contact contactMethods[jellyseerr.FieldTelegramEnabled] = tgUser.Contact
} }
} }
@@ -58,19 +68,30 @@ func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
} }
func (app *appContext) SynchronizeJellyseerrUsers() { func (app *appContext) SynchronizeJellyseerrUsers() {
jsSync := JellyseerrInitialSyncStatus{}
app.storage.db.Get("jellyseerr_inital_sync_status", &jsSync)
if jsSync.Done {
return
}
users, err := app.jf.GetUsers(false) users, err := app.jf.GetUsers(false)
if err != nil { if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err) app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
return return
} }
app.js.ReloadCache()
// I'm sure Jellyseerr can handle it, // I'm sure Jellyseerr can handle it,
// but past issues with the Jellyfin db scare me from // but past issues with the Jellyfin db scare me from
// running these concurrently. W/e, its a bg task anyway. // running these concurrently. W/e, its a bg task anyway.
for _, user := range users { for _, user := range users {
app.SynchronizeJellyseerrUser(user.ID) app.SynchronizeJellyseerrUser(user.ID)
} }
// Don't run again until this flag is unset
// Stored in the DB as it's not something the user needs to see.
app.storage.db.Upsert("jellyseerr_inital_sync_status", JellyseerrInitialSyncStatus{true})
} }
// Not really a normal daemon, since it'll only fire once when the feature is enabled.
func newJellyseerrDaemon(interval time.Duration, app *appContext) *GenericDaemon { func newJellyseerrDaemon(interval time.Duration, app *appContext) *GenericDaemon {
d := NewGenericDaemon(interval, app, d := NewGenericDaemon(interval, app,
func(app *appContext) { func(app *appContext) {
@@ -78,5 +99,12 @@ func newJellyseerrDaemon(interval time.Duration, app *appContext) *GenericDaemon
}, },
) )
d.Name("Jellyseerr import") d.Name("Jellyseerr import")
jsSync := JellyseerrInitialSyncStatus{}
app.storage.db.Get("jellyseerr_inital_sync_status", &jsSync)
if jsSync.Done {
return nil
}
return d return d
} }
+66 -14
View File
@@ -26,6 +26,8 @@ type Jellyseerr struct {
header map[string]string header map[string]string
httpClient *http.Client httpClient *http.Client
userCache map[string]User // Map of jellyfin IDs to users userCache map[string]User // Map of jellyfin IDs to users
jsToJfID map[int64]string // Map of jellyseerr IDs to jellyfin IDs
invalidatedUsers map[int64]bool // Map of jellyseerr IDs needing a re-caching
cacheExpiry time.Time cacheExpiry time.Time
cacheLength time.Duration cacheLength time.Duration
timeoutHandler co.TimeoutHandler timeoutHandler co.TimeoutHandler
@@ -51,6 +53,8 @@ func NewJellyseerr(server, key string, timeoutHandler co.TimeoutHandler) *Jellys
cacheExpiry: time.Now(), cacheExpiry: time.Now(),
timeoutHandler: timeoutHandler, timeoutHandler: timeoutHandler,
userCache: map[string]User{}, userCache: map[string]User{},
jsToJfID: map[int64]string{},
invalidatedUsers: map[int64]bool{},
LogRequestBodies: false, LogRequestBodies: false,
} }
} }
@@ -92,8 +96,9 @@ func (js *Jellyseerr) req(mode string, uri string, data any, queryParams url.Val
var responseText string var responseText string
defer resp.Body.Close() defer resp.Body.Close()
if response || err != nil { if response || err != nil {
responseText, err = js.decodeResp(resp) var decodeErr error
if err != nil { responseText, decodeErr = js.decodeResp(resp)
if decodeErr != nil {
return responseText, resp.StatusCode, err return responseText, resp.StatusCode, err
} }
} }
@@ -157,6 +162,7 @@ func (js *Jellyseerr) ImportFromJellyfin(jfIDs ...string) ([]User, error) {
for _, u := range data { for _, u := range data {
if u.JellyfinUserID != "" { if u.JellyfinUserID != "" {
js.userCache[u.JellyfinUserID] = u js.userCache[u.JellyfinUserID] = u
js.jsToJfID[u.ID] = u.JellyfinUserID
} }
} }
return data, err return data, err
@@ -165,8 +171,13 @@ func (js *Jellyseerr) ImportFromJellyfin(jfIDs ...string) ([]User, error) {
func (js *Jellyseerr) getUsers() error { func (js *Jellyseerr) getUsers() error {
if js.cacheExpiry.After(time.Now()) { if js.cacheExpiry.After(time.Now()) {
return nil return nil
if len(js.invalidatedUsers) != 0 {
return js.getInvalidatedUsers()
}
} }
js.cacheExpiry = time.Now().Add(js.cacheLength) js.cacheExpiry = time.Now().Add(js.cacheLength)
userCache := map[string]User{}
jsToJfID := map[int64]string{}
pageCount := 1 pageCount := 1
pageIndex := 0 pageIndex := 0
for { for {
@@ -178,7 +189,8 @@ func (js *Jellyseerr) getUsers() error {
if u.JellyfinUserID == "" { if u.JellyfinUserID == "" {
continue continue
} }
js.userCache[u.JellyfinUserID] = u userCache[u.JellyfinUserID] = u
jsToJfID[u.ID] = u.JellyfinUserID
} }
pageCount = res.Page.Pages pageCount = res.Page.Pages
pageIndex++ pageIndex++
@@ -186,6 +198,10 @@ func (js *Jellyseerr) getUsers() error {
break break
} }
} }
js.userCache = userCache
js.jsToJfID = jsToJfID
js.invalidatedUsers = map[int64]bool{}
return nil return nil
} }
@@ -206,15 +222,15 @@ func (js *Jellyseerr) getUserPage(page int) (GetUsersDTO, error) {
} }
func (js *Jellyseerr) MustGetUser(jfID string) (User, error) { func (js *Jellyseerr) MustGetUser(jfID string) (User, error) {
u, _, err := js.GetOrImportUser(jfID) u, _, err := js.GetOrImportUser(jfID, false)
return u, err return u, err
} }
// GetImportedUser provides the same function as ImportFromJellyfin, but will always return the user, // GetImportedUser provides the same function as ImportFromJellyfin, but will always return the user,
// even if they already existed. Also returns whether the user was imported or not, // even if they already existed. Also returns whether the user was imported or not,
func (js *Jellyseerr) GetOrImportUser(jfID string) (u User, imported bool, err error) { func (js *Jellyseerr) GetOrImportUser(jfID string, fixedCache bool) (u User, imported bool, err error) {
imported = false imported = false
u, err = js.GetExistingUser(jfID) u, err = js.GetExistingUser(jfID, fixedCache)
if err == nil { if err == nil {
return return
} }
@@ -232,15 +248,24 @@ func (js *Jellyseerr) GetOrImportUser(jfID string) (u User, imported bool, err e
return return
} }
func (js *Jellyseerr) GetExistingUser(jfID string) (u User, err error) { func (js *Jellyseerr) GetExistingUser(jfID string, fixedCache bool) (u User, err error) {
js.getUsers() js.getUsers()
ok := false ok := false
err = nil err = nil
if u, ok = js.userCache[jfID]; ok { u, ok = js.userCache[jfID]
_, invalidated := js.invalidatedUsers[u.ID]
if ok && !invalidated {
return return
} }
if invalidated {
err = js.getInvalidatedUsers()
if err != nil {
return
}
} else if !fixedCache {
js.cacheExpiry = time.Now() js.cacheExpiry = time.Now()
js.getUsers() js.getUsers()
}
if u, ok = js.userCache[jfID]; ok { if u, ok = js.userCache[jfID]; ok {
err = nil err = nil
return return
@@ -253,7 +278,7 @@ func (js *Jellyseerr) getUser(jfID string) (User, error) {
if js.AutoImportUsers { if js.AutoImportUsers {
return js.MustGetUser(jfID) return js.MustGetUser(jfID)
} }
return js.GetExistingUser(jfID) return js.GetExistingUser(jfID, false)
} }
func (js *Jellyseerr) Me() (User, error) { func (js *Jellyseerr) Me() (User, error) {
@@ -267,6 +292,25 @@ func (js *Jellyseerr) Me() (User, error) {
return data, err return data, err
} }
func (js *Jellyseerr) getInvalidatedUsers() error {
// FIXME: Collect errors and return
for jellyseerrID, _ := range js.invalidatedUsers {
jfID, ok := js.jsToJfID[jellyseerrID]
if !ok {
continue
}
user, err := js.UserByID(jellyseerrID)
if err != nil {
continue
}
js.userCache[jfID] = user
js.jsToJfID[jellyseerrID] = jfID
delete(js.invalidatedUsers, jellyseerrID)
}
return nil
}
func (js *Jellyseerr) GetPermissions(jfID string) (Permissions, error) { func (js *Jellyseerr) GetPermissions(jfID string) (Permissions, error) {
data := permissionsDTO{Permissions: -1} data := permissionsDTO{Permissions: -1}
u, err := js.getUser(jfID) u, err := js.getUser(jfID)
@@ -294,6 +338,7 @@ func (js *Jellyseerr) SetPermissions(jfID string, perm Permissions) error {
} }
u.Permissions = perm u.Permissions = perm
js.userCache[jfID] = u js.userCache[jfID] = u
js.jsToJfID[u.ID] = jfID
return nil return nil
} }
@@ -309,6 +354,7 @@ func (js *Jellyseerr) ApplyTemplateToUser(jfID string, tmpl UserTemplate) error
} }
u.UserTemplate = tmpl u.UserTemplate = tmpl
js.userCache[jfID] = u js.userCache[jfID] = u
js.jsToJfID[u.ID] = jfID
return nil return nil
} }
@@ -325,8 +371,7 @@ func (js *Jellyseerr) ModifyUser(jfID string, conf map[UserField]any) error {
if err != nil { if err != nil {
return err return err
} }
// Lazily just invalidate the cache. js.invalidatedUsers[u.ID] = true
js.cacheExpiry = time.Now()
return nil return nil
} }
@@ -412,12 +457,19 @@ func (js *Jellyseerr) ModifyMainUserSettings(jfID string, conf MainUserSettings)
if err != nil { if err != nil {
return err return err
} }
return js.ModifyMainUserSettingsByID(u.ID, conf)
}
_, _, err = js.post(fmt.Sprintf(js.server+"/user/%d/settings/main", u.ID), conf, false) func (js *Jellyseerr) ModifyMainUserSettingsByID(jellyseerrID int64, conf MainUserSettings) error {
_, _, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/main", jellyseerrID), conf, false)
if err != nil { if err != nil {
return err return err
} }
// Lazily just invalidate the cache. js.invalidatedUsers[jellyseerrID] = true
js.cacheExpiry = time.Now()
return nil return nil
} }
func (js *Jellyseerr) ReloadCache() error {
js.cacheExpiry = time.Now()
return js.getUsers()
}
+8 -3
View File
@@ -1,6 +1,10 @@
package main package main
import "github.com/hrfee/jfa-go/common" import (
"fmt"
"github.com/hrfee/jfa-go/common"
)
type langMeta struct { type langMeta struct {
Name string `json:"name"` Name string `json:"name"`
@@ -108,6 +112,7 @@ type emailLang struct {
WelcomeEmail langSection `json:"welcomeEmail"` WelcomeEmail langSection `json:"welcomeEmail"`
EmailConfirmation langSection `json:"emailConfirmation"` EmailConfirmation langSection `json:"emailConfirmation"`
UserExpired langSection `json:"userExpired"` UserExpired langSection `json:"userExpired"`
ExpiryReminder langSection `json:"expiryReminder"`
} }
type setupLangs map[string]setupLang type setupLangs map[string]setupLang
@@ -165,7 +170,7 @@ func (ts *telegramLangs) getOptions() []common.Option {
} }
type langSection map[string]string type langSection map[string]string
type tmpl map[string]string type tmpl = map[string]any
func templateString(text string, vals tmpl) string { func templateString(text string, vals tmpl) string {
start, previousEnd := -1, -1 start, previousEnd := -1, -1
@@ -182,7 +187,7 @@ func templateString(text string, vals tmpl) string {
start = -1 start = -1
continue continue
} }
out += text[previousEnd+1:start] + val out += text[previousEnd+1:start] + fmt.Sprint(val)
previousEnd = i previousEnd = i
start = -1 start = -1
} }
+31 -1
View File
@@ -136,7 +136,37 @@
"enableReferrals": "Empfehlungen aktivieren", "enableReferrals": "Empfehlungen aktivieren",
"disableReferrals": "Empfehlungen deaktivieren", "disableReferrals": "Empfehlungen deaktivieren",
"userLabel": "Benutzer Label", "userLabel": "Benutzer Label",
"noResultsFound": "Keine Resultate gefunden" "noResultsFound": "Keine Resultate gefunden",
"buildTime": "Erstellungszeit",
"accountDisabled": "Konto deaktiviert: {user}",
"accountReEnabled": "Konto reaktiviert: {user}",
"accountExpired": "Konto abgelaufen: {user}",
"accountWillExpire": "Konto läuft ab am {date}.",
"expirationBasedOn": "Angegebenes Datum basiert auf dem ersten Benutzer.",
"userDeleted": "Benutzer wurde gelöscht.",
"userDisabled": "Benutzer wurde deaktiviert",
"inviteCreated": "Einladung erstellt: {invite}",
"inviteDeleted": "Einladung gelöscht: {invite}",
"builtBy": "Erstellt von",
"accountLinked": "{contactMethod} verknüpft: {user}",
"referrer": "Empfehlungsgeber",
"loginNotAdmin": "Kein Administrator?",
"jellyseerrProfile": "Jellyseerr-Benutzerprofil",
"jellyseerrUserDefaultsDescription": "Erstellen Sie einen Jellyseerr-Benutzer und konfigurieren Sie ihn. Wählen Sie ihn anschließend unten aus. Seine Einstellungen/Berechtigungen werden gespeichert und auf neue Jellyseerr-Benutzer angewendet, die von jfa-go erstellt werden, wenn dieses Profil ausgewählt ist.",
"sortDirection": "Sortierreihenfolge",
"searchAll": "Alle suchen/sortieren",
"searchAllRecords": "Alle Datensätze suchen/sortieren (auf dem Server)",
"postSignupCard": "Hilfekarte nach der Anmeldung",
"postSignupCardDescription": "Karte, die dem Benutzer nach der Anmeldung angezeigt wird. Überschreibt die „Erfolgsmeldung“. Wird durch die Einstellung „Automatische Weiterleitung bei Erfolg“ überschrieben.",
"buildTags": "Build Tags",
"accountUnlinked": "{contactMethod} entfernt: {user}",
"accountResetPassword": "{user} hat sein Passwort zurückgesetzt",
"accountChangedPassword": "{user} hat sein Passwort geändert",
"accountCreated": "Konto erstellt: {user}",
"accountDeleted": "Konto gelöscht: {user}",
"applyConfigurationAndPolicy": "Jellyfin Konfiguration/Richtlinie anwenden",
"applyOmbi": "Ombi -Profil anwenden (falls verfügbar)",
"applyJellyseerr": "Jellyseerr-Profil anwenden (falls verfügbar)"
}, },
"notifications": { "notifications": {
"changedEmailAddress": "E-Mail-Adresse von {n} geändert.", "changedEmailAddress": "E-Mail-Adresse von {n} geändert.",
+15 -2
View File
@@ -39,11 +39,15 @@
"commitNoun": "Commit", "commitNoun": "Commit",
"newUser": "New User", "newUser": "New User",
"profile": "Profile", "profile": "Profile",
"editProfile": "Edit profile",
"editProfileDescription": "For large changes, it is recommended you modify settings in Jellyfin/Jellyseerr/Ombi and re-generate the profile, but you can also make direct changes here. Please use caution when editing.",
"unknown": "Unknown", "unknown": "Unknown",
"label": "Label", "label": "Label",
"userLabel": "User Label", "userLabel": "User Label",
"userLabelDescription": "Label to apply to users created with this invite.", "userLabelDescription": "Label to apply to users created with this invite.",
"logs": "Logs", "logs": "Logs",
"tasks": "Tasks",
"tasksDescription": "Tasks are large actions that may be run periodically in the background. You can manually trigger them here if you wish.",
"announce": "Announce", "announce": "Announce",
"templates": "Templates", "templates": "Templates",
"subject": "Subject", "subject": "Subject",
@@ -56,6 +60,7 @@
"unlink": "Unlink Account", "unlink": "Unlink Account",
"deleted": "Deleted", "deleted": "Deleted",
"disabled": "Disabled", "disabled": "Disabled",
"run": "Run",
"sendPWR": "Send Password Reset", "sendPWR": "Send Password Reset",
"noResultsFound": "No Results Found", "noResultsFound": "No Results Found",
"noResultsFoundLocally": "Only loaded records were searched. You can load more, or perform the search over all records on the server.", "noResultsFoundLocally": "Only loaded records were searched. You can load more, or perform the search over all records on the server.",
@@ -66,6 +71,8 @@
"setExpiry": "Set expiry", "setExpiry": "Set expiry",
"removeExpiry": "Remove expiry", "removeExpiry": "Remove expiry",
"enterExpiry": "Enter an expiry", "enterExpiry": "Enter an expiry",
"extendFromPreviousExpiry": "Extend from previous expiry date (if possible)",
"extendFromPreviousExpiryDescription": "If a record of an expired user's expiry time is found in the activity log, expiry will be extended from then, rather than the current time, unless the new expiry date would have already passed.",
"sendPWRManual": "User {n} has no method of contact, press copy to get a link to send to them.", "sendPWRManual": "User {n} has no method of contact, press copy to get a link to send to them.",
"sendPWRSuccess": "Password reset link sent.", "sendPWRSuccess": "Password reset link sent.",
"sendPWRSuccessManual": "If the user hasn't received it, press copy to get a link to manually send to them.", "sendPWRSuccessManual": "If the user hasn't received it, press copy to get a link to manually send to them.",
@@ -113,6 +120,7 @@
"addProfileDescription": "Create a Jellyfin user and configure it, then select it below. When this profile is applied to an invite, new users will be created with the settings.", "addProfileDescription": "Create a Jellyfin user and configure it, then select it below. When this profile is applied to an invite, new users will be created with the settings.",
"addProfileNameOf": "Profile Name", "addProfileNameOf": "Profile Name",
"addProfileStoreHomescreenLayout": "Store homescreen layout", "addProfileStoreHomescreenLayout": "Store homescreen layout",
"addProfileStoreJellyseerr": "Create Jellyseerr profile",
"inviteNoUsersCreated": "None yet!", "inviteNoUsersCreated": "None yet!",
"inviteUsersCreated": "Created users", "inviteUsersCreated": "Created users",
"inviteNoProfile": "No Profile", "inviteNoProfile": "No Profile",
@@ -209,7 +217,9 @@
"backupCanBeFound": "The backup can be found on the server at {filepath}.", "backupCanBeFound": "The backup can be found on the server at {filepath}.",
"backupCanDownload": "Alternatively, click below to download the backup.", "backupCanDownload": "Alternatively, click below to download the backup.",
"wikiPage": "Wiki Page", "wikiPage": "Wiki Page",
"wiki": "Wiki" "wiki": "Wiki",
"restartRequired": "Restart required",
"required": "Required"
}, },
"notifications": { "notifications": {
"pathCopied": "Full path copied to clipboard.", "pathCopied": "Full path copied to clipboard.",
@@ -237,6 +247,7 @@
"errorBlankFields": "Fields were left blank", "errorBlankFields": "Fields were left blank",
"errorDeleteProfile": "Failed to delete profile {n}", "errorDeleteProfile": "Failed to delete profile {n}",
"errorLoadProfiles": "Failed to load profiles.", "errorLoadProfiles": "Failed to load profiles.",
"errorLoadProfile": "Failed to load profile.",
"errorCreateProfile": "Failed to create profile {n}", "errorCreateProfile": "Failed to create profile {n}",
"errorSavedProfile": "Failed to save profile {n}", "errorSavedProfile": "Failed to save profile {n}",
"errorSetDefaultProfile": "Failed to set default profile.", "errorSetDefaultProfile": "Failed to set default profile.",
@@ -254,8 +265,10 @@
"errorNoReferralTemplate": "Profile doesn't contain referral template, add one in settings.", "errorNoReferralTemplate": "Profile doesn't contain referral template, add one in settings.",
"errorLoadActivities": "Failed to load activities.", "errorLoadActivities": "Failed to load activities.",
"errorInvalidDate": "Date is invalid.", "errorInvalidDate": "Date is invalid.",
"errorInvalidJSON": "Invalid JSON.",
"updateAvailable": "A new update is available, check settings.", "updateAvailable": "A new update is available, check settings.",
"noUpdatesAvailable": "No new updates available." "noUpdatesAvailable": "No new updates available.",
"runTask": "Triggered task."
}, },
"quantityStrings": { "quantityStrings": {
"modifySettingsFor": { "modifySettingsFor": {
+37 -9
View File
@@ -93,14 +93,14 @@
"notifyEvent": "Értesítés ekkor:", "notifyEvent": "Értesítés ekkor:",
"notifyInviteExpiry": "Lejáratkor", "notifyInviteExpiry": "Lejáratkor",
"notifyUserCreation": "Használatkor", "notifyUserCreation": "Használatkor",
"sendPIN": "", "sendPIN": "Kérd meg a felhasználókat, hogy küldjék el a PIN-t a botnak.",
"searchDiscordUser": "", "searchDiscordUser": "Kezd el írni adiscord felhasználó nevet a keresés indításához.",
"findDiscordUser": "", "findDiscordUser": "Discord felhasználó keresése",
"linkMatrixDescription": "", "linkMatrixDescription": "Add meg a felhasználó nevét és jelszavát hogy botként tudd használni. A beküldés után az alkalmazás újra fog indulni.",
"matrixHomeServer": "", "matrixHomeServer": "Otthoni szerver címe",
"saveAsTemplate": "", "saveAsTemplate": "Mentés sablonként",
"deleteTemplate": "", "deleteTemplate": "Sablon törlése",
"templateEnterName": "", "templateEnterName": "Adj meg egy nevet a sablon mentéséhez.",
"unlink": "Fiók leválasztása", "unlink": "Fiók leválasztása",
"after": "Utánna", "after": "Utánna",
"before": "Elötte", "before": "Elötte",
@@ -117,7 +117,35 @@
"invite": "Meghívás", "invite": "Meghívás",
"activity": "Aktivitás", "activity": "Aktivitás",
"userLabel": "Felhasználói címke", "userLabel": "Felhasználói címke",
"userLabelDescription": "Ezzel a meghívóval létrehozott felhasználókra alkalmazandó címke." "userLabelDescription": "Ezzel a meghívóval létrehozott felhasználókra alkalmazandó címke.",
"noResultsFoundLocally": "A keresés csak a betöltött adatokon meg végbe. Betölthetsz több adatot is vagy kereshetsz az összes adaton.",
"keepSearchingDescription": "Csak a betöltött tevékenységek között futott le a keresés. Kattints ide ha az összes tevékenység között szeretnél keresni.",
"enableReferralsDescription": "Adjon a felhasználóknak egy meghívóhoz hasonló személyes hivatkozási linket, amelyet elküldhet barátainak/családjának. Ez származhat a profiljukban található ajánlói sablonból vagy egy meglévő meghívóból.",
"enableReferralsProfileDescription": "Adj az ezzel a profillal létrehozott felhasználóknak egy személyre szabott ajánlói linket, hasonlóan egy meghívóhoz, amelyet elküldhetnek barátaiknak és családtagjaiknak. Hozz létre egy meghívót a kívánt beállításokkal, majd válaszd ki itt. Minden ajánlás ezután ezen a meghívón alapul majd. A meghívót törölheted, ha kész vagy.",
"postSignupCardDescription": "A felhasználónak a regisztráció után megjelenő kártya. Felülírja a „Sikerüzenet” beállítást. Felülírja az „Automatikus átirányítás siker esetén” beállítás.",
"buildTime": "Készítési idő",
"accessJFA": "jfa-go hozzáférés",
"accessJFASettings": "Nem módosítható, mert a Beállítások > Általános menüpontban engedélyezve van a „Csak rendszergazdai felhasználók” vagy az „Összes Jellyfin felhasználó bejelentkezése” lehetőség.",
"disabled": "Tiltva",
"userPagePage": "Felhasználói oldal: oldal",
"noResultsFound": "Nincs megjeleníthető adat",
"settingsHiddenDependency": "Az egyező beállítások rejtve vannak, mert egy másik beállítás értékétől függenek:",
"settingsDependsOn": "{setting}: ettől függ: {dependency}",
"settingsAdvancedMode": "{setting}: Haladó beállítások engedélyezése szükséges",
"keepSearching": "Keresés folytatása",
"removeExpiry": "Lejárat eltávolítása",
"enterExpiry": "Lejárati dátum megadása",
"enableReferrals": "Hivatkozások engedélyezése",
"disableReferrals": "Hivatkozások tiltása",
"useInviteExpiry": "Lejárat beállítása profilból vagy meghívóból",
"useInviteExpiryNote": "Alapértelmezés szerint a meghívók 90 nap után lejárnak, de a felhasználó megújíthatja őket. Engedélyezze, ha azt szeretné, hogy a megadott idő lejárta után a meghívás letiltásra kerüljön.",
"settingsMaybeUnderAdvanced": "Tipp: Lehet hogy megtalálod amit keresel ha bekapcsolod a haladó beállíításokat.",
"jellyseerrProfile": "Jellyseer felhasználói profil",
"jellyseerrUserDefaultsDescription": "Hozz létre egy Jellyseerr felhasználót, állítsd be, majd válaszd ki lent. A beállításait/engedélyeit a rendszer tárolja és alkalmazza a jfa-go által létrehozott új Jellyseerr felhasználókra, amikor ezt a profilt kiválasztod.",
"sortDirection": "Rendezés iránya",
"searchAll": "Összes keresés/rendezés",
"searchAllRecords": "Keresés/rendezés az összes adaton(a szerveren lévő)",
"builtBy": "Készítette"
}, },
"notifications": { "notifications": {
"changedEmailAddress": "", "changedEmailAddress": "",
+3 -2
View File
@@ -18,7 +18,7 @@
"create": "", "create": "",
"apply": "", "apply": "",
"select": "", "select": "",
"name": "", "name": "Nome",
"date": "", "date": "",
"setExpiry": "", "setExpiry": "",
"updates": "", "updates": "",
@@ -117,7 +117,8 @@
"userPageLogin": "", "userPageLogin": "",
"userPagePage": "", "userPagePage": "",
"buildTime": "", "buildTime": "",
"builtBy": "" "builtBy": "",
"disabled": "Disabilitato"
}, },
"notifications": { "notifications": {
"changedEmailAddress": "", "changedEmailAddress": "",
+322
View File
@@ -0,0 +1,322 @@
{
"meta": {
"name": "İngilizce (ABD)"
},
"strings": {
"invites": "Davetler",
"invite": "Davet",
"accounts": "Hesaplar",
"activity": "Aktivite",
"settings": "Ayarlar",
"inviteMonths": "Ay",
"inviteDays": "Gün",
"inviteHours": "Saat",
"inviteMinutes": "Dakika",
"inviteNumberOfUses": "",
"inviteDuration": "",
"warning": "",
"inviteInfiniteUsesWarning": "",
"inviteSendToEmail": "",
"create": "",
"apply": "",
"select": "",
"name": "",
"date": "",
"updates": "",
"update": "",
"download": "",
"search": "",
"advancedSettings": "",
"lastActiveTime": "",
"from": "",
"after": "",
"before": "",
"user": "",
"userExpiry": "",
"userExpiryDescription": "",
"aboutProgram": "",
"version": "",
"commitNoun": "",
"newUser": "",
"profile": "",
"unknown": "",
"label": "",
"userLabel": "",
"userLabelDescription": "",
"logs": "",
"announce": "",
"templates": "",
"subject": "",
"message": "Mesaj",
"variables": "",
"conditionals": "",
"preview": "",
"reset": "",
"donate": "",
"unlink": "",
"deleted": "",
"disabled": "Devre Dışı",
"sendPWR": "",
"noResultsFound": "",
"noResultsFoundLocally": "",
"keepSearching": "",
"keepSearchingDescription": "",
"contactThrough": "",
"extendExpiry": "",
"setExpiry": "",
"removeExpiry": "",
"enterExpiry": "",
"sendPWRManual": "",
"sendPWRSuccess": "",
"sendPWRSuccessManual": "",
"sendPWRValidFor": "",
"customizeMessages": "",
"customizeMessagesDescription": "",
"markdownSupported": "",
"modifySettings": "",
"modifySettingsDescription": "",
"enableReferrals": "",
"disableReferrals": "",
"enableReferralsDescription": "",
"enableReferralsProfileDescription": "",
"useInviteExpiry": "",
"useInviteExpiryNote": "",
"applyHomescreenLayout": "",
"applyConfigurationAndPolicy": "",
"applyOmbi": "",
"applyJellyseerr": "",
"sendDeleteNotificationEmail": "",
"sendDeleteNotifiationExample": "",
"settingsRestart": "",
"settingsRestarting": "",
"settingsRestartRequired": "",
"settingsRestartRequiredDescription": "",
"settingsApplyRestartLater": "",
"settingsApplyRestartNow": "",
"settingsApplied": "",
"settingsRefreshPage": "",
"settingsRequiredOrRestartMessage": "",
"settingsSave": "",
"settingsHiddenDependency": "",
"settingsDependsOn": "",
"settingsAdvancedMode": "",
"settingsMaybeUnderAdvanced": "",
"ombiProfile": "",
"ombiUserDefaultsDescription": "",
"jellyseerrProfile": "",
"jellyseerrUserDefaultsDescription": "",
"userProfiles": "",
"userProfilesDescription": "",
"userProfilesIsDefault": "",
"userProfilesLibraries": "",
"addProfile": "",
"addProfileDescription": "",
"addProfileNameOf": "",
"addProfileStoreHomescreenLayout": "",
"inviteNoUsersCreated": "",
"inviteUsersCreated": "",
"inviteNoProfile": "",
"inviteDateCreated": "",
"inviteNoInvites": "",
"inviteExpiresInTime": "",
"notifyEvent": "",
"notifyInviteExpiry": "",
"notifyUserCreation": "",
"sendPIN": "",
"searchDiscordUser": "",
"findDiscordUser": "",
"linkMatrixDescription": "",
"matrixHomeServer": "",
"saveAsTemplate": "",
"deleteTemplate": "",
"templateEnterName": "",
"accessJFA": "",
"accessJFASettings": "",
"sortingBy": "",
"sortDirection": "",
"filters": "",
"clickToRemoveFilter": "",
"clearSearch": "",
"searchAll": "",
"searchAllRecords": "",
"actions": "",
"searchOptions": "",
"matchText": "",
"jellyfinID": "",
"userPageLogin": "",
"userPagePage": "",
"postSignupCard": "",
"postSignupCardDescription": "",
"buildTime": "",
"builtBy": "",
"buildTags": "",
"loginNotAdmin": "",
"referrer": "",
"accountLinked": "",
"accountUnlinked": "",
"accountResetPassword": "",
"accountChangedPassword": "",
"accountCreated": "",
"accountDeleted": "",
"accountDisabled": "",
"accountReEnabled": "",
"accountExpired": "",
"accountWillExpire": "",
"expirationBasedOn": "",
"userDeleted": "",
"userDisabled": "",
"inviteCreated": "",
"inviteDeleted": "",
"inviteExpired": "",
"fromInvite": "",
"byAdmin": "",
"byUser": "",
"byJfaGo": "",
"activityID": "",
"title": "",
"usersMentioned": "",
"actor": "",
"actorDescription": "",
"accountCreationFilter": "",
"accountDeletionFilter": "",
"accountDisabledFilter": "",
"accountEnabledFilter": "",
"contactLinkedFilter": "",
"contactUnlinkedFilter": "",
"passwordChangeFilter": "",
"passwordResetFilter": "",
"inviteCreatedFilter": "",
"inviteDeletedFilter": "",
"loadMore": "",
"loadAll": "",
"noMoreResults": "",
"totalRecords": "",
"loadedRecords": "",
"shownRecords": "",
"selectedRecords": "",
"allMatchingSelected": "",
"allLoadedSelected": "",
"backups": "",
"backupsDescription": "",
"backupsFormatNote": "",
"backupsCopy": "",
"backupDownloadRestore": "",
"backupUpload": "",
"backupDownload": "",
"backupRestore": "",
"backupNow": "",
"backupCreated": "",
"backupCanBeFound": "",
"backupCanDownload": "",
"wikiPage": "",
"wiki": ""
},
"notifications": {
"pathCopied": "",
"changedEmailAddress": "",
"userCreated": "",
"createProfile": "",
"saveSettings": "",
"saveEmail": "",
"sentAnnouncement": "",
"savedAnnouncement": "",
"setOmbiProfile": "",
"savedProfile": "",
"updateApplied": "",
"updateAppliedRefresh": "",
"telegramVerified": "",
"accountConnected": "",
"referralsEnabled": "",
"activityDeleted": "",
"errorInviteNoLongerExists": "",
"errorInviteNotFound": "",
"errorSettingsAppliedNoHomescreenLayout": "",
"errorHomescreenAppliedNoSettings": "",
"errorSettingsFailed": "",
"errorSaveEmail": "",
"errorBlankFields": "",
"errorDeleteProfile": "",
"errorLoadProfiles": "",
"errorCreateProfile": "",
"errorSavedProfile": "",
"errorSetDefaultProfile": "",
"errorLoadUsers": "",
"errorLoadSettings": "",
"errorSetOmbiProfile": "",
"errorLoadOmbiUsers": "",
"errorChangedEmailAddress": "",
"errorFailureCheckLogs": "",
"errorPartialFailureCheckLogs": "",
"errorUserCreated": "",
"errorSendWelcomeEmail": "",
"errorApplyUpdate": "",
"errorCheckUpdate": "",
"errorNoReferralTemplate": "",
"errorLoadActivities": "",
"errorInvalidDate": "",
"updateAvailable": "",
"noUpdatesAvailable": ""
},
"quantityStrings": {
"modifySettingsFor": {
"singular": "",
"plural": ""
},
"enableReferralsFor": {
"singular": "",
"plural": ""
},
"deleteNUsers": {
"singular": "",
"plural": ""
},
"disableUsers": {
"singular": "",
"plural": ""
},
"reEnableUsers": {
"singular": "",
"plural": ""
},
"addUser": {
"singular": "",
"plural": ""
},
"deleteUser": {
"singular": "",
"plural": ""
},
"deletedUser": {
"singular": "",
"plural": ""
},
"disabledUser": {
"singular": "",
"plural": ""
},
"enabledUser": {
"singular": "",
"plural": ""
},
"announceTo": {
"singular": "",
"plural": ""
},
"appliedSettings": {
"singular": "",
"plural": ""
},
"extendExpiry": {
"singular": "",
"plural": ""
},
"setExpiry": {
"singular": "",
"plural": ""
},
"extendedExpiry": {
"singular": "",
"plural": ""
}
}
}
+7 -2
View File
@@ -39,14 +39,19 @@
"contactMethods": "Kapcsolati lehetőségek", "contactMethods": "Kapcsolati lehetőségek",
"accountStatus": "Fiók státusz", "accountStatus": "Fiók státusz",
"notSet": "Nincs beállítva", "notSet": "Nincs beállítva",
"myAccount": "Saját fiókom" "myAccount": "Saját fiókom",
"internal": "Belső",
"referrals": "Hivatkozások",
"inviteRemainingUses": "Fennmaradó felhasználások",
"external": "Külső"
}, },
"notifications": { "notifications": {
"errorLoginBlank": "A felhasználónév és/vagy a jelszó üresen lett hagyva.", "errorLoginBlank": "A felhasználónév és/vagy a jelszó üresen lett hagyva.",
"errorConnection": "Nem lehet csatlakozni a jfa-go-hoz.", "errorConnection": "Nem lehet csatlakozni a jfa-go-hoz.",
"errorUnknown": "Ismeretlen hiba.", "errorUnknown": "Ismeretlen hiba.",
"error401Unauthorized": "Nincs jogosultság. Próbáld frissíteni az oldalt.", "error401Unauthorized": "Nincs jogosultság. Próbáld frissíteni az oldalt.",
"errorSaveSettings": "Nem lehet menteni a beállításokat." "errorSaveSettings": "Nem lehet menteni a beállításokat.",
"errorSpecialSymbols": "Ez a mező nem tartalmazhat speciális karaktereket."
}, },
"quantityStrings": { "quantityStrings": {
"year": { "year": {
+1 -1
View File
@@ -3,7 +3,7 @@
"name": "Italiano (IT)" "name": "Italiano (IT)"
}, },
"strings": { "strings": {
"username": "Username", "username": "Nome Utente",
"password": "Password", "password": "Password",
"emailAddress": "Indirizzo Email", "emailAddress": "Indirizzo Email",
"name": "Nome", "name": "Nome",
+70
View File
@@ -0,0 +1,70 @@
{
"meta": {
"name": "İngilizce (ABD)"
},
"strings": {
"username": "Kullanıcı Adı",
"password": "Şifre",
"emailAddress": "E-posta Adresi",
"name": "İsim",
"submit": "Kaydet",
"send": "Gönder",
"success": "Başarılı",
"continue": "Devam Et",
"error": "Hata",
"copy": "Kopyala",
"copied": "Kopyalandı",
"time24h": "24 Saat",
"time12h": "12 Saat",
"linkTelegram": "Telegram Bağla",
"contactEmail": "E-posta ile İletişim",
"contactTelegram": "Telegram ile İletişim",
"linkDiscord": "Discord Bağla",
"linkMatrix": "Matrix Bağla",
"contactDiscord": "Discord ile İletişim",
"theme": "Tema",
"refresh": "Yenile",
"required": "Gerekli",
"login": "Oturum Aç",
"logout": "Oturumu Kapat",
"admin": "Yönetici",
"enabled": "Etkin",
"disabled": "Devre Dışı",
"reEnable": "Yeniden Etkinleştir",
"disable": "Devre Dışı Bırak",
"contactMethods": "İletişim Yöntemleri",
"accountStatus": "Hesap Durumu",
"notSet": "Ayarlanmadı",
"expiry": "Son Kullanma Tarihi",
"add": "Ekle",
"edit": "Düzenle",
"delete": "Sil",
"myAccount": "Hesabım",
"referrals": "Referanslar",
"inviteRemainingUses": "Kalan Kullanım",
"internal": "Dahili",
"external": "Harici"
},
"notifications": {
"errorLoginBlank": "Kullanıcı adı ve/veya şifre boş bırakıldı.",
"errorConnection": "jfa-go'ya bağlanılamadı.",
"errorUnknown": "Bilinmeyen hata.",
"error401Unauthorized": "Yetkisiz İşlem. Sayfayı yenilemeyi deneyin.",
"errorSaveSettings": "Ayarlar kaydedilemedi.",
"errorSpecialSymbols": "Alan özel semboller içeremez."
},
"quantityStrings": {
"year": {
"singular": "{n} Yıl",
"plural": "{n} Yıl"
},
"month": {
"singular": "{n} Ay",
"plural": "{n} Ay"
},
"day": {
"singular": "{n} Gün",
"plural": "{n} Gün"
}
}
}
+5
View File
@@ -80,5 +80,10 @@
"title": "Your account has expired - Jellyfin", "title": "Your account has expired - Jellyfin",
"yourAccountHasExpired": "Your account has expired.", "yourAccountHasExpired": "Your account has expired.",
"contactTheAdmin": "Contact the administrator for more info." "contactTheAdmin": "Contact the administrator for more info."
},
"expiryReminder": {
"name": "Expiry reminder",
"title": "Reminder: your account will expire soon - Jellyfin",
"yourAccountIsDueToExpire": "Your account is due to expire in {expiresIn}, or on {date} at {time}."
} }
} }
+57 -50
View File
@@ -3,75 +3,82 @@
"name": "Magyar (HU)" "name": "Magyar (HU)"
}, },
"strings": { "strings": {
"ifItWasNotYou": "", "ifItWasNotYou": "Ha nem Te voltál, akkor hagyd figyelmen kívül.",
"helloUser": "", "helloUser": "Szia {username},",
"reason": "" "reason": "Ok"
}, },
"userCreated": { "userCreated": {
"name": "", "name": "Felhasználó létrehozása",
"title": "", "title": "Értesítés: Felhasználó létrehozva",
"aUserWasCreated": "", "aUserWasCreated": "Felhasználó létrehozva {code} kóddal.",
"time": "", "time": "Idő",
"notificationNotice": "" "notificationNotice": "Megjegyzés: A figyelmeztető üzenetek ki- be kapcsolhatók az admin felületen."
}, },
"inviteExpiry": { "inviteExpiry": {
"name": "", "name": "Meghívó lejárata",
"title": "", "title": "Értesítés: A meghívó lejárt",
"inviteExpired": "", "inviteExpired": "A meghívó lejárt.",
"expiredAt": "", "expiredAt": "A {code} kód lejárt ekkor {time}.",
"notificationNotice": "" "notificationNotice": "Megjegyzés: A figyelmeztető üzenetek ki- be kapcsolhatók az admin felületen."
}, },
"passwordReset": { "passwordReset": {
"name": "", "name": "Jelszó visszaállítás",
"title": "", "title": "Jelszó visszaállítási kérelem - Jellyfin",
"someoneHasRequestedReset": "", "someoneHasRequestedReset": "Valaki mostanában jelszó visszaállítást kért.",
"ifItWasYou": "", "ifItWasYou": "Ha Te voltál, írd be a kódot ide.",
"ifItWasYouLink": "", "ifItWasYouLink": "Ha Te voltál, kattints a linkre.",
"codeExpiry": "", "codeExpiry": "A kód lejárt {expiresInMinutes} perce. ({date} {time} UTC).",
"pin": "" "pin": "PIN"
}, },
"userDeleted": { "userDeleted": {
"name": "", "name": "Felhasználó törlése",
"title": "", "title": "A fiókod törölve lett - Jellyfin",
"yourAccountWasDeleted": "" "yourAccountWasDeleted": "A jellyfin fiókod törölve lett."
}, },
"userDisabled": { "userDisabled": {
"name": "", "name": "Felhsználó letiltva",
"title": "", "title": "A felhasználód le lett tiltva - Jellyfin",
"yourAccountWasDisabled": "" "yourAccountWasDisabled": "A fiókod le lett tiltva."
}, },
"userEnabled": { "userEnabled": {
"name": "", "name": "Felhasználó engedélyezve",
"title": "", "title": "A fiókod fel lett oldva - Jellyfin",
"yourAccountWasEnabled": "" "yourAccountWasEnabled": "A fiókod fel lett oldva."
}, },
"inviteEmail": { "inviteEmail": {
"name": "", "name": "Meghívó email",
"title": "", "title": "Meghívó - Jellyfin",
"hello": "", "hello": "Szia",
"youHaveBeenInvited": "", "youHaveBeenInvited": "Meghívtak a jellyfin alkalmazásba.",
"toJoin": "", "toJoin": "Csatlakozáshoz kattints a linkre.",
"inviteExpiry": "", "inviteExpiry": "A meghívó {date} {time}-kor lejár, ami {expiresInMinutes} perc múlva lesz, szóval gyorsan cselekedj.",
"linkButton": "" "linkButton": "Fiók beállítása"
}, },
"welcomeEmail": { "welcomeEmail": {
"name": "", "name": "Üdvözöllek",
"title": "", "title": "Üdvözöllek a Jellyfin-ben",
"welcome": "", "welcome": "Üdvözöllek a Jellyfin-ben!",
"youCanLoginWith": "", "youCanLoginWith": "Be tudsz lépni az alábbi adatokkal",
"yourAccountWillExpire": "", "yourAccountWillExpire": "A fiókod {date} dátummal lejár.",
"jellyfinURL": "" "jellyfinURL": "URL"
}, },
"emailConfirmation": { "emailConfirmation": {
"name": "", "name": "Megerősítő email cím",
"title": "", "title": "Erősítsd meg az email címed- Jellyfin",
"clickBelow": "", "clickBelow": "Kattints az alábbi linkre, hogy megerősítsd az email címed és elkezd használni a jellyfin-t.",
"confirmEmail": "" "confirmEmail": "Email megerősítése"
}, },
"userExpired": { "userExpired": {
"name": "", "name": "Felhasználó lejárata",
"title": "", "title": "A fiókod lejárt - Jellyfin",
"yourAccountHasExpired": "", "yourAccountHasExpired": "A fiókod lejárt.",
"contactTheAdmin": "" "contactTheAdmin": "Lépj kapcsolatba az rendszergazdával további információkért."
},
"userExpiryAdjusted": {
"name": "Lejárat módosítva",
"title": "Fiók lejárat módosítva - Jellyfin",
"yourExpiryWasAdjusted": "A fiókod lejárata módosult.",
"ifPreviouslyDisabled": "Ha fiókod korábban letiltották, előfordulhat, hogy újra engedélyezték.",
"newExpiry": "A fiókod {date} napon lejár."
} }
} }
+11 -2
View File
@@ -17,7 +17,7 @@
"confirmationRequired": "E-mail megerősítés szükséges", "confirmationRequired": "E-mail megerősítés szükséges",
"confirmationRequiredMessage": "Kérjük ellenőrizze az e-mail címére küldött üzenetet, a fiók ellenőrzéséhez.", "confirmationRequiredMessage": "Kérjük ellenőrizze az e-mail címére küldött üzenetet, a fiók ellenőrzéséhez.",
"yourAccountIsValidUntil": "A fiókja eddig lesz érvényes: {date}.", "yourAccountIsValidUntil": "A fiókja eddig lesz érvényes: {date}.",
"sendPIN": "Az alábbi PIN-t küldje el a botnak, majd itt csatolja össze a fiókját.", "sendPIN": "Az alábbi PIN-t küld el a botnak, majd itt csatold össze a fiókoddal.",
"sendPINDiscord": "Írja be a {command} parancsot a {server_channel} Discord csatornába, adja meg a PIN-t.", "sendPINDiscord": "Írja be a {command} parancsot a {server_channel} Discord csatornába, adja meg a PIN-t.",
"matrixEnterUser": "Írja be a felhasználója azonosítóját majd nyomja meg a beküldés gombot. A kapott kódot ide írja be.", "matrixEnterUser": "Írja be a felhasználója azonosítóját majd nyomja meg a beküldés gombot. A kapott kódot ide írja be.",
"customMessagePlaceholderContent": "Kattints a felhasználói oldal szerkesztés gombjára a beállításokban a kártya testreszabásához, vagy jeleníts meg egyet a bejelentkezési képernyőn, ne aggódj, a felhasználó ezt nem láthatja.", "customMessagePlaceholderContent": "Kattints a felhasználói oldal szerkesztés gombjára a beállításokban a kártya testreszabásához, vagy jeleníts meg egyet a bejelentkezési képernyőn, ne aggódj, a felhasználó ezt nem láthatja.",
@@ -34,7 +34,16 @@
"resetPasswordThroughJellyfin": "A jelszavad visszaállításához látogass el a {jfLink} oldalra, és nyomj rá az \"Elfelejtett jelszó\" gombra.", "resetPasswordThroughJellyfin": "A jelszavad visszaállításához látogass el a {jfLink} oldalra, és nyomj rá az \"Elfelejtett jelszó\" gombra.",
"resetPasswordThroughLink": "A jelszavad visszaállításához, add meg a felhasználóneved, e-mail címed vagy a hozzákötött kapcsolattartási felhasználónevet, és nyomj a gombra. A linket levélben fogod kapni.", "resetPasswordThroughLink": "A jelszavad visszaállításához, add meg a felhasználóneved, e-mail címed vagy a hozzákötött kapcsolattartási felhasználónevet, és nyomj a gombra. A linket levélben fogod kapni.",
"resetSent": "Visszaállítás elküldve.", "resetSent": "Visszaállítás elküldve.",
"changePassword": "Jelszó megváltoztatása" "changePassword": "Jelszó megváltoztatása",
"referralsWithExpiryDescription": "Hívd meg barátaidat és családtagjaidat a Jellyfinre ezzel a linkkel. A link nem lesz elérhető, ha lejár.",
"referralsDescription": "Hívd meg barátaidat és családtagjaidat a Jellyfinre ezzel a linkkel. Gyere vissza ide egy újért, ha lejár.",
"copyReferral": "Link másolása",
"invitedBy": "Meghívást kaptál {user} által.",
"resetPasswordThroughLinkStart": "Jelszava visszaállításához adja meg az alábbiak egyikét:",
"resetPasswordThroughLinkEnd": "Ezután kattints az elküldésre. Egy linket fogsz kapni a jelszó visszaállításához.",
"resetPasswordUsername": "Jellyfin felhasználónév",
"resetPasswordEmail": "Email cím",
"resetPasswordContactMethod": "A fiókodhoz kapcsolt kapcsolatfelvételi mód felhasználóneve"
}, },
"notifications": { "notifications": {
"errorUserExists": "A felhasználó már létezik.", "errorUserExists": "A felhasználó már létezik.",
+12 -4
View File
@@ -3,11 +3,11 @@
"name": "Italiano (IT)" "name": "Italiano (IT)"
}, },
"strings": { "strings": {
"pageTitle": "Crea Un Account Jellyfin", "pageTitle": "Crea Account Jellyfin",
"createAccountHeader": "Crea Un Account", "createAccountHeader": "Crea Un Account",
"accountDetails": "Dettagli", "accountDetails": "Dettagli",
"emailAddress": "Email", "emailAddress": "Email",
"username": "Username", "username": "Nome Utente",
"password": "Password", "password": "Password",
"reEnterPassword": "Riscrivi La Password", "reEnterPassword": "Riscrivi La Password",
"reEnterPasswordInvalid": "Le password non sono uguali.", "reEnterPasswordInvalid": "Le password non sono uguali.",
@@ -17,7 +17,7 @@
"confirmationRequired": "Richiesta la conferma Email", "confirmationRequired": "Richiesta la conferma Email",
"confirmationRequiredMessage": "Controlla la tua casella email per verificare il tuo indirizzo.", "confirmationRequiredMessage": "Controlla la tua casella email per verificare il tuo indirizzo.",
"yourAccountIsValidUntil": "Il tuo account sarà valido fino al {date}.", "yourAccountIsValidUntil": "Il tuo account sarà valido fino al {date}.",
"sendPIN": "Scrivi il PIN qui sotto al bot, poi torna qui per connettere il tuo account.", "sendPIN": "Invia il PIN riportato sotto al bot, poi torna qui per associare il tuo account.",
"sendPINDiscord": "Scrivi {command} in {server_channel} su Discord, poi invia il PIN qui sotto.", "sendPINDiscord": "Scrivi {command} in {server_channel} su Discord, poi invia il PIN qui sotto.",
"matrixEnterUser": "Inserisci il tuo ID utente, premi invia e ti verrò inviato un PIN. Inseriscilo qui per continuare.", "matrixEnterUser": "Inserisci il tuo ID utente, premi invia e ti verrò inviato un PIN. Inseriscilo qui per continuare.",
"customMessagePlaceholderHeader": "Personalizza questa scheda", "customMessagePlaceholderHeader": "Personalizza questa scheda",
@@ -34,7 +34,15 @@
"resetPassword": "Ripristina Password", "resetPassword": "Ripristina Password",
"resetSent": "Richiesta di ripristino inviata.", "resetSent": "Richiesta di ripristino inviata.",
"resetSentDescription": "Se l'username/metodo di contatto corrisponde ad un account esistente, verrà inviato un link di reset a tutti i metodi di contatto disponibili. Il codice scadrà tra 30 minuti.", "resetSentDescription": "Se l'username/metodo di contatto corrisponde ad un account esistente, verrà inviato un link di reset a tutti i metodi di contatto disponibili. Il codice scadrà tra 30 minuti.",
"changePassword": "Cambia Password" "changePassword": "Cambia Password",
"resetPasswordThroughLinkStart": "Per reimpostare la password, inserisci uno dei seguenti:",
"resetPasswordThroughLinkEnd": "Successivamente premi Invia. Un link verra' inviato per resettare la tua password.",
"resetPasswordUsername": "Il tuo nome utente Jellyfin",
"resetPasswordEmail": "Il tuo indirizzo email",
"referralsWithExpiryDescription": "Invita amici e famigliari su Jellyfin con questo link. Il link verra' disabilitato una volta scaduto.",
"referralsDescription": "Invita amici e famigliari su Jellyfin usando questo link. Ritorna su questa pagina per ottenerne uno nuovo.",
"copyReferral": "Copia Link",
"invitedBy": "Sei stato invitato dall'utente {user}."
}, },
"notifications": { "notifications": {
"errorUserExists": "L'utente è già esistente.", "errorUserExists": "L'utente è già esistente.",
+88
View File
@@ -0,0 +1,88 @@
{
"meta": {
"name": "İngilizce (ABD)"
},
"strings": {
"pageTitle": "Jellyfin Hesabı Oluştur",
"createAccountHeader": "Hesap Oluştur",
"accountDetails": "Ayrıntılar",
"emailAddress": "E-posta",
"username": "Kullanıcı Adı",
"oldPassword": "Eski Şifre",
"newPassword": "Yeni Şifre",
"password": "Şifre",
"reEnterPassword": "Şifreyi Tekrar Girin",
"reEnterPasswordInvalid": "Şifreler aynı değil.",
"createAccountButton": "Hesap Oluştur",
"passwordRequirementsHeader": "Şifre Gereksinimleri",
"successHeader": "Başarılı!",
"confirmationRequired": "E-posta onayı gerekli",
"confirmationRequiredMessage": "Lütfen adresinizi doğrulamak için e-posta gelen kutunuzu kontrol edin.",
"yourAccountIsValidUntil": "Hesabınız {date} tarihine kadar geçerli olacaktır.",
"sendPIN": "Aşağıdaki **PIN'i** bota gönderin, ardından hesabınızı bağlamak için buraya geri gelin.",
"sendPINDiscord": "Discord'da {server_channel} {command} yazın, ardından aşağıdaki PIN'i gönderin.",
"matrixEnterUser": "Kullanıcı Kimliğinizi girin, gönderin ve size bir PIN gönderilecektir. Devam etmek için buraya girin.",
"welcomeUser": "Hoşgeldin, {user}!",
"addContactMethod": "İletişim Yöntemi Ekle",
"editContactMethod": "İletişim Yöntemini Düzenle",
"joinTheServer": "Sunucuya katıl:",
"customMessagePlaceholderHeader": "Bu kartı özelleştir",
"customMessagePlaceholderContent": "Bu kartı özelleştirmek için ayarlarda kullanıcı sayfası düzenleme düğmesine tıklayın ya da oturum açma ekranında bir tane gösterin ve endişelenmeyin, kullanıcı bunu göremez.",
"userPageSuccessMessage": "Hesabınızla ilgili ayrıntıları daha sonra {myAccount} sayfasında görebilir ve değiştirebilirsiniz.",
"resetPassword": "Şifreyi Sıfırla",
"resetPasswordThroughJellyfin": "Şifrenizi sıfırlamak için {jfLink} adresini ziyaret edin ve \"Şifremi Unuttum\" düğmesine basın.",
"resetPasswordThroughLink": "Şifrenizi sıfırlamak için kullanıcı adınızı, e-posta adresinizi veya bağlı bir iletişim yöntemi kullanıcı adınızı girin ve gönderin. Şifrenizi sıfırlamanız için bir bağlantı gönderilecektir.",
"resetPasswordThroughLinkStart": "Şifrenizi sıfırlamak için aşağıdakilerden birini girin:",
"resetPasswordThroughLinkEnd": "Şifrenizi sıfırlamanız için bir bağlantı gönderilecektir. Ardından gönder'e basın.",
"resetPasswordUsername": "Jellyfin kullanıcı adınız",
"resetPasswordEmail": "E-posta adresiniz",
"resetPasswordContactMethod": "Hesabınıza bağlı herhangi bir iletişim yönteminin kullanıcı adı",
"resetSent": "Sıfırlama Gönderildi.",
"resetSentDescription": "Verilen kullanıcı adı/iletişim yöntemine sahip bir hesap varsa, mevcut tüm iletişim yöntemleri aracılığıyla bir şifre sıfırlama bağlantısı gönderilmiştir. Kodun süresi **30 dakika** içinde dolacaktır.",
"changePassword": "Şifreyi Değiştir",
"referralsDescription": "Bu bağlantı ile arkadaşlarınızı ve ailenizi Jellyfin'e davet edin. Süresi dolarsa yeni bir tane almak için buraya geri gelin.",
"referralsWithExpiryDescription": "Bu bağlantı ile arkadaşlarınızı ve ailenizi Jellyfin'e davet edin. Bağlantının süresi dolduğunda devre dışı bırakılacaktır.",
"copyReferral": "Linki Kopyala",
"invitedBy": "Sizi {user} adlı kullanıcı davet etti."
},
"notifications": {
"errorUserExists": "Kullanıcı zaten mevcut.",
"errorInvalidCode": "Geçersiz davet kodu.",
"errorAccountLinked": "Hesap zaten kullanımda.",
"errorEmailLinked": "E-posta zaten kullanımda.",
"errorTelegramVerification": "Telegram doğrulama gerekli.",
"errorDiscordVerification": "Discord doğrulama gerekli.",
"errorMatrixVerification": "Matrix doğrulama gerekli.",
"errorInvalidPIN": "PIN geçersiz.",
"errorUnknown": "Bilinmeyen hata.",
"errorNoEmail": "E-posta gerekli.",
"errorCaptcha": "Captcha yanlış.",
"errorPassword": "Şifre gereksinimlerini kontrol edin.",
"errorNoMatch": "Şifreler eşleşmiyor.",
"errorOldPassword": "Eski şifre yanlış.",
"passwordChanged": "Şifre Değiştirildi.",
"verified": "Hesap doğrulandı."
},
"validationStrings": {
"length": {
"singular": "En az {n} karakter içermeli",
"plural": "En az {n} karakter içermeli"
},
"uppercase": {
"singular": "En az {n} büyük harf içermeli",
"plural": "En az {n} büyük harf içermeli"
},
"lowercase": {
"singular": "En az {n} küçük harf içermeli",
"plural": "En az {n} küçük harf içermeli"
},
"number": {
"singular": "En az {n} küçük harf içermeli",
"plural": "En az {n} küçük harf içermeli"
},
"special": {
"singular": "En az {n} özel karakter içermeli",
"plural": "En az {n} özel karakter içermeli"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"meta": {
"name": "İngilizce (ABD)"
},
"strings": {
"passwordReset": "Şifre sıfırlama",
"reset": "Sıfırla",
"resetFailed": "Şifre sıfırlama başarısız oldu",
"tryAgain": "Lütfen tekrar deneyin.",
"youCanLogin": "Artık aşağıdaki kodla şifreniz olarak oturum açabilirsiniz.",
"youCanLoginOmbi": "Artık aşağıdaki kodu şifreniz olarak kullanarak Jellyfin & Ombi'ye oturum açabilirsiniz.",
"youCanLoginPassword": "Artık yeni şifrenizle oturum açabilirsiniz. Jellyfin'e devam etmek için aşağıya basın.",
"changeYourPassword": "Oturum açtıktan sonra şifrenizi değiştirdiğinizden emin olun.",
"enterYourPassword": "Yeni şifrenizi aşağıya girin."
}
}
+1 -1
View File
@@ -141,7 +141,7 @@
}, },
"notifications": { "notifications": {
"title": "Admin Notifications", "title": "Admin Notifications",
"description": "If enabled, you can choose (per invite) to receive an message when an invite expires, or a user is created. If you didn't choose the Jellyfin login method, make sure you provided your email address, or add another contact method later." "description": "If enabled, you can choose (per invite) to receive a message when an invite expires, or a user is created. If you didn't choose the Jellyfin login method, make sure you provided your email address, or add another contact method later."
}, },
"inviteEmails": { "inviteEmails": {
"title": "Invite Messages", "title": "Invite Messages",
+3 -3
View File
@@ -18,7 +18,7 @@
"apiKey": "API Key", "apiKey": "API Key",
"error": "Error", "error": "Error",
"errorInvalidUserPass": "Invalid username/password.", "errorInvalidUserPass": "Invalid username/password.",
"errorNotAdmin": "User is not aEnabledllowed to manage server.", "errorNotAdmin": "User is not allowed to manage server.",
"errorUserDisabled": "User may be disabled.", "errorUserDisabled": "User may be disabled.",
"error404": "404, check the internal URL.", "error404": "404, check the internal URL.",
"errorConnectionRefused": "Connection refused.", "errorConnectionRefused": "Connection refused.",
@@ -126,7 +126,7 @@
}, },
"notifications": { "notifications": {
"title": "Admin Notifications", "title": "Admin Notifications",
"description": "If enabled, you can choose (per invite) to receive an message when an invite expires, or a user is created. If you didn't choose the Jellyfin login method, make sure you provided your email address, or add another contact method later." "description": "If enabled, you can choose (per invite) to receive a message when an invite expires, or a user is created. If you didn't choose the Jellyfin login method, make sure you provided your email address, or add another contact method later."
}, },
"userPage": { "userPage": {
"title": "User Page", "title": "User Page",
@@ -136,7 +136,7 @@
}, },
"welcomeEmails": { "welcomeEmails": {
"title": "Welcome messages", "title": "Welcome messages",
"description": "If enabled, an message will be sent to new users with the Jellyfin/Emby URL and their username." "description": "If enabled, a message will be sent to new users with the Jellyfin/Emby URL and their username."
}, },
"inviteEmails": { "inviteEmails": {
"title": "Invite Messages", "title": "Invite Messages",
+123 -93
View File
@@ -20,132 +20,162 @@
"errorNotAdmin": "A felhasználó számára nincs engedélyezve a szerver kezelése.", "errorNotAdmin": "A felhasználó számára nincs engedélyezve a szerver kezelése.",
"errorUserDisabled": "Lehetséges, hogy a felhasználó le lett tiltva.", "errorUserDisabled": "Lehetséges, hogy a felhasználó le lett tiltva.",
"error404": "404, ellenőrizze a belső URL-t.", "error404": "404, ellenőrizze a belső URL-t.",
"errorConnectionRefused": "", "errorConnectionRefused": "Csatlakozás visszautasítva.",
"error": "Hiba" "error": "Hiba",
"errorUnknown": "Váratlan hiba, ellenőrizd a napló fájlt.",
"errorProxy": "Proxy beállítás érvénytelen."
}, },
"startPage": { "startPage": {
"welcome": "Üdv!", "welcome": "Üdv!",
"pressStart": "", "pressStart": "A jfa-go beállításához néhány dolgot el kell végezned. A folytatáshoz nyomd meg a kezdés gombot.",
"httpsNotice": "", "httpsNotice": "Győződjön meg róla, hogy HTTPS-en vagy privát hálózaton keresztül éri el ezt az oldalt.",
"start": "" "start": "Kezdés"
}, },
"endPage": { "endPage": {
"finished": "", "finished": "Kész!",
"restartMessage": "", "restartMessage": "",
"refreshPage": "" "refreshPage": "Újratöltés",
"moreFeatures": "Rengeteg további funkció, mint például a Discord/Telegram/Matrix botok és az egyéni Markdown üzenetek, megtalálható a Beállításokban, ezért mindenképpen böngészd át őket.",
"restartReload": "Kattints ide az újraindításhoz, majd a megadott belső/külső URL-címek egyikén nyisd meg a jfa-go alkalmazást.",
"ifFailedLoad": "Ha nem töltődik be, ellenőrizd az alkalmazás naplóit, hogy miért."
}, },
"language": { "language": {
"title": "", "title": "Nyelv",
"description": "", "description": "A jfa-go legtöbb részéhez elérhetők közösségi fordítások. Az alábbiakban kiválaszthatod az alapértelmezett nyelveket, de a felhasználók továbbra is módosíthatják azokat, ha akarják. Ha szeretnél segíteni a fordításban, regisztrálj a {n}-re, hogy elkezdhesd a közreműködést!",
"defaultAdminLang": "", "defaultAdminLang": "Alapártelmezett rendszergazda nyelv",
"defaultFormLang": "", "defaultFormLang": "Alapértelmezett fiók nyelv",
"defaultEmailLang": "" "defaultEmailLang": "Alapértelmezett email nyelv"
}, },
"general": { "general": {
"title": "", "title": "Alap",
"listenAddress": "", "listenAddress": "Figyelő címe",
"urlBase": "", "urlBase": "Alap URL",
"urlBaseNotice": "", "urlBaseNotice": "Csak akkor szükséges, ha fordított proxyt használsz egy almappán (pl. 'jellyf.in/accounts').",
"lightTheme": "", "lightTheme": "Fényes",
"darkTheme": "", "darkTheme": "Sötét",
"useHTTPS": "", "useHTTPS": "HTTPS használata",
"httpsPort": "", "httpsPort": "HTTPS Port",
"useHTTPSNotice": "", "useHTTPSNotice": "Csak akkor aljánlott ha fordított proxy-t használsz.",
"pathToCertificate": "", "pathToCertificate": "Tanúsítvány elérési útja",
"pathToKeyFile": "" "pathToKeyFile": "Kulcs fájl elérési útja",
"externalURLNotice": "Az URL, amelyről a jfa-go címhez fogsz hozzáférni. Linkek generálására szolgál, például jelszó-visszaállításhoz. Ha beállítottál egyet, feltétlenül add meg a fenti alap URL-t is.",
"externalURL": "Külső jfa-go URL"
}, },
"updates": { "updates": {
"title": "", "title": "Frissítések",
"description": "", "description": "Engedélyezd ha szeretnél értesítést az új frissítésekről. A jfa-go 30 percenként ellenőrzi a(z) {n} címet. Nem gyűjt IP-címeket vagy személyes adatokat.",
"updateChannel": "", "updateChannel": "Csatorna frissítése",
"stable": "", "stable": "Stabil",
"unstable": "" "unstable": "Instabil"
}, },
"login": { "login": {
"title": "", "title": "Belépés",
"description": "", "description": "Az admin oldal eléréséhez az alábbi módszerrel kell bejelentkezned:",
"authorizeWithJellyfin": "", "authorizeWithJellyfin": "Bejelentkezés Jellyfin/Emby segítségével: A bejelentkezési adatok meg vannak osztva a Jellyfin-nel, ami több felhasználó létrehozását teszi lehetővé.",
"authorizeManual": "", "authorizeManual": "Felhasználónév és Jelszó: Felhasználónév és jelszó manuális beállítása.",
"adminOnly": "", "adminOnly": "Csak rendszergazda felhasználók (ajánlott)",
"allowAll": "", "allowAll": "Összes Jellyfin felhasználó belépéssének engedélyezése",
"allowAllDescription": "", "allowAllDescription": "Nem ajánlott, a beállítás után engedélyezni kell az egyes felhasználók bejelentkezését.",
"emailNotice": "" "emailNotice": "Az email címed értesítések fogadására lesz használva.",
"authorizeManualUserPageNotice": "Ennek használata letiltja a „Felhasználói oldal” funkciót."
}, },
"jellyfinEmby": { "jellyfinEmby": {
"title": "", "title": "Jellyfin/Emby",
"description": "", "description": "Egy adminisztrátori fiók szükséges, mivel az API nem engedélyezi a felhasználók létrehozását API-kulcs használatával. Létre kell hoznia egy külön fiókot, és engedélyeznie kell az „Ez a felhasználó kezelheti a szervert” beállítást. Minden mást letilthat. Ha ezt megtette, adja meg itt a hitelesítő adatait.",
"embyNotice": "", "embyNotice": "Az Emby támogatása korlátozott, és nem támogatja a jelszó-visszaállítást.",
"internal": "", "internal": "Belső",
"external": "", "external": "Külső",
"replaceJellyfin": "", "replaceJellyfin": "Szerver neve",
"replaceJellyfinNotice": "", "replaceJellyfinNotice": "Ha meg van adva, ez felülírja a 'Jellyfin' minden előfordulását az alkalmazásban.",
"addressExternalNotice": "", "addressExternalNotice": "Hagyja üresen, ha ugyanazt a címet szeretnéd használni.",
"testConnection": "" "testConnection": "Kapcsolat tesztelése"
}, },
"ombi": { "ombi": {
"title": "", "title": "Ombi",
"description": "", "description": "Az Ombihoz való csatlakozással Jellyfin és Ombi fiók is létrejön, amikor a felhasználó a jfa-go-n keresztül csatlakozik. A beállítás befejezése után lépjen a Beállítások menüpontra, hogy alapértelmezett profilt állítson be az új ombi-felhasználók számára.",
"apiKeyNotice": "" "apiKeyNotice": "Ezt az Ombi beállítások első lapján találod.",
"stabilityWarning": "Figyelmeztetés: Az Ombi integráció instabil, és problémákat okozhat. Helyette a Jellyseerr használata ajánlott. További információkért lásd: {n}."
}, },
"messages": { "messages": {
"title": "", "title": "Üzenetek",
"description": "" "description": "A jfa-go jelszó-visszaállítási információkat és különféle üzeneteket tud küldeni e-mailben, Discordon, Telegramon és/vagy Matrixon keresztül. Az e-mailt alább állíthatod be, a többit pedig később a Beállításokban konfigurálhatod. Az utasításokat a {n} oldalon találod. Ha erre nincs szükséged, itt letilthatod ezeket a funkciókat."
}, },
"email": { "email": {
"title": "", "title": "Email",
"description": "", "description": "A jfa-go jelszó-visszaállító PIN-kódokat és különféle értesítéseket tud küldeni e-mailben. Csatlakozhatsz egy SMTP-kiszolgálóhoz, vagy használhatod az {n} API-t.",
"method": "", "method": "Küldési mód",
"useEmailAsUsername": "", "useEmailAsUsername": "Email cím használata fehasználónévnek",
"useEmailAsUsernameNotice": "", "useEmailAsUsernameNotice": "Ha engedélyezve van, az új felhasználók a Jellyfin/Emby rendszerbe felhasználónév helyett az e-mail címükkel jelentkeznek be.",
"fromAddress": "", "fromAddress": "Feladó címe",
"senderName": "", "senderName": "Küldő címe",
"dateFormat": "", "dateFormat": "Dátum formátuma",
"dateFormatNotice": "", "dateFormatNotice": "A dátum az strftime formátumot követi. További információkért látogasson el a {n} oldalra.",
"encryption": "", "encryption": "Titkosítás",
"mailgunApiURL": "" "mailgunApiURL": "API URL"
}, },
"notifications": { "notifications": {
"title": "", "title": "Rendszergazda értesítések",
"description": "" "description": "Ha engedélyezve van, meghívónként kiválaszthatod, hogy üzenetet kapj-e, amikor egy meghívó lejár, vagy amikor létrejön egy felhasználó. Ha nem a Jellyfin bejelentkezési módot választottad, győződj meg róla, hogy megadtad az e-mail címedet, vagy adj hozzá később egy másik kapcsolatfelvételi módot."
}, },
"welcomeEmails": { "welcomeEmails": {
"title": "", "title": "Üdvözlő üzenetek",
"description": "" "description": "Ha engedélyezve van, az új felhasználók üzenetben kapják meg a Jellyfin/Emby URL-címet és a felhasználónevüket."
}, },
"inviteEmails": { "inviteEmails": {
"title": "", "title": "Meghívó üzenetek",
"description": "" "description": "Ha engedélyezve van, közvetlenül a felhasználó e-mail címére, Discord vagy Matrix felhasználóra küldhet meghívókat. Mivel fordított proxyt használhat, meg kell adnia azt az URL-címet, ahonnan a meghívók elérhetők. Írja be az URL-alapját, és fűzze hozzá a '/invite' részt."
}, },
"passwordResets": { "passwordResets": {
"title": "", "title": "Jelszó visszaállítás",
"description": "", "description": "Amikor egy felhasználó megpróbálja visszaállítani a jelszavát, a Jellyfin létrehoz egy „passwordreset-*.json” nevű fájlt, amely egy PIN-kódot tartalmaz. A jfa-go beolvassa a fájlt, és elküldi a PIN-kódot a felhasználónak. Ha engedélyezte a „Felhasználói oldal” funkciót, a visszaállítás ott is elvégezhető felhasználónév, e-mail cím vagy kapcsolatfelvételi mód megadásával.",
"pathToJellyfin": "", "pathToJellyfin": "Jellyfin konfigurációs könyvtár elérési útja",
"pathToJellyfinNotice": "", "pathToJellyfinNotice": "Ha nem tudod, hol van ez, próbáld meg visszaállítani a jelszavadat a Jellyfinben. Megjelenik egy felugró ablak a következővel: '<jellyfin elérési útja>/passwordreset-*.json'. Ez nem szükséges, ha csak az önkiszolgáló jelszó-visszaállítást szeretnéd használni a \"Felhasználói oldalon\".",
"resetLinks": "", "resetLinks": "Link küldése PIN kód helyett",
"resetLinksNotice": "", "resetLinksNotice": "Ha az Ombi integráció engedélyezve van, használja ezt a Jellyfin jelszó-visszaállítások Ombival való szinkronizálásához.",
"resetLinksLanguage": "", "resetLinksLanguage": "Alapértelmezett jelszó-visszaállítási nyelv",
"setPassword": "", "setPassword": "Jelszó beállítás linken keresztül",
"setPasswordNotice": "" "setPasswordNotice": "Ha engedélyezve van, a felhasználónak nem kell PIN-kóddal módosítania a jelszavát. Ez a jelszó-ellenőrzést is kikényszeríti.",
"moreInfo": "A jelszavak visszaállításának különböző módjairól további információt a {n} oldalon talál.",
"resetLinksRequiredForUserPage": "Szükséges az önkiszolgáló jelszó-visszaállításhoz a felhasználói oldalon."
}, },
"passwordValidation": { "passwordValidation": {
"title": "", "title": "Jelszóérvényesítés",
"description": "", "description": "Ha engedélyezve van, a fiók létrehozási oldalán megjelennek a jelszóra vonatkozó követelmények, például a minimális hossz, a nagy- és kisbetűk stb.",
"length": "", "length": "Hossz",
"uppercase": "", "uppercase": "Nagybetűs karakterek",
"lowercase": "", "lowercase": "Kisbetűs karakterek",
"numbers": "", "numbers": "Számok",
"special": "" "special": "Speciális karakterek"
}, },
"helpMessages": { "helpMessages": {
"title": "", "title": "Súgóüzenetek",
"description": "", "description": "Ezek az üzenetek a fiók létrehozási oldalán és néhány e-mailben jelennek meg.",
"contactMessage": "", "contactMessage": "Kapcsolatfelvételi üzenet",
"contactMessageNotice": "", "contactMessageNotice": "Az adminisztrációs oldal kivételével az összes oldal alján megjelenik.",
"helpMessage": "", "helpMessage": "Súgóüzenet",
"helpMessageNotice": "", "helpMessageNotice": "A fiók létrehozási oldalán jelenik meg.",
"successMessage": "", "successMessage": "Sikeres üzenet",
"successMessageNotice": "", "successMessageNotice": "Akkor jelenik meg, amikor a felhasználó létrehozza a fiókját.",
"emailMessage": "", "emailMessage": "Email üzenet",
"emailMessageNotice": "" "emailMessageNotice": "Az e-mailek alján jelenik meg.",
"markdownMessageNotice": "Egyes e-mailek, oldalak és üzenetek tartalma testreszabható a Markdown segítségével a beállításokban."
},
"jellyseerr": {
"description": "A Jellyseerr az Ombi alternatívája, és jobban integrálódik a jfa-go-val. A beállítás befejezése után a Beállítások menüpontban hozz létre egy profilt, és adj hozzá egy sablont az új Jellyseerr fiókokhoz.",
"title": "Jellyseerr",
"importExisting": "Meglévő fiókok importálása",
"importExistingDescription": "Ha engedélyezve van, a meglévő felhasználók elérhetőségi adatai és beállításai szinkronizálva lesznek a jfa-go rendszerből."
},
"userPage": {
"description": "A felhasználói oldal („Fiókom” néven látható) lehetővé teszi a felhasználók számára, hogy hozzáférjenek a fiókjukkal kapcsolatos információkhoz, például a kapcsolatfelvételi módokhoz és a fiók lejáratához. Megváltoztathatják jelszavukat, jelszó-visszaállítást kezdeményezhetnek, és összekapcsolhatják/módosíthatják a kapcsolatfelvételi módokat anélkül, hogy megkérdeznék Önt. Ezenkívül személyre szabott Markdown-üzenetek jeleníthetők meg a felhasználóknak a bejelentkezés előtt és után.",
"title": "Felhasználói oldal",
"customizeMessages": "Kattintson a beállításokban a „Felhasználói oldal” melletti szerkesztés gombra a későbbi módosításhoz.",
"requiredSettings": "A jfa-go-ba Jellyfinen keresztül történő bejelentkezést be kell állítani. Győződjön meg róla, hogy a „jelszó visszaállítása linken keresztül” lehetőség van kiválasztva később az önkiszolgáló jelszó-visszaállításhoz."
},
"proxy": {
"title": "Proxy",
"description": "A jfa-go minden kapcsolatot HTTP/SOCKS5 proxyn keresztül hozzon létre. A Jellyfinhez való csatlakozást ezen a proxyn keresztül fogja tesztelni.",
"protocol": "Protokoll",
"address": "Cím (Port-al együtt)"
} }
} }
+180
View File
@@ -0,0 +1,180 @@
{
"meta": {
"name": "İngilizce (ABD)"
},
"strings": {
"pageTitle": "Kurulum - jfa-go",
"next": "İleri",
"back": "Geri",
"optional": "İsteğe Bağlı",
"serverType": "Sunucu Türü",
"disabled": "Devre Dışı",
"enabled": "Etkin",
"port": "Bağlantı Noktası",
"message": "Mesaj",
"serverAddress": "Sunucu Adresi",
"emailSubject": "E-posta Konusu",
"URL": "URL",
"apiKey": "API Anahtarı",
"error": "Hata",
"errorInvalidUserPass": "Geçersiz kullanıcı adı/şifre.",
"errorNotAdmin": "Kullanıcının sunucuyu yönetmesine izin verilmiyor.",
"errorUserDisabled": "Kullanıcı devre dışı bırakılmış olabilir.",
"error404": "404, dahili URL'yi kontrol edin.",
"errorConnectionRefused": "Bağlantı reddedildi.",
"errorUnknown": "Bilinmeyen hata, uygulama günlüklerini kontrol edin.",
"errorProxy": ""
},
"startPage": {
"welcome": "",
"pressStart": "",
"httpsNotice": "",
"start": ""
},
"endPage": {
"finished": "",
"moreFeatures": "",
"restartReload": "",
"ifFailedLoad": "",
"refreshPage": ""
},
"language": {
"title": "",
"description": "",
"defaultAdminLang": "",
"defaultFormLang": "",
"defaultEmailLang": ""
},
"general": {
"title": "",
"listenAddress": "",
"urlBase": "",
"urlBaseNotice": "",
"externalURL": "",
"externalURLNotice": "",
"lightTheme": "",
"darkTheme": "",
"useHTTPS": "",
"httpsPort": "",
"useHTTPSNotice": "",
"pathToCertificate": "",
"pathToKeyFile": ""
},
"updates": {
"title": "",
"description": "",
"updateChannel": "",
"stable": "",
"unstable": ""
},
"proxy": {
"title": "",
"description": "",
"protocol": "",
"address": ""
},
"login": {
"title": "",
"description": "",
"authorizeWithJellyfin": "",
"authorizeManual": "",
"adminOnly": "",
"allowAll": "",
"allowAllDescription": "",
"authorizeManualUserPageNotice": "",
"emailNotice": ""
},
"jellyfinEmby": {
"title": "",
"description": "",
"embyNotice": "",
"internal": "",
"external": "",
"replaceJellyfin": "",
"replaceJellyfinNotice": "",
"addressExternalNotice": "",
"testConnection": ""
},
"ombi": {
"title": "",
"description": "",
"apiKeyNotice": "",
"stabilityWarning": ""
},
"jellyseerr": {
"title": "",
"description": "",
"importExisting": "",
"importExistingDescription": ""
},
"messages": {
"title": "",
"description": ""
},
"email": {
"title": "",
"description": "",
"method": "",
"useEmailAsUsername": "",
"useEmailAsUsernameNotice": "",
"fromAddress": "",
"senderName": "",
"dateFormat": "",
"dateFormatNotice": "",
"encryption": "",
"mailgunApiURL": ""
},
"notifications": {
"title": "",
"description": ""
},
"userPage": {
"title": "",
"description": "",
"customizeMessages": "",
"requiredSettings": ""
},
"welcomeEmails": {
"title": "",
"description": ""
},
"inviteEmails": {
"title": "",
"description": ""
},
"passwordResets": {
"title": "",
"description": "",
"moreInfo": "",
"pathToJellyfin": "",
"pathToJellyfinNotice": "",
"resetLinks": "",
"resetLinksRequiredForUserPage": "",
"resetLinksNotice": "",
"resetLinksLanguage": "",
"setPassword": "",
"setPasswordNotice": ""
},
"passwordValidation": {
"title": "",
"description": "",
"length": "",
"uppercase": "",
"lowercase": "",
"numbers": "",
"special": ""
},
"helpMessages": {
"title": "",
"description": "",
"markdownMessageNotice": "",
"contactMessage": "",
"contactMessageNotice": "",
"helpMessage": "",
"helpMessageNotice": "",
"successMessage": "",
"successMessageNotice": "",
"emailMessage": "",
"emailMessageNotice": ""
}
}
+2 -1
View File
@@ -13,6 +13,7 @@
"languageSet": "El idioma esta configurado como {language}.", "languageSet": "El idioma esta configurado como {language}.",
"discordDMs": "Por favor, compruebe sus DMs para una respuesta.", "discordDMs": "Por favor, compruebe sus DMs para una respuesta.",
"sentInvite": "Enviar invitación.", "sentInvite": "Enviar invitación.",
"sentInviteFailure": "Error al enviar la invitación, compruebe los logs." "sentInviteFailure": "Error al enviar la invitación, compruebe los logs.",
"noPermission": "No tienes permisos para esta acción."
} }
} }
+4 -1
View File
@@ -11,6 +11,9 @@
"languageMessage": "Megjegyzés: Az elérhető nyelveket a {command} parancsal láthatod, és a {command} <nyelv kód> parancsal szerkesztheted.", "languageMessage": "Megjegyzés: Az elérhető nyelveket a {command} parancsal láthatod, és a {command} <nyelv kód> parancsal szerkesztheted.",
"languageMessageDiscord": "Megjegyzés: a saját nyelvet a /lang <nyelv neve> parancsal tudod beállítani.", "languageMessageDiscord": "Megjegyzés: a saját nyelvet a /lang <nyelv neve> parancsal tudod beállítani.",
"languageSet": "Nyelv {language}-ra/re állítva.", "languageSet": "Nyelv {language}-ra/re állítva.",
"discordDMs": "Ellenőrizd az üzeneteidet." "discordDMs": "Ellenőrizd az üzeneteidet.",
"sentInvite": "Meghívó elküldve.",
"sentInviteFailure": "Meghívó elküldése sikertelen, ellenőrizd a napló fájlt.",
"noPermission": "Nincs jogosultságod erre a műveletre."
} }
} }
+9 -9
View File
@@ -21,7 +21,7 @@ import (
// } // }
type Logger struct { type Logger struct {
empty bool Empty bool
logger *log.Logger logger *log.Logger
shortfile bool shortfile bool
printer *c.Color printer *c.Color
@@ -75,13 +75,13 @@ func NewLogger(out io.Writer, prefix string, flag int, color c.Attribute) (l *Lo
func NewEmptyLogger() (l *Logger) { func NewEmptyLogger() (l *Logger) {
l = &Logger{ l = &Logger{
empty: true, Empty: true,
} }
return return
} }
func (l *Logger) Printf(format string, v ...interface{}) { func (l *Logger) Printf(format string, v ...interface{}) {
if l.empty { if l.Empty {
return return
} }
var out string var out string
@@ -93,7 +93,7 @@ func (l *Logger) Printf(format string, v ...interface{}) {
} }
func (l *Logger) PrintfCustomLevel(level int, format string, v ...interface{}) { func (l *Logger) PrintfCustomLevel(level int, format string, v ...interface{}) {
if l.empty { if l.Empty {
return return
} }
var out string var out string
@@ -105,14 +105,14 @@ func (l *Logger) PrintfCustomLevel(level int, format string, v ...interface{}) {
} }
func (l *Logger) PrintfNoFile(format string, v ...interface{}) { func (l *Logger) PrintfNoFile(format string, v ...interface{}) {
if l.empty { if l.Empty {
return return
} }
l.logger.Print(l.printer.Sprintf(format, v...)) l.logger.Print(l.printer.Sprintf(format, v...))
} }
func (l *Logger) Print(v ...interface{}) { func (l *Logger) Print(v ...interface{}) {
if l.empty { if l.Empty {
return return
} }
var out string var out string
@@ -124,7 +124,7 @@ func (l *Logger) Print(v ...interface{}) {
} }
func (l *Logger) Println(v ...interface{}) { func (l *Logger) Println(v ...interface{}) {
if l.empty { if l.Empty {
return return
} }
var out string var out string
@@ -136,7 +136,7 @@ func (l *Logger) Println(v ...interface{}) {
} }
func (l *Logger) Fatal(v ...interface{}) { func (l *Logger) Fatal(v ...interface{}) {
if l.empty { if l.Empty {
return return
} }
var out string var out string
@@ -148,7 +148,7 @@ func (l *Logger) Fatal(v ...interface{}) {
} }
func (l *Logger) Fatalf(format string, v ...interface{}) { func (l *Logger) Fatalf(format string, v ...interface{}) {
if l.empty { if l.Empty {
return return
} }
var out string var out string
+15
View File
@@ -70,6 +70,8 @@ const (
FailedInitTelegram = "Failed to initialize Telegram daemon: %v" FailedInitTelegram = "Failed to initialize Telegram daemon: %v"
InitMatrix = "Initialized Matrix daemon" InitMatrix = "Initialized Matrix daemon"
FailedInitMatrix = "Failed to initialize Matrix daemon: %v" FailedInitMatrix = "Failed to initialize Matrix daemon: %v"
InitingMatrixCrypto = "Initializing Matrix encryption store"
InitMatrixCrypto = "Initialized Matrix encryption store"
InitRouter = "Initializing router" InitRouter = "Initializing router"
LoadRoutes = "Loading Routes" LoadRoutes = "Loading Routes"
@@ -185,6 +187,10 @@ const (
IncorrectCaptcha = "captcha incorrect" IncorrectCaptcha = "captcha incorrect"
ExtendCreateExpiry = "Extended or created expiry for user \"%s\"" ExtendCreateExpiry = "Extended or created expiry for user \"%s\""
FoundExistingExpiry = "Found existing expiry key"
FoundPreviousExpiryLog = "Found most recent previous expiry in activity log @ %v"
ExpiryWouldBeInPast = "Expiry would've been in the past, using current time base"
PreviousExpiryNotExpiry = "Last user disable was not an expiry, using current time base"
UserEmailAdjusted = "Email for user \"%s\" adjusted" UserEmailAdjusted = "Email for user \"%s\" adjusted"
UserAdminAdjusted = "Admin state for user \"%s\" set to %t" UserAdminAdjusted = "Admin state for user \"%s\" set to %t"
@@ -273,6 +279,7 @@ const (
// pwreset.go // pwreset.go
PWRExpired = "PWR for user \"%s\" already expired @ %s, check system time!" PWRExpired = "PWR for user \"%s\" already expired @ %s, check system time!"
NewPWRForUser = "New password reset for user \"%s\""
// router.go // router.go
UseDefaultHTML = "Using default HTML \"%s\"" UseDefaultHTML = "Using default HTML \"%s\""
@@ -294,6 +301,8 @@ const (
FailedGetUpdateTag = "Failed to get latest tag: %v" FailedGetUpdateTag = "Failed to get latest tag: %v"
FailedGetUpdate = "Failed to get update: %v" FailedGetUpdate = "Failed to get update: %v"
UpdateTagDetails = "Update/Tag details: %+v" UpdateTagDetails = "Update/Tag details: %+v"
TagEmpty = "tag was empty"
TagAtEmpty = "tag at \"%s\" was empty"
// user-auth.go // user-auth.go
UserPage = "userpage" UserPage = "userpage"
@@ -345,6 +354,8 @@ const (
) )
const ( const (
FailedConstructCustomContent = "Possible error in custom content \"%s\": %v"
FailedConstructExpiryAdmin = "Failed to construct expiry notification for \"%s\": %v" FailedConstructExpiryAdmin = "Failed to construct expiry notification for \"%s\": %v"
FailedSendExpiryAdmin = "Failed to send expiry notification for \"%s\" to \"%s\": %v" FailedSendExpiryAdmin = "Failed to send expiry notification for \"%s\" to \"%s\": %v"
SentExpiryAdmin = "Sent expiry notification for \"%s\" to \"%s\"" SentExpiryAdmin = "Sent expiry notification for \"%s\" to \"%s\""
@@ -382,6 +393,10 @@ const (
FailedSendExpiryAdjustmentMessage = "Failed to send expiry adjustment message for \"%s\" to \"%s\": %v" FailedSendExpiryAdjustmentMessage = "Failed to send expiry adjustment message for \"%s\" to \"%s\": %v"
SentExpiryAdjustmentMessage = "Sent expiry adjustment message for \"%s\" to \"%s\"" SentExpiryAdjustmentMessage = "Sent expiry adjustment message for \"%s\" to \"%s\""
FailedConstructExpiryReminderMessage = "Failed to construct expiry reminder message for \"%s\": %v"
FailedSendExpiryReminderMessage = "Failed to send expiry reminder message for \"%s\" to \"%s\": %v"
SentExpiryReminderMessage = "Sent expiry reminder message for \"%s\" to \"%s\""
FailedConstructExpiryMessage = "Failed to construct expiry message for \"%s\": %v" FailedConstructExpiryMessage = "Failed to construct expiry message for \"%s\": %v"
FailedSendExpiryMessage = "Failed to send expiry message for \"%s\" to \"%s\": %v" FailedSendExpiryMessage = "Failed to send expiry message for \"%s\" to \"%s\": %v"
SentExpiryMessage = "Sent expiry message for \"%s\" to \"%s\"" SentExpiryMessage = "Sent expiry message for \"%s\" to \"%s\""
+7 -68
View File
@@ -1,78 +1,17 @@
<mjml> <mjml>
<mj-head> <mj-include path="./layout/header.mjml" />
<mj-raw>
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
</mj-raw>
<mj-style>
:root {
Color-scheme: light dark;
supported-color-schemes: light dark;
}
@media (prefers-color-scheme: light) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
@media (prefers-color-scheme: dark) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
</mj-style>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="#cacaca" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body> <mj-body>
<mj-section mj-class="bg2"> <mj-include path="./layout/body-start.mjml" />
<mj-section mj-class="body">
<mj-column> <mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> {{ .jellyfin }} </mj-text> <mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<p>{{ .helloUser }}</p> <p>{{ .helloUser }}</p>
<p>{{ .clickBelow }}</p> <p>{{ .clickBelow }}</p>
<p>{{ .ifItWasNotYou }}</p> <p>{{ .ifItWasNotYou }}</p>
</mj-text> </mj-text>
<mj-button mj-class="blue bold" href="{{ .confirmationURL }}">{{ .confirmEmail }}</mj-button> <mj-button mj-class="blue text-white" href="{{ .confirmationURL }}">{{ .confirmEmail }}</mj-button>
</mj-column> </mj-column>
</mj-section> </mj-section>
<mj-section mj-class="bg2"> <mj-include path="./layout/body-end.mjml" />
<mj-column> </mj-body>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
{{ .message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml> </mjml>
+1 -1
View File
@@ -5,4 +5,4 @@
{{ .confirmationURL }} {{ .confirmationURL }}
{{ .message }} {{ .footer }}
+8 -69
View File
@@ -1,86 +1,25 @@
<mjml> <mjml>
<mj-head> <mj-include path="./layout/header.mjml" />
<mj-raw>
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
</mj-raw>
<mj-style>
:root {
Color-scheme: light dark;
supported-color-schemes: light dark;
}
@media (prefers-color-scheme: light) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
@media (prefers-color-scheme: dark) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
</mj-style>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="#cacaca" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body> <mj-body>
<mj-section mj-class="bg2"> <mj-include path="./layout/body-start.mjml" />
<mj-section mj-class="body">
<mj-column> <mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> jellyfin-accounts </mj-text> <mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<p>{{ .aUserWasCreated }}</p> <p>{{ .aUserWasCreated }}</p>
</mj-text> </mj-text>
<mj-table mj-class="text" container-background-color="#242424"> <mj-table css-class="bg-gray" mj-class="bg-gray">
<tr style="text-align: left;"> <tr style="text-align: left;">
<th>{{ .nameString }}</th> <th>{{ .nameString }}</th>
<th>{{ .addressString }}</th> <th>{{ .addressString }}</th>
<th>{{ .timeString }}</th> <th>{{ .timeString }}</th>
</tr> </tr>
<tr style="font-style: italic; text-align: left; color: rgb(153,153,153);"> <tr class="text-gray" style="font-style: italic; text-align: left;">
<th>{{ .name }}</th> <th>{{ .name }}</th>
<th>{{ .address }}</th> <th>{{ .address }}</th>
<th>{{ .time }}</th> <th>{{ .time }}</th>
</mj-table> </mj-table>
</mj-column> </mj-column>
</mj-section> </mj-section>
<mj-section mj-class="bg2"> <mj-include path="./layout/body-end.mjml" />
<mj-column> </mj-body>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
{{ .notificationNotice }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml> </mjml>
+1 -1
View File
@@ -6,4 +6,4 @@
{{ .timeString }}: {{ .time }} {{ .timeString }}: {{ .time }}
{{ .notificationNotice }} {{ .footer }}
+8 -67
View File
@@ -1,76 +1,17 @@
<mjml> <mjml>
<mj-head> <mj-include path="./layout/header.mjml" />
<mj-raw>
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
</mj-raw>
<mj-style>
:root {
Color-scheme: light dark;
supported-color-schemes: light dark;
}
@media (prefers-color-scheme: light) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
@media (prefers-color-scheme: dark) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
</mj-style>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="#cacaca" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body> <mj-body>
<mj-section mj-class="bg2"> <mj-include path="./layout/body-start.mjml" />
<mj-section mj-class="body">
<mj-column> <mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> {{ .jellyfin }} </mj-text> <mj-text>
</mj-column> <p>{{ .helloUser }}</p>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<h3>{{ .yourAccountWas }}</h3> <h3>{{ .yourAccountWas }}</h3>
<p>{{ .reasonString }}: <i>{{ .reason }}</i></p> <p>{{ .reasonString }}: <i>{{ .reason }}</i></p>
</mj-text> </mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>
<mj-section mj-class="bg2"> <mj-include path="./layout/body-end.mjml" />
<mj-column> </mj-body>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
{{ .message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml> </mjml>
+3 -1
View File
@@ -1,5 +1,7 @@
{{ .helloUser }}
{{ .yourAccountWas }} {{ .yourAccountWas }}
{{ .reasonString }}: {{ .reason }} {{ .reasonString }}: {{ .reason }}
{{ .message }} {{ .footer }}
-84
View File
@@ -1,84 +0,0 @@
<mjml>
<mj-head>
<mj-raw>
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
</mj-raw>
<mj-style>
:root {
Color-scheme: light dark;
supported-color-schemes: light dark;
}
@media (prefers-color-scheme: light) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
@media (prefers-color-scheme: dark) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
</mj-style>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="#cacaca" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> {{ .jellyfin }} </mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<p>{{ .helloUser }}</p>
<p>{{ .someoneHasRequestedReset }}</p>
<p>{{ .ifItWasYou }}</p>
<p>{{ .codeExpiry }}</p>
<p>{{ .ifItWasNotYou }}</p>
</mj-text>
<mj-raw>{{ if .link_reset }}</mj-raw>
<mj-button mj-class="blue bold" href="{{ .pin }}"><mj-raw>{{ .pin_code }}</mj-raw></mj-button>
<mj-raw>{{ else }}</mj-raw>
<mj-button mj-class="blue bold"><mj-raw>{{ .pin }}</mj-raw></mj-button>
<mj-raw>{{ end }}</mj-raw>
</mj-column>
</mj-section>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
{{ .message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>
+6 -67
View File
@@ -1,76 +1,15 @@
<mjml> <mjml>
<mj-head> <mj-include path="./layout/header.mjml" />
<mj-raw>
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
</mj-raw>
<mj-style>
:root {
Color-scheme: light dark;
supported-color-schemes: light dark;
}
@media (prefers-color-scheme: light) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
@media (prefers-color-scheme: dark) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
</mj-style>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="#cacaca" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body> <mj-body>
<mj-section mj-class="bg2"> <mj-include path="./layout/body-start.mjml" />
<mj-section mj-class="body">
<mj-column> <mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> jellyfin-accounts </mj-text> <mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<h3>{{ .inviteExpired }}</h3> <h3>{{ .inviteExpired }}</h3>
<p>{{ .expiredAt }}</p> <p>{{ .expiredAt }}</p>
</mj-text> </mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>
<mj-section mj-class="bg2"> <mj-include path="./layout/body-end.mjml" />
<mj-column> </mj-body>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
{{ .notificationNotice }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml> </mjml>
+1 -1
View File
@@ -2,4 +2,4 @@
{{ .expiredAt }} {{ .expiredAt }}
{{ .notificationNotice }} {{ .footer }}
+6 -71
View File
@@ -1,83 +1,18 @@
<mjml> <mjml>
<mj-head> <mj-include path="./layout/header.mjml" />
<mj-raw>
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
</mj-raw>
<mj-style>
:root {
Color-scheme: light dark;
supported-color-schemes: light dark;
}
@media (prefers-color-scheme: light) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
@media (prefers-color-scheme: dark) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
</mj-style>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="#cacaca" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body> <mj-body>
<mj-section mj-class="bg2"> <mj-include path="./layout/body-start.mjml" />
<mj-section mj-class="body">
<mj-column> <mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> {{ .jellyfin }} </mj-text> <mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<p>{{ .helloUser }}</p> <p>{{ .helloUser }}</p>
<h3>{{ .yourExpiryWasAdjusted }}</h3> <h3>{{ .yourExpiryWasAdjusted }}</h3>
<p>{{ .ifPreviouslyDisabled }}</p> <p>{{ .ifPreviouslyDisabled }}</p>
<h4>{{ .newExpiry }}</h4> <h4>{{ .newExpiry }}</h4>
<p>{{ .reasonString }}: <i>{{ .reason }}</i></p> <p>{{ .reasonString }}: <i>{{ .reason }}</i></p>
</mj-text> </mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>
<mj-section mj-class="bg2"> <mj-include path="./layout/body-end.mjml" />
<mj-column> </mj-body>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
{{ .message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml> </mjml>
+1 -1
View File
@@ -8,4 +8,4 @@
{{ .reasonString }}: {{ .reason }} {{ .reasonString }}: {{ .reason }}
{{ .message }} {{ .footer }}
+15
View File
@@ -0,0 +1,15 @@
<mjml>
<mj-include path="./layout/header.mjml" />
<mj-body>
<mj-include path="./layout/body-start.mjml" />
<mj-section mj-class="body">
<mj-column>
<mj-text>
<p>{{ .helloUser }}</p>
<p>{{ .yourAccountIsDueToExpire }}</p>
</mj-text>
</mj-column>
</mj-section>
<mj-include path="./layout/body-end.mjml" />
</mj-body>
</mjml>
+5
View File
@@ -0,0 +1,5 @@
{{ .helloUser }}
{{ .yourAccountIsDueToExpire }}
{{ .footer }}
+7 -68
View File
@@ -1,79 +1,18 @@
<mjml> <mjml>
<mj-head> <mj-include path="./layout/header.mjml" />
<mj-raw>
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
</mj-raw>
<mj-style>
:root {
Color-scheme: light dark;
supported-color-schemes: light dark;
}
@media (prefers-color-scheme: light) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
@media (prefers-color-scheme: dark) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
</mj-style>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="#cacaca" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body> <mj-body>
<mj-section mj-class="bg2"> <mj-include path="./layout/body-start.mjml" />
<mj-section mj-class="body">
<mj-column> <mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> {{ .jellyfin }} </mj-text> <mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<p>{{ .hello }},</p> <p>{{ .hello }},</p>
<h3>{{ .youHaveBeenInvited }}</h3> <h3>{{ .youHaveBeenInvited }}</h3>
<p>{{ .toJoin }}</p> <p>{{ .toJoin }}</p>
<p>{{ .inviteExpiry }}</p> <p>{{ .inviteExpiry }}</p>
</mj-text> </mj-text>
<mj-button mj-class="blue bold" href="{{ .inviteURL }}">{{ .linkButton }}</mj-button> <mj-button mj-class="blue text-white" href="{{ .inviteURL }}">{{ .linkButton }}</mj-button>
</mj-column> </mj-column>
</mj-section> </mj-section>
<mj-section mj-class="bg2"> <mj-include path="./layout/body-end.mjml" />
<mj-column> </mj-body>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
{{ .message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml> </mjml>
+1 -1
View File
@@ -5,4 +5,4 @@
{{ .inviteURL }} {{ .inviteURL }}
{{ .message }} {{ .footer }}
+5
View File
@@ -0,0 +1,5 @@
<mj-section mj-class="bg-gray">
<mj-column>
<mj-text mj-class="secondary text-gray">{{ .footer }}</mj-text>
</mj-column>
</mj-section>
+5
View File
@@ -0,0 +1,5 @@
<mj-section mj-class="bg-gray">
<mj-column>
<mj-text mj-class="text-white" font-size="25px" font-family="Plus Jakarta Sans, Noto Sans, Helvetica, Arial, sans-serif"> {{ .header }} </mj-text>
</mj-column>
</mj-section>
+64
View File
@@ -0,0 +1,64 @@
<mj-head>
<mj-raw>
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
</mj-raw>
<mj-style>
:root {
Color-scheme: light dark;
supported-color-schemes: light dark;
}
body, .body {
background: #101010 !important;
background-color: #101010 !important;
}
.text-gray {
color: rgb(153,153,153) !important;
}
.bg-gray {
background: #292929 !important;
background-color: #292929 !important;
}
@media (prefers-color-scheme: light) {
Color-scheme: dark;
body, .body {
background: #101010 !important;
background-color: #101010 !important;
}
[data-ogsc] .body {
background: #101010 !important;
background-color: #101010 !important;
}
[data-ogsb] .body {
background: #101010 !important;
background-color: #101010 !important;
}
}
@media (prefers-color-scheme: dark) {
Color-scheme: dark;
body, .body {
background: #101010 !important;
background-color: #101010 !important;
}
[data-ogsc] .body {
background: #101010 !important;
background-color: #101010 !important;
}
[data-ogsb] .body {
background: #101010 !important;
background-color: #101010 !important;
}
}
</mj-style>
<mj-attributes>
<mj-class name="body" background-color="#101010" />
<mj-class name="bg-gray" background-color="#292929" />
<mj-class name="text-white" color="rgb(255,255,255)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
<mj-class name="secondary" font-style="italic" font-size="14px" />
<mj-class name="text-gray" color="rgb(153,153,153)" />
<mj-all font-family="Hanken Grotesk, Noto Sans, Helvetica, Arial, sans-serif" font-size="16px" color="rgba(255,255,255,0.8)">
</mj-attributes>
<mj-font name="Plus Jakarta Sans" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,700;1,700&display=swap" />
<mj-font name="Hanken Grotesk" href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:ital@0;1&display=swap" />
</mj-head>
+23
View File
@@ -0,0 +1,23 @@
<mjml>
<mj-include path="./layout/header.mjml" />
<mj-body>
<mj-include path="./layout/body-start.mjml" />
<mj-section mj-class="body">
<mj-column>
<mj-text>
<p>{{ .helloUser }}</p>
<p>{{ .someoneHasRequestedReset }}</p>
<p>{{ .ifItWasYou }}</p>
<p>{{ .codeExpiry }}</p>
<p>{{ .ifItWasNotYou }}</p>
</mj-text>
<mj-raw>{{ if .link_reset }}</mj-raw>
<mj-button mj-class="blue text-white" href="{{ .pin }}"><mj-raw>{{ .pin_code }}</mj-raw></mj-button>
<mj-raw>{{ else }}</mj-raw>
<mj-button mj-class="blue text-white"><mj-raw>{{ .pin }}</mj-raw></mj-button>
<mj-raw>{{ end }}</mj-raw>
</mj-column>
</mj-section>
<mj-include path="./layout/body-end.mjml" />
</mj-body>
</mjml>
+1 -1
View File
@@ -10,4 +10,4 @@
{{ .pinString }}: {{ .pin }} {{ .pinString }}: {{ .pin }}
{{ .message }} {{ .footer }}
+7 -68
View File
@@ -1,75 +1,14 @@
<mjml> <mjml>
<mj-head> <mj-include path="./layout/header.mjml" />
<mj-raw>
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
</mj-raw>
<mj-style>
:root {
Color-scheme: light dark;
supported-color-schemes: light dark;
}
@media (prefers-color-scheme: light) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
@media (prefers-color-scheme: dark) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
</mj-style>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="#cacaca" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body> <mj-body>
<mj-section mj-class="bg2"> <mj-include path="./layout/body-start.mjml" />
<mj-section mj-class="body">
<mj-column> <mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> {{ .jellyfin }} </mj-text> <mj-text>
</mj-column> {{ .text }}
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<mj-raw>{{ .text }}</mj-raw>
</mj-text> </mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>
<mj-section mj-class="bg2"> <mj-include path="./layout/body-end.mjml" />
<mj-column> </mj-body>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
{{ .message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml> </mjml>
+1 -1
View File
@@ -1,3 +1,3 @@
{{ .plaintext }} {{ .plaintext }}
{{ .message }} {{ .footer }}
+6 -67
View File
@@ -1,76 +1,15 @@
<mjml> <mjml>
<mj-head> <mj-include path="./layout/header.mjml" />
<mj-raw>
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
</mj-raw>
<mj-style>
:root {
Color-scheme: light dark;
supported-color-schemes: light dark;
}
@media (prefers-color-scheme: light) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
@media (prefers-color-scheme: dark) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
</mj-style>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="#cacaca" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body> <mj-body>
<mj-section mj-class="bg2"> <mj-include path="./layout/body-start.mjml" />
<mj-section mj-class="body">
<mj-column> <mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> {{ .jellyfin }} </mj-text> <mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<h3>{{ .yourAccountHasExpired }}</h3> <h3>{{ .yourAccountHasExpired }}</h3>
<p>{{ .contactTheAdmin }}</p> <p>{{ .contactTheAdmin }}</p>
</mj-text> </mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>
<mj-section mj-class="bg2"> <mj-include path="./layout/body-end.mjml" />
<mj-column> </mj-body>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
{{ .message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml> </mjml>
+1 -1
View File
@@ -2,4 +2,4 @@
{{ .contactTheAdmin }} {{ .contactTheAdmin }}
{{ .message }} {{ .footer }}
+6 -67
View File
@@ -1,65 +1,10 @@
<mjml> <mjml>
<mj-head> <mj-include path="./layout/header.mjml" />
<mj-raw>
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
</mj-raw>
<mj-style>
:root {
Color-scheme: light dark;
supported-color-schemes: light dark;
}
@media (prefers-color-scheme: light) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
@media (prefers-color-scheme: dark) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
</mj-style>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="#cacaca" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body> <mj-body>
<mj-section mj-class="bg2"> <mj-include path="./layout/body-start.mjml" />
<mj-section mj-class="body">
<mj-column> <mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> {{ .jellyfin }} </mj-text> <mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<h3>{{ .welcome }}</h3> <h3>{{ .welcome }}</h3>
<p>{{ .youCanLoginWith }}:</p> <p>{{ .youCanLoginWith }}:</p>
{{ .jellyfinURLString }}: <a href="{{ .jellyfinURL }}">{{ .jellyfinURL }}</a> {{ .jellyfinURLString }}: <a href="{{ .jellyfinURL }}">{{ .jellyfinURL }}</a>
@@ -68,12 +13,6 @@
</mj-text> </mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>
<mj-section mj-class="bg2"> <mj-include path="./layout/body-end.mjml" />
<mj-column> </mj-body>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
{{ .message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml> </mjml>

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