mirror of
https://github.com/hrfee/jfa-go.git
synced 2026-01-18 16:47:42 +01:00
messages: custom content described in customcontent.go, message tests
customcontent.go constains a structure with all the custom content, methods for getting display names, subjects, etc., and a list of variables, conditionals, and placeholder values. Tests for constructX methods included in email_test.go, and all jfa-go tests can be run with make INTERNAL=off test.
This commit is contained in:
5
Makefile
5
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: configuration email typescript swagger copy compile compress inline-css variants-html install clean npm config-description config-default precompile
|
||||
.PHONY: configuration email typescript swagger copy compile compress inline-css variants-html install clean npm config-description config-default precompile test
|
||||
.DEFAULT_GOAL := all
|
||||
|
||||
GOESBUILD ?= off
|
||||
@@ -224,6 +224,9 @@ $(GO_TARGET): $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
|
||||
mkdir -p build
|
||||
$(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)
|
||||
|
||||
compress:
|
||||
|
||||
@@ -135,7 +135,7 @@ func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup
|
||||
wait.Add(1)
|
||||
go func(addr string) {
|
||||
defer wait.Done()
|
||||
msg, err := app.email.constructExpiry(data.Code, data, app, false)
|
||||
msg, err := app.email.constructExpiry(data, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructExpiryAdmin, data.Code, err)
|
||||
} else {
|
||||
@@ -218,7 +218,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
|
||||
invite.SendTo = req.SendTo
|
||||
}
|
||||
if addressValid {
|
||||
msg, err := app.email.constructInvite(invite.Code, invite, app, false)
|
||||
msg, err := app.email.constructInvite(invite, false)
|
||||
if err != nil {
|
||||
// Slight misuse of the template
|
||||
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, req.SendTo, err)
|
||||
@@ -343,7 +343,7 @@ func (app *appContext) GetInvites(gc *gin.Context) {
|
||||
// These used to be stored formatted instead of as a unix timestamp.
|
||||
unix, err := strconv.ParseInt(pair[1], 10, 64)
|
||||
if err != nil {
|
||||
date, err := timefmt.Parse(pair[1], app.datePattern+" "+app.timePattern)
|
||||
date, err := timefmt.Parse(pair[1], datePattern+" "+timePattern)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedParseTime, err)
|
||||
}
|
||||
|
||||
235
api-messages.go
235
api-messages.go
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -23,26 +22,16 @@ func (app *appContext) GetCustomContent(gc *gin.Context) {
|
||||
if _, ok := app.storage.lang.Email[lang]; !ok {
|
||||
lang = app.storage.lang.chosenEmailLang
|
||||
}
|
||||
adminLang := lang
|
||||
if _, ok := app.storage.lang.Admin[lang]; !ok {
|
||||
adminLang = app.storage.lang.chosenAdminLang
|
||||
}
|
||||
list := emailListDTO{
|
||||
"UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.MustGetCustomContentKey("UserCreated").Enabled},
|
||||
"InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.MustGetCustomContentKey("InviteExpiry").Enabled},
|
||||
"PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.MustGetCustomContentKey("PasswordReset").Enabled},
|
||||
"UserDeleted": {Name: app.storage.lang.Email[lang].UserDeleted["name"], Enabled: app.storage.MustGetCustomContentKey("UserDeleted").Enabled},
|
||||
"UserDisabled": {Name: app.storage.lang.Email[lang].UserDisabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserDisabled").Enabled},
|
||||
"UserEnabled": {Name: app.storage.lang.Email[lang].UserEnabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserEnabled").Enabled},
|
||||
"UserExpiryAdjusted": {Name: app.storage.lang.Email[lang].UserExpiryAdjusted["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpiryAdjusted").Enabled},
|
||||
"InviteEmail": {Name: app.storage.lang.Email[lang].InviteEmail["name"], Enabled: app.storage.MustGetCustomContentKey("InviteEmail").Enabled},
|
||||
"WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.MustGetCustomContentKey("WelcomeEmail").Enabled},
|
||||
"EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.MustGetCustomContentKey("EmailConfirmation").Enabled},
|
||||
"UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpired").Enabled},
|
||||
"ExpiryReminder": {Name: app.storage.lang.Email[lang].ExpiryReminder["name"], Enabled: app.storage.MustGetCustomContentKey("ExpiryReminder").Enabled},
|
||||
"UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.MustGetCustomContentKey("UserLogin").Enabled},
|
||||
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("UserPage").Enabled},
|
||||
"PostSignupCard": {Name: app.storage.lang.Admin[adminLang].Strings["postSignupCard"], Enabled: app.storage.MustGetCustomContentKey("PostSignupCard").Enabled, Description: app.storage.lang.Admin[adminLang].Strings["postSignupCardDescription"]},
|
||||
list := emailListDTO{}
|
||||
for _, cc := range customContent {
|
||||
if cc.ContentType == CustomTemplate {
|
||||
continue
|
||||
}
|
||||
ccDescription := emailListEl{Name: cc.DisplayName(&app.storage.lang, lang), Enabled: app.storage.MustGetCustomContentKey(cc.Name).Enabled}
|
||||
if cc.Description != nil {
|
||||
ccDescription.Description = cc.Description(&app.storage.lang, lang)
|
||||
}
|
||||
list[cc.Name] = ccDescription
|
||||
}
|
||||
|
||||
filter := gc.Query("filter")
|
||||
@@ -74,11 +63,12 @@ func (app *appContext) SetCustomMessage(gc *gin.Context) {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
message, ok := app.storage.GetCustomContentKey(id)
|
||||
_, ok := customContent[id]
|
||||
if !ok {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
message, ok := app.storage.GetCustomContentKey(id)
|
||||
message.Content = req.Content
|
||||
message.Enabled = true
|
||||
app.storage.SetCustomContentKey(id, message)
|
||||
@@ -124,151 +114,92 @@ func (app *appContext) SetCustomMessageState(gc *gin.Context) {
|
||||
// @Security Bearer
|
||||
// @tags Configuration
|
||||
func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
|
||||
lang := app.storage.lang.chosenEmailLang
|
||||
id := gc.Param("id")
|
||||
var content string
|
||||
var err error
|
||||
var msg *Message
|
||||
var variables []string
|
||||
var conditionals []string
|
||||
var values map[string]interface{}
|
||||
username := app.storage.lang.Email[lang].Strings.get("username")
|
||||
emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress")
|
||||
customMessage, ok := app.storage.GetCustomContentKey(id)
|
||||
contentInfo, ok := customContent[id]
|
||||
// FIXME: Add announcement to customContent
|
||||
if !ok && id != "Announcement" {
|
||||
app.err.Printf(lm.FailedGetCustomMessage, id)
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
if id == "WelcomeEmail" {
|
||||
conditionals = []string{"{yourAccountWillExpire}"}
|
||||
customMessage.Conditionals = conditionals
|
||||
} else if id == "UserPage" {
|
||||
variables = []string{"{username}"}
|
||||
customMessage.Variables = variables
|
||||
} else if id == "UserLogin" {
|
||||
variables = []string{}
|
||||
customMessage.Variables = variables
|
||||
} else if id == "PostSignupCard" {
|
||||
variables = []string{"{username}", "{myAccountURL}"}
|
||||
customMessage.Variables = variables
|
||||
|
||||
content, ok := app.storage.GetCustomContentKey(id)
|
||||
|
||||
if contentInfo.Variables == nil {
|
||||
contentInfo.Variables = []string{}
|
||||
}
|
||||
if contentInfo.Conditionals == nil {
|
||||
contentInfo.Conditionals = []string{}
|
||||
}
|
||||
if contentInfo.Placeholders == nil {
|
||||
contentInfo.Placeholders = map[string]any{}
|
||||
}
|
||||
|
||||
content = customMessage.Content
|
||||
noContent := content == ""
|
||||
if !noContent {
|
||||
variables = customMessage.Variables
|
||||
// Generate content from real email, if the user hasn't already customised this message.
|
||||
if content.Content == "" {
|
||||
var msg *Message
|
||||
switch id {
|
||||
// FIXME: Add announcement to customContent
|
||||
case "UserCreated":
|
||||
msg, err = app.email.constructCreated("", "", time.Time{}, Invite{}, true)
|
||||
case "InviteExpiry":
|
||||
msg, err = app.email.constructExpiry(Invite{}, true)
|
||||
case "PasswordReset":
|
||||
msg, err = app.email.constructReset(PasswordReset{}, true)
|
||||
case "UserDeleted":
|
||||
msg, err = app.email.constructDeleted("", true)
|
||||
case "UserDisabled":
|
||||
msg, err = app.email.constructDisabled("", true)
|
||||
case "UserEnabled":
|
||||
msg, err = app.email.constructEnabled("", true)
|
||||
case "UserExpiryAdjusted":
|
||||
msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", true)
|
||||
case "ExpiryReminder":
|
||||
msg, err = app.email.constructExpiryReminder("", time.Now().AddDate(0, 0, 3), true)
|
||||
case "InviteEmail":
|
||||
msg, err = app.email.constructInvite(Invite{Code: ""}, true)
|
||||
case "WelcomeEmail":
|
||||
msg, err = app.email.constructWelcome("", time.Time{}, true)
|
||||
case "EmailConfirmation":
|
||||
msg, err = app.email.constructConfirmation("", "", "", true)
|
||||
case "UserExpired":
|
||||
msg, err = app.email.constructUserExpired(true)
|
||||
case "Announcement":
|
||||
case "UserPage":
|
||||
case "UserLogin":
|
||||
case "PostSignupCard":
|
||||
// These don't have any example content
|
||||
msg = nil
|
||||
}
|
||||
if err != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
if msg != nil {
|
||||
content.Content = msg.Text
|
||||
}
|
||||
}
|
||||
switch id {
|
||||
case "Announcement":
|
||||
// Just send the email html
|
||||
content = ""
|
||||
case "UserCreated":
|
||||
if noContent {
|
||||
msg, err = app.email.constructCreated("", "", "", Invite{}, app, true)
|
||||
}
|
||||
values = app.email.createdValues("xxxxxx", username, emailAddress, Invite{}, app, false)
|
||||
case "InviteExpiry":
|
||||
if noContent {
|
||||
msg, err = app.email.constructExpiry("", Invite{}, app, true)
|
||||
}
|
||||
values = app.email.expiryValues("xxxxxx", Invite{}, app, false)
|
||||
case "PasswordReset":
|
||||
if noContent {
|
||||
msg, err = app.email.constructReset(PasswordReset{}, app, true)
|
||||
}
|
||||
values = app.email.resetValues(PasswordReset{Pin: "12-34-56", Username: username}, app, false)
|
||||
case "UserDeleted":
|
||||
if noContent {
|
||||
msg, err = app.email.constructDeleted("", app, true)
|
||||
}
|
||||
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
|
||||
case "UserDisabled":
|
||||
if noContent {
|
||||
msg, err = app.email.constructDisabled("", app, true)
|
||||
}
|
||||
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
|
||||
case "UserEnabled":
|
||||
if noContent {
|
||||
msg, err = app.email.constructEnabled("", app, true)
|
||||
}
|
||||
values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
|
||||
case "UserExpiryAdjusted":
|
||||
if noContent {
|
||||
msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", app, true)
|
||||
}
|
||||
values = app.email.expiryAdjustedValues(username, time.Time{}, app.storage.lang.Email[lang].Strings.get("reason"), app, false, true)
|
||||
case "ExpiryReminder":
|
||||
if noContent {
|
||||
msg, err = app.email.constructExpiryReminder("", time.Now().AddDate(0, 0, 3), app, true)
|
||||
}
|
||||
values = app.email.expiryReminderValues(username, time.Now().AddDate(0, 0, 3), app, false, true)
|
||||
case "InviteEmail":
|
||||
if noContent {
|
||||
msg, err = app.email.constructInvite("", Invite{}, app, true)
|
||||
}
|
||||
values = app.email.inviteValues("xxxxxx", Invite{}, app, false)
|
||||
case "WelcomeEmail":
|
||||
if noContent {
|
||||
msg, err = app.email.constructWelcome("", time.Time{}, app, true)
|
||||
}
|
||||
values = app.email.welcomeValues(username, time.Now(), app, false, true)
|
||||
case "EmailConfirmation":
|
||||
if noContent {
|
||||
msg, err = app.email.constructConfirmation("", "", "", app, true)
|
||||
}
|
||||
values = app.email.confirmationValues("xxxxxx", username, "xxxxxx", app, false)
|
||||
case "UserExpired":
|
||||
if noContent {
|
||||
msg, err = app.email.constructUserExpired(app, true)
|
||||
}
|
||||
values = app.email.userExpiredValues(app, false)
|
||||
case "UserLogin", "UserPage", "PostSignupCard":
|
||||
values = map[string]interface{}{}
|
||||
}
|
||||
if err != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" && id != "PostSignupCard" {
|
||||
content = msg.Text
|
||||
variables = make([]string, strings.Count(content, "{"))
|
||||
i := 0
|
||||
found := false
|
||||
buf := ""
|
||||
for _, c := range content {
|
||||
if !found && c != '{' && c != '}' {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
buf += string(c)
|
||||
if c == '}' {
|
||||
found = false
|
||||
variables[i] = buf
|
||||
buf = ""
|
||||
i++
|
||||
}
|
||||
}
|
||||
customMessage.Variables = variables
|
||||
}
|
||||
if variables == nil {
|
||||
variables = []string{}
|
||||
}
|
||||
app.storage.SetCustomContentKey(id, customMessage)
|
||||
|
||||
var mail *Message
|
||||
if id != "UserLogin" && id != "UserPage" && id != "PostSignupCard" {
|
||||
mail, err = app.email.constructTemplate("", "<div class=\"preview-content\"></div>", app)
|
||||
if contentInfo.ContentType == CustomMessage {
|
||||
mail = &Message{}
|
||||
err = app.email.construct(EmptyCustomContent, CustomContent{
|
||||
Name: EmptyCustomContent.Name,
|
||||
Enabled: true,
|
||||
Content: "<div class=\"preview-content\"></div>",
|
||||
}, map[string]any{}, mail)
|
||||
if err != nil {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
} else if id == "PostSignupCard" {
|
||||
// Jankiness follows.
|
||||
// Specific workaround for the currently-unique "Post signup card".
|
||||
// Source content from "Success Message" setting.
|
||||
if noContent {
|
||||
content = "# " + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("successHeader") + "\n" + app.config.Section("ui").Key("success_message").String()
|
||||
if content.Content == "" {
|
||||
content.Content = "# " + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("successHeader") + "\n" + app.config.Section("ui").Key("success_message").String()
|
||||
if app.config.Section("user_page").Key("enabled").MustBool(false) {
|
||||
content += "\n\n<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})",
|
||||
})
|
||||
}
|
||||
@@ -277,13 +208,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>",
|
||||
}
|
||||
mail.Markdown = mail.HTML
|
||||
} else {
|
||||
} else if contentInfo.ContentType == CustomCard {
|
||||
mail = &Message{
|
||||
HTML: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
|
||||
}
|
||||
mail.Markdown = mail.HTML
|
||||
} else {
|
||||
app.err.Printf("unknown custom content type %d", contentInfo.ContentType)
|
||||
}
|
||||
gc.JSON(200, customEmailDTO{Content: content, 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.
|
||||
|
||||
@@ -264,7 +264,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
|
||||
}
|
||||
app.debug.Printf(lm.EmailConfirmationRequired, id)
|
||||
respond(401, "confirmEmail", gc)
|
||||
msg, err := app.email.constructConfirmation("", name, key, app, false)
|
||||
msg, err := app.email.constructConfirmation("", name, key, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructConfirmationEmail, id, err)
|
||||
} else if err := app.email.send(msg, req.Email); err != nil {
|
||||
@@ -643,7 +643,7 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
|
||||
Username: pwr.Username,
|
||||
Expiry: pwr.Expiry,
|
||||
Internal: true,
|
||||
}, app, false,
|
||||
}, false,
|
||||
)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
|
||||
|
||||
30
api-users.go
30
api-users.go
@@ -189,7 +189,7 @@ func (app *appContext) NewUserFromInvite(gc *gin.Context) {
|
||||
|
||||
app.debug.Printf(lm.EmailConfirmationRequired, req.Username)
|
||||
respond(401, "confirmEmail", gc)
|
||||
msg, err := app.email.constructConfirmation(req.Code, req.Username, key, app, false)
|
||||
msg, err := app.email.constructConfirmation(req.Code, req.Username, key, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructConfirmationEmail, req.Code, err)
|
||||
} else if err := app.email.send(msg, req.Email); err != nil {
|
||||
@@ -262,7 +262,7 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
|
||||
}
|
||||
app.contactMethods[i].DeleteVerifiedToken(c.PIN)
|
||||
c.User.SetJellyfin(nu.User.ID)
|
||||
c.User.Store(&(app.storage))
|
||||
c.User.Store(app.storage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
|
||||
continue
|
||||
}
|
||||
go func(addr string) {
|
||||
msg, err := app.email.constructCreated(req.Code, req.Username, req.Email, invite, app, false)
|
||||
msg, err := app.email.constructCreated(req.Username, req.Email, time.Now(), invite, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructCreationAdmin, req.Code, err)
|
||||
} else {
|
||||
@@ -384,9 +384,9 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
|
||||
var err error
|
||||
if sendMail {
|
||||
if req.Enabled {
|
||||
msg, err = app.email.constructEnabled(req.Reason, app, false)
|
||||
msg, err = app.email.constructEnabled(req.Reason, false)
|
||||
} else {
|
||||
msg, err = app.email.constructDisabled(req.Reason, app, false)
|
||||
msg, err = app.email.constructDisabled(req.Reason, false)
|
||||
}
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructEnableDisableMessage, "?", err)
|
||||
@@ -452,7 +452,7 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
|
||||
var msg *Message
|
||||
var err error
|
||||
if sendMail {
|
||||
msg, err = app.email.constructDeleted(req.Reason, app, false)
|
||||
msg, err = app.email.constructDeleted(req.Reason, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructDeletionMessage, "?", err)
|
||||
sendMail = false
|
||||
@@ -541,7 +541,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
msg, err := app.email.constructExpiryAdjusted(user.Name, exp, req.Reason, app, false)
|
||||
msg, err := app.email.constructExpiryAdjusted(user.Name, exp, req.Reason, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructExpiryAdjustmentMessage, uid, err)
|
||||
return
|
||||
@@ -677,7 +677,11 @@ func (app *appContext) Announce(gc *gin.Context) {
|
||||
app.err.Printf(lm.FailedGetUser, userID, lm.Jellyfin, err)
|
||||
continue
|
||||
}
|
||||
msg, err := app.email.constructTemplate(req.Subject, req.Message, app, user.Name)
|
||||
msg := &Message{}
|
||||
err = app.email.construct(AnnouncementCustomContent(req.Subject), CustomContent{
|
||||
Enabled: true,
|
||||
Content: req.Message,
|
||||
}, map[string]any{"username": user.Name}, msg)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructAnnouncementMessage, userID, err)
|
||||
respondBool(500, false, gc)
|
||||
@@ -690,7 +694,11 @@ func (app *appContext) Announce(gc *gin.Context) {
|
||||
}
|
||||
// app.info.Printf(lm.SentAnnouncementMessage, "*", "?")
|
||||
} else {
|
||||
msg, err := app.email.constructTemplate(req.Subject, req.Message, app)
|
||||
msg := &Message{}
|
||||
err := app.email.construct(AnnouncementCustomContent(req.Subject), CustomContent{
|
||||
Enabled: true,
|
||||
Content: req.Message,
|
||||
}, map[string]any{"username": ""}, msg)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructAnnouncementMessage, "*", err)
|
||||
respondBool(500, false, gc)
|
||||
@@ -810,7 +818,7 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) {
|
||||
app.internalPWRs[pwr.PIN] = pwr
|
||||
sendAddress := app.getAddressOrName(id)
|
||||
if sendAddress == "" || len(req.Users) == 1 {
|
||||
resp.Link, err = app.GenResetLink(pwr.PIN)
|
||||
resp.Link, err = GenResetLink(pwr.PIN)
|
||||
linkCount++
|
||||
if sendAddress == "" {
|
||||
resp.Manual = true
|
||||
@@ -823,7 +831,7 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) {
|
||||
Username: pwr.Username,
|
||||
Expiry: pwr.Expiry,
|
||||
Internal: true,
|
||||
}, app, false,
|
||||
}, false,
|
||||
)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructPWRMessage, id, err)
|
||||
|
||||
21
api.go
21
api.go
@@ -36,23 +36,14 @@ func respondBool(code int, val bool, gc *gin.Context) {
|
||||
gc.Abort()
|
||||
}
|
||||
|
||||
func (app *appContext) loadStrftime() {
|
||||
app.datePattern = app.config.Section("messages").Key("date_format").String()
|
||||
app.timePattern = `%H:%M`
|
||||
if val, _ := app.config.Section("messages").Key("use_24h").Bool(); !val {
|
||||
app.timePattern = `%I:%M %p`
|
||||
}
|
||||
func prettyTime(dt time.Time) (date, time string) {
|
||||
date = timefmt.Format(dt, datePattern)
|
||||
time = timefmt.Format(dt, timePattern)
|
||||
return
|
||||
}
|
||||
|
||||
func (app *appContext) prettyTime(dt time.Time) (date, time string) {
|
||||
date = timefmt.Format(dt, app.datePattern)
|
||||
time = timefmt.Format(dt, app.timePattern)
|
||||
return
|
||||
}
|
||||
|
||||
func (app *appContext) formatDatetime(dt time.Time) string {
|
||||
d, t := app.prettyTime(dt)
|
||||
func formatDatetime(dt time.Time) string {
|
||||
d, t := prettyTime(dt)
|
||||
return d + " " + t
|
||||
}
|
||||
|
||||
@@ -310,7 +301,7 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
|
||||
if req["restart-program"] != nil && req["restart-program"].(bool) {
|
||||
app.Restart()
|
||||
}
|
||||
app.loadConfig()
|
||||
app.ReloadConfig()
|
||||
// Patch new settings for next GetConfig
|
||||
app.PatchConfigBase()
|
||||
// Reinitialize password validator on config change, as opposed to every applicable request like in python.
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
const (
|
||||
BACKUP_PREFIX = "jfa-go-db"
|
||||
BACKUP_PREFIX_OLD = "jfa-go-db-"
|
||||
BACKUP_COMMIT_PREFIX = "-c-"
|
||||
BACKUP_DATE_PREFIX = "-d-"
|
||||
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
|
||||
}
|
||||
|
||||
// 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"
|
||||
|
||||
func (b Backup) String() string {
|
||||
@@ -274,8 +275,10 @@ func (app *appContext) loadPendingBackup() {
|
||||
}
|
||||
app.info.Printf(lm.MoveOldDB, oldPath)
|
||||
|
||||
app.ConnectDB()
|
||||
defer app.storage.db.Close()
|
||||
if err := app.storage.Connect(app.config); err != nil {
|
||||
app.err.Fatalf(lm.FailedConnectDB, app.storage.db_path, err)
|
||||
}
|
||||
defer app.storage.Close()
|
||||
|
||||
f, err := os.Open(LOADBAK)
|
||||
if err != nil {
|
||||
|
||||
@@ -17,13 +17,13 @@ func testBackupParse(f string, a Backup, 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.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
|
||||
testBackupParse(Q1, A1, t)
|
||||
}
|
||||
func TestBackupParserOldUpload(t *testing.T) {
|
||||
Q2 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX
|
||||
Q2 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX_OLD + "2023-12-21T21-08-00" + BACKUP_SUFFIX
|
||||
A2 := Backup{
|
||||
Upload: true,
|
||||
}
|
||||
|
||||
370
config.go
370
config.go
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -18,6 +19,12 @@ import (
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
*ini.File
|
||||
proxyTransport *http.Transport
|
||||
proxyConfig *easyproxy.ProxyConfig
|
||||
}
|
||||
|
||||
var emailEnabled = false
|
||||
var messagesEnabled = false
|
||||
var telegramEnabled = false
|
||||
@@ -28,8 +35,8 @@ var matrixEnabled = false
|
||||
// IMPORTANT: When linking straight to a page, rather than appending further to the URL (like accessing an API route), append a /.
|
||||
var PAGES = PagePaths{}
|
||||
|
||||
func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
|
||||
val := app.config.Section(sect).Key(key).MustString("")
|
||||
func (config *Config) GetPath(sect, key string) (fs.FS, string) {
|
||||
val := config.Section(sect).Key(key).MustString("")
|
||||
if strings.HasPrefix(val, "jfa-go:") {
|
||||
return localFS, strings.TrimPrefix(val, "jfa-go:")
|
||||
}
|
||||
@@ -37,15 +44,15 @@ func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
|
||||
return os.DirFS(dir), file
|
||||
}
|
||||
|
||||
func (app *appContext) MustSetValue(section, key, val string) {
|
||||
app.config.Section(section).Key(key).SetValue(app.config.Section(section).Key(key).MustString(val))
|
||||
func (config *Config) MustSetValue(section, key, val string) {
|
||||
config.Section(section).Key(key).SetValue(config.Section(section).Key(key).MustString(val))
|
||||
}
|
||||
|
||||
func (app *appContext) MustSetURLPath(section, key, val string) {
|
||||
func (config *Config) MustSetURLPath(section, key, val string) {
|
||||
if !strings.HasPrefix(val, "/") && val != "" {
|
||||
val = "/" + val
|
||||
}
|
||||
app.MustSetValue(section, key, val)
|
||||
config.MustSetValue(section, key, val)
|
||||
}
|
||||
|
||||
func FixFullURL(v string) string {
|
||||
@@ -69,26 +76,26 @@ func FormatSubpath(path string, removeSingleSlash bool) string {
|
||||
return strings.TrimSuffix(path, "/")
|
||||
}
|
||||
|
||||
func (app *appContext) MustCorrectURL(section, key, value string) {
|
||||
v := app.config.Section(section).Key(key).String()
|
||||
func (config *Config) MustCorrectURL(section, key, value string) {
|
||||
v := config.Section(section).Key(key).String()
|
||||
if v == "" {
|
||||
v = value
|
||||
}
|
||||
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.
|
||||
func (app *appContext) ExternalDomain(gc *gin.Context) string {
|
||||
if !app.UseProxyHost || gc.Request.Host == "" {
|
||||
return app.externalDomain
|
||||
// ExternalDomain returns the Host for the request, using the fixed externalDomain value unless UseProxyHost is true.
|
||||
func ExternalDomain(gc *gin.Context) string {
|
||||
if !UseProxyHost || gc.Request.Host == "" {
|
||||
return externalDomain
|
||||
}
|
||||
return gc.Request.Host
|
||||
}
|
||||
|
||||
// ExternalDomainNoPort attempts to return 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 {
|
||||
domain := app.ExternalDomain(gc)
|
||||
domain := ExternalDomain(gc)
|
||||
host, _, err := net.SplitHostPort(domain)
|
||||
if err != nil {
|
||||
return domain
|
||||
@@ -96,11 +103,11 @@ func (app *appContext) ExternalDomainNoPort(gc *gin.Context) string {
|
||||
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.
|
||||
// When nil is passed, app.externalURI is returned.
|
||||
func (app *appContext) ExternalURI(gc *gin.Context) string {
|
||||
// ExternalURI returns the External URI of jfa-go's root directory (by default, where the admin page is), using the fixed externalURI value unless UseProxyHost is true and gc is not nil.
|
||||
// When nil is passed, externalURI is returned.
|
||||
func ExternalURI(gc *gin.Context) string {
|
||||
if gc == nil {
|
||||
return app.externalURI
|
||||
return externalURI
|
||||
}
|
||||
|
||||
var proto string
|
||||
@@ -111,10 +118,10 @@ func (app *appContext) ExternalURI(gc *gin.Context) string {
|
||||
}
|
||||
|
||||
// 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 app.externalURI
|
||||
return externalURI
|
||||
}
|
||||
|
||||
func (app *appContext) EvaluateRelativePath(gc *gin.Context, path string) string {
|
||||
@@ -129,177 +136,192 @@ func (app *appContext) EvaluateRelativePath(gc *gin.Context, path string) string
|
||||
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
|
||||
app.config, err = ini.ShadowLoad(app.configPath)
|
||||
config := &Config{}
|
||||
config.File, err = ini.ShadowLoad(configPathOrContents)
|
||||
if err != nil {
|
||||
return err
|
||||
return config, err
|
||||
}
|
||||
|
||||
// URLs
|
||||
app.MustSetURLPath("ui", "url_base", "")
|
||||
app.MustSetURLPath("url_paths", "admin", "")
|
||||
app.MustSetURLPath("url_paths", "user_page", "/my/account")
|
||||
app.MustSetURLPath("url_paths", "form", "/invite")
|
||||
PAGES.Base = FormatSubpath(app.config.Section("ui").Key("url_base").String(), true)
|
||||
PAGES.Admin = FormatSubpath(app.config.Section("url_paths").Key("admin").String(), true)
|
||||
PAGES.MyAccount = FormatSubpath(app.config.Section("url_paths").Key("user_page").String(), true)
|
||||
PAGES.Form = FormatSubpath(app.config.Section("url_paths").Key("form").String(), true)
|
||||
if !(app.config.Section("user_page").Key("enabled").MustBool(true)) {
|
||||
config.MustSetURLPath("ui", "url_base", "")
|
||||
config.MustSetURLPath("url_paths", "admin", "")
|
||||
config.MustSetURLPath("url_paths", "user_page", "/my/account")
|
||||
config.MustSetURLPath("url_paths", "form", "/invite")
|
||||
PAGES.Base = FormatSubpath(config.Section("ui").Key("url_base").String(), true)
|
||||
PAGES.Admin = FormatSubpath(config.Section("url_paths").Key("admin").String(), true)
|
||||
PAGES.MyAccount = FormatSubpath(config.Section("url_paths").Key("user_page").String(), true)
|
||||
PAGES.Form = FormatSubpath(config.Section("url_paths").Key("form").String(), true)
|
||||
if !(config.Section("user_page").Key("enabled").MustBool(true)) {
|
||||
PAGES.MyAccount = "disabled"
|
||||
}
|
||||
if PAGES.Base == PAGES.Form || PAGES.Base == "/accounts" || PAGES.Base == "/settings" || PAGES.Base == "/activity" {
|
||||
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", "")
|
||||
app.MustCorrectURL("jellyfin", "public_server", app.config.Section("jellyfin").Key("server").String())
|
||||
app.MustCorrectURL("ui", "redirect_url", app.config.Section("jellyfin").Key("public_server").String())
|
||||
config.MustCorrectURL("jellyfin", "server", "")
|
||||
config.MustCorrectURL("jellyfin", "public_server", config.Section("jellyfin").Key("server").String())
|
||||
config.MustCorrectURL("ui", "redirect_url", config.Section("jellyfin").Key("public_server").String())
|
||||
|
||||
for _, key := range app.config.Section("files").Keys() {
|
||||
for _, key := range config.Section("files").Keys() {
|
||||
if name := key.Name(); name != "html_templates" && name != "lang_files" {
|
||||
key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json"))))
|
||||
key.SetValue(key.MustString(filepath.Join(dataPath, (key.Name() + ".json"))))
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users", "announcements", "custom_user_page_content"} {
|
||||
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json"))))
|
||||
config.Section("files").Key(key).SetValue(config.Section("files").Key(key).MustString(filepath.Join(dataPath, (key + ".json"))))
|
||||
}
|
||||
for _, key := range []string{"matrix_sql"} {
|
||||
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".db"))))
|
||||
config.Section("files").Key(key).SetValue(config.Section("files").Key(key).MustString(filepath.Join(dataPath, (key + ".db"))))
|
||||
}
|
||||
|
||||
// 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.
|
||||
app.UseProxyHost = app.config.Section("ui").Key("use_proxy_host").MustBool(false)
|
||||
app.externalURI = strings.TrimSuffix(strings.TrimSuffix(app.config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/")
|
||||
if !strings.HasSuffix(app.externalURI, PAGES.Base) {
|
||||
app.err.Println(lm.NoURLSuffix)
|
||||
// If true, ExternalDomain() will return one based on the reported Host (ideally reported in "Host" or "X-Forwarded-Host" by the reverse proxy), falling back to externalDomain if not set.
|
||||
UseProxyHost = config.Section("ui").Key("use_proxy_host").MustBool(false)
|
||||
externalURI = strings.TrimSuffix(strings.TrimSuffix(config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/")
|
||||
if !strings.HasSuffix(externalURI, PAGES.Base) {
|
||||
logs.err.Println(lm.NoURLSuffix)
|
||||
}
|
||||
if app.externalURI == "" {
|
||||
if app.UseProxyHost {
|
||||
app.err.Println(lm.NoExternalHost + lm.LoginWontSave + lm.SetExternalHostDespiteUseProxyHost)
|
||||
if externalURI == "" {
|
||||
if UseProxyHost {
|
||||
logs.err.Println(lm.NoExternalHost + lm.LoginWontSave + lm.SetExternalHostDespiteUseProxyHost)
|
||||
} 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 {
|
||||
app.externalDomain = u.Hostname()
|
||||
externalDomain = u.Hostname()
|
||||
}
|
||||
|
||||
app.config.Section("email").Key("no_username").SetValue(strconv.FormatBool(app.config.Section("email").Key("no_username").MustBool(false)))
|
||||
config.Section("email").Key("no_username").SetValue(strconv.FormatBool(config.Section("email").Key("no_username").MustBool(false)))
|
||||
|
||||
app.MustSetValue("password_resets", "email_html", "jfa-go:"+"email.html")
|
||||
app.MustSetValue("password_resets", "email_text", "jfa-go:"+"email.txt")
|
||||
// FIXME: Remove all these, eventually
|
||||
// config.MustSetValue("password_resets", "email_html", "jfa-go:"+"email.html")
|
||||
// config.MustSetValue("password_resets", "email_text", "jfa-go:"+"email.txt")
|
||||
|
||||
app.MustSetValue("invite_emails", "email_html", "jfa-go:"+"invite-email.html")
|
||||
app.MustSetValue("invite_emails", "email_text", "jfa-go:"+"invite-email.txt")
|
||||
// config.MustSetValue("invite_emails", "email_html", "jfa-go:"+"invite-email.html")
|
||||
// config.MustSetValue("invite_emails", "email_text", "jfa-go:"+"invite-email.txt")
|
||||
|
||||
app.MustSetValue("email_confirmation", "email_html", "jfa-go:"+"confirmation.html")
|
||||
app.MustSetValue("email_confirmation", "email_text", "jfa-go:"+"confirmation.txt")
|
||||
// config.MustSetValue("email_confirmation", "email_html", "jfa-go:"+"confirmation.html")
|
||||
// config.MustSetValue("email_confirmation", "email_text", "jfa-go:"+"confirmation.txt")
|
||||
|
||||
app.MustSetValue("notifications", "expiry_html", "jfa-go:"+"expired.html")
|
||||
app.MustSetValue("notifications", "expiry_text", "jfa-go:"+"expired.txt")
|
||||
// config.MustSetValue("notifications", "expiry_html", "jfa-go:"+"expired.html")
|
||||
// config.MustSetValue("notifications", "expiry_text", "jfa-go:"+"expired.txt")
|
||||
|
||||
app.MustSetValue("notifications", "created_html", "jfa-go:"+"created.html")
|
||||
app.MustSetValue("notifications", "created_text", "jfa-go:"+"created.txt")
|
||||
// config.MustSetValue("notifications", "created_html", "jfa-go:"+"created.html")
|
||||
// config.MustSetValue("notifications", "created_text", "jfa-go:"+"created.txt")
|
||||
|
||||
app.MustSetValue("deletion", "email_html", "jfa-go:"+"deleted.html")
|
||||
app.MustSetValue("deletion", "email_text", "jfa-go:"+"deleted.txt")
|
||||
|
||||
app.MustSetValue("smtp", "hello_hostname", "localhost")
|
||||
app.MustSetValue("smtp", "cert_validation", "true")
|
||||
app.MustSetValue("smtp", "auth_type", "4")
|
||||
app.MustSetValue("smtp", "port", "465")
|
||||
|
||||
app.MustSetValue("activity_log", "keep_n_records", "1000")
|
||||
app.MustSetValue("activity_log", "delete_after_days", "90")
|
||||
|
||||
sc := app.config.Section("discord").Key("start_command").MustString("start")
|
||||
app.config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
|
||||
// config.MustSetValue("deletion", "email_html", "jfa-go:"+"deleted.html")
|
||||
// config.MustSetValue("deletion", "email_text", "jfa-go:"+"deleted.txt")
|
||||
|
||||
// Deletion template is good enough for these as well.
|
||||
app.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html")
|
||||
app.MustSetValue("disable_enable", "disabled_text", "jfa-go:"+"deleted.txt")
|
||||
app.MustSetValue("disable_enable", "enabled_html", "jfa-go:"+"deleted.html")
|
||||
app.MustSetValue("disable_enable", "enabled_text", "jfa-go:"+"deleted.txt")
|
||||
// config.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html")
|
||||
// config.MustSetValue("disable_enable", "disabled_text", "jfa-go:"+"deleted.txt")
|
||||
// config.MustSetValue("disable_enable", "enabled_html", "jfa-go:"+"deleted.html")
|
||||
// config.MustSetValue("disable_enable", "enabled_text", "jfa-go:"+"deleted.txt")
|
||||
|
||||
app.MustSetValue("welcome_email", "email_html", "jfa-go:"+"welcome.html")
|
||||
app.MustSetValue("welcome_email", "email_text", "jfa-go:"+"welcome.txt")
|
||||
// config.MustSetValue("welcome_email", "email_html", "jfa-go:"+"welcome.html")
|
||||
// config.MustSetValue("welcome_email", "email_text", "jfa-go:"+"welcome.txt")
|
||||
|
||||
app.MustSetValue("template_email", "email_html", "jfa-go:"+"template.html")
|
||||
app.MustSetValue("template_email", "email_text", "jfa-go:"+"template.txt")
|
||||
// config.MustSetValue("template_email", "email_html", "jfa-go:"+"template.html")
|
||||
// config.MustSetValue("template_email", "email_text", "jfa-go:"+"template.txt")
|
||||
|
||||
app.MustSetValue("user_expiry", "behaviour", "disable_user")
|
||||
app.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html")
|
||||
app.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt")
|
||||
config.MustSetValue("user_expiry", "behaviour", "disable_user")
|
||||
// config.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html")
|
||||
// config.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt")
|
||||
|
||||
app.MustSetValue("user_expiry", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html")
|
||||
app.MustSetValue("user_expiry", "adjustment_email_text", "jfa-go:"+"expiry-adjusted.txt")
|
||||
// config.MustSetValue("user_expiry", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html")
|
||||
// config.MustSetValue("user_expiry", "adjustment_email_text", "jfa-go:"+"expiry-adjusted.txt")
|
||||
|
||||
app.MustSetValue("user_expiry", "reminder_email_html", "jfa-go:"+"expiry-reminder.html")
|
||||
app.MustSetValue("user_expiry", "reminder_email_text", "jfa-go:"+"expiry-reminder.txt")
|
||||
// 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("email", "collect", "true")
|
||||
fnameSettingSuffix := []string{"html", "text"}
|
||||
fnameExtension := []string{"html", "txt"}
|
||||
|
||||
app.MustSetValue("matrix", "topic", "Jellyfin notifications")
|
||||
app.MustSetValue("matrix", "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("discord", "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("telegram", "show_on_reg", "true")
|
||||
config.MustSetValue("activity_log", "keep_n_records", "1000")
|
||||
config.MustSetValue("activity_log", "delete_after_days", "90")
|
||||
|
||||
app.MustSetValue("backups", "every_n_minutes", "1440")
|
||||
app.MustSetValue("backups", "path", filepath.Join(app.dataPath, "backups"))
|
||||
app.MustSetValue("backups", "keep_n_backups", "20")
|
||||
app.MustSetValue("backups", "keep_previous_version_backup", "true")
|
||||
sc := config.Section("discord").Key("start_command").MustString("start")
|
||||
config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
|
||||
|
||||
app.config.Section("jellyfin").Key("version").SetValue(version)
|
||||
app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
|
||||
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
|
||||
config.MustSetValue("email", "collect", "true")
|
||||
|
||||
app.MustSetValue("jellyfin", "cache_timeout", "30")
|
||||
app.MustSetValue("jellyfin", "web_cache_async_timeout", "1")
|
||||
app.MustSetValue("jellyfin", "web_cache_sync_timeout", "10")
|
||||
config.MustSetValue("matrix", "topic", "Jellyfin notifications")
|
||||
config.MustSetValue("matrix", "show_on_reg", "true")
|
||||
|
||||
LOGIP = app.config.Section("advanced").Key("log_ips").MustBool(false)
|
||||
LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false)
|
||||
config.MustSetValue("discord", "show_on_reg", "true")
|
||||
|
||||
app.MustSetValue("advanced", "auth_retry_count", "6")
|
||||
app.MustSetValue("advanced", "auth_retry_gap", "10")
|
||||
config.MustSetValue("telegram", "show_on_reg", "true")
|
||||
|
||||
app.MustSetValue("ui", "port", "8056")
|
||||
app.MustSetValue("advanced", "tls_port", "8057")
|
||||
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")
|
||||
|
||||
app.MustSetValue("advanced", "value_log_size", "512")
|
||||
config.Section("jellyfin").Key("version").SetValue(version)
|
||||
config.Section("jellyfin").Key("device").SetValue("jfa-go")
|
||||
config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
|
||||
|
||||
config.MustSetValue("jellyfin", "cache_timeout", "30")
|
||||
config.MustSetValue("jellyfin", "web_cache_async_timeout", "1")
|
||||
config.MustSetValue("jellyfin", "web_cache_sync_timeout", "10")
|
||||
|
||||
LOGIP = config.Section("advanced").Key("log_ips").MustBool(false)
|
||||
LOGIPU = config.Section("advanced").Key("log_ips_users").MustBool(false)
|
||||
|
||||
config.MustSetValue("advanced", "auth_retry_count", "6")
|
||||
config.MustSetValue("advanced", "auth_retry_gap", "10")
|
||||
|
||||
config.MustSetValue("ui", "port", "8056")
|
||||
config.MustSetValue("advanced", "tls_port", "8057")
|
||||
|
||||
config.MustSetValue("advanced", "value_log_size", "512")
|
||||
|
||||
pwrMethods := []string{"allow_pwr_username", "allow_pwr_email", "allow_pwr_contact_method"}
|
||||
allDisabled := true
|
||||
for _, v := range pwrMethods {
|
||||
if app.config.Section("user_page").Key(v).MustBool(true) {
|
||||
if config.Section("user_page").Key(v).MustBool(true) {
|
||||
allDisabled = false
|
||||
}
|
||||
}
|
||||
if allDisabled {
|
||||
app.info.Println(lm.EnableAllPWRMethods)
|
||||
logs.info.Println(lm.EnableAllPWRMethods)
|
||||
for _, v := range pwrMethods {
|
||||
app.config.Section("user_page").Key(v).SetValue("true")
|
||||
config.Section("user_page").Key(v).SetValue("true")
|
||||
}
|
||||
}
|
||||
|
||||
messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false)
|
||||
telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false)
|
||||
discordEnabled = app.config.Section("discord").Key("enabled").MustBool(false)
|
||||
matrixEnabled = app.config.Section("matrix").Key("enabled").MustBool(false)
|
||||
messagesEnabled = config.Section("messages").Key("enabled").MustBool(false)
|
||||
telegramEnabled = config.Section("telegram").Key("enabled").MustBool(false)
|
||||
discordEnabled = config.Section("discord").Key("enabled").MustBool(false)
|
||||
matrixEnabled = config.Section("matrix").Key("enabled").MustBool(false)
|
||||
if !messagesEnabled {
|
||||
emailEnabled = false
|
||||
telegramEnabled = false
|
||||
discordEnabled = false
|
||||
matrixEnabled = false
|
||||
} else if app.config.Section("email").Key("method").MustString("") == "" {
|
||||
} else if config.Section("email").Key("method").MustString("") == "" {
|
||||
emailEnabled = false
|
||||
} else {
|
||||
emailEnabled = true
|
||||
@@ -308,31 +330,64 @@ func (app *appContext) loadConfig() error {
|
||||
messagesEnabled = false
|
||||
}
|
||||
|
||||
if app.proxyEnabled = app.config.Section("advanced").Key("proxy").MustBool(false); app.proxyEnabled {
|
||||
app.proxyConfig = easyproxy.ProxyConfig{}
|
||||
app.proxyConfig.Protocol = easyproxy.HTTP
|
||||
if strings.Contains(app.config.Section("advanced").Key("proxy_protocol").MustString("http"), "socks") {
|
||||
app.proxyConfig.Protocol = easyproxy.SOCKS5
|
||||
if proxyEnabled := config.Section("advanced").Key("proxy").MustBool(false); proxyEnabled {
|
||||
config.proxyConfig = &easyproxy.ProxyConfig{}
|
||||
config.proxyConfig.Protocol = easyproxy.HTTP
|
||||
if strings.Contains(config.Section("advanced").Key("proxy_protocol").MustString("http"), "socks") {
|
||||
config.proxyConfig.Protocol = easyproxy.SOCKS5
|
||||
}
|
||||
app.proxyConfig.Addr = app.config.Section("advanced").Key("proxy_address").MustString("")
|
||||
app.proxyConfig.User = app.config.Section("advanced").Key("proxy_user").MustString("")
|
||||
app.proxyConfig.Password = app.config.Section("advanced").Key("proxy_password").MustString("")
|
||||
app.proxyTransport, err = easyproxy.NewTransport(app.proxyConfig)
|
||||
config.proxyConfig.Addr = config.Section("advanced").Key("proxy_address").MustString("")
|
||||
config.proxyConfig.User = config.Section("advanced").Key("proxy_user").MustString("")
|
||||
config.proxyConfig.Password = config.Section("advanced").Key("proxy_password").MustString("")
|
||||
config.proxyTransport, err = easyproxy.NewTransport(*(config.proxyConfig))
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedInitProxy, app.proxyConfig.Addr, err)
|
||||
logs.err.Printf(lm.FailedInitProxy, config.proxyConfig.Addr, err)
|
||||
// As explained in lm.FailedInitProxy, sleep here might grab the admin's attention,
|
||||
// Since we don't crash on this failing.
|
||||
time.Sleep(15 * time.Second)
|
||||
app.proxyEnabled = false
|
||||
config.proxyConfig = nil
|
||||
config.proxyTransport = nil
|
||||
} else {
|
||||
app.proxyEnabled = true
|
||||
app.info.Printf(lm.InitProxy, app.proxyConfig.Addr)
|
||||
logs.info.Printf(lm.InitProxy, config.proxyConfig.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
app.MustSetValue("updates", "enabled", "true")
|
||||
releaseChannel := app.config.Section("updates").Key("channel").String()
|
||||
if app.config.Section("updates").Key("enabled").MustBool(false) {
|
||||
config.MustSetValue("updates", "enabled", "true")
|
||||
|
||||
substituteStrings = config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("")
|
||||
|
||||
if substituteStrings != "" {
|
||||
v := config.Section("ui").Key("success_message")
|
||||
v.SetValue(strings.ReplaceAll(v.String(), "Jellyfin", substituteStrings))
|
||||
}
|
||||
|
||||
datePattern = config.Section("messages").Key("date_format").String()
|
||||
timePattern = `%H:%M`
|
||||
if !(config.Section("messages").Key("use_24h").MustBool(true)) {
|
||||
timePattern = `%I:%M %p`
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// ReloadDependents re-initialises or applies changes to components of the app which can be reconfigured without restarting.
|
||||
func (config *Config) ReloadDependents(app *appContext) {
|
||||
oldFormLang := config.Section("ui").Key("language").MustString("")
|
||||
if oldFormLang != "" {
|
||||
app.storage.lang.chosenUserLang = oldFormLang
|
||||
}
|
||||
newFormLang := config.Section("ui").Key("language-form").MustString("")
|
||||
if newFormLang != "" {
|
||||
app.storage.lang.chosenUserLang = newFormLang
|
||||
}
|
||||
|
||||
app.storage.lang.chosenAdminLang = config.Section("ui").Key("language-admin").MustString("en-us")
|
||||
app.storage.lang.chosenEmailLang = config.Section("email").Key("language").MustString("en-us")
|
||||
app.storage.lang.chosenPWRLang = config.Section("password_resets").Key("language").MustString("en-us")
|
||||
app.storage.lang.chosenTelegramLang = config.Section("telegram").Key("language").MustString("en-us")
|
||||
|
||||
releaseChannel := config.Section("updates").Key("channel").String()
|
||||
if config.Section("updates").Key("enabled").MustBool(false) {
|
||||
v := version
|
||||
if releaseChannel == "stable" {
|
||||
if version == "git" {
|
||||
@@ -341,9 +396,9 @@ func (app *appContext) loadConfig() error {
|
||||
} else if releaseChannel == "unstable" {
|
||||
v = "git"
|
||||
}
|
||||
app.updater = newUpdater(baseURL, namespace, repo, v, commit, updater)
|
||||
if app.proxyEnabled {
|
||||
app.updater.SetTransport(app.proxyTransport)
|
||||
app.updater = NewUpdater(baseURL, namespace, repo, v, commit, updater)
|
||||
if config.proxyTransport != nil {
|
||||
app.updater.SetTransport(config.proxyTransport)
|
||||
}
|
||||
}
|
||||
if releaseChannel == "" {
|
||||
@@ -352,32 +407,21 @@ func (app *appContext) loadConfig() error {
|
||||
} else {
|
||||
releaseChannel = "stable"
|
||||
}
|
||||
app.MustSetValue("updates", "channel", releaseChannel)
|
||||
config.MustSetValue("updates", "channel", releaseChannel)
|
||||
}
|
||||
|
||||
substituteStrings = app.config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("")
|
||||
app.email = NewEmailer(config, app.storage, app.LoggerSet)
|
||||
}
|
||||
|
||||
if substituteStrings != "" {
|
||||
v := app.config.Section("ui").Key("success_message")
|
||||
v.SetValue(strings.ReplaceAll(v.String(), "Jellyfin", substituteStrings))
|
||||
func (app *appContext) ReloadConfig() {
|
||||
var err error = nil
|
||||
app.config, err = NewConfig(app.configPath, app.dataPath, app.LoggerSet)
|
||||
if err != nil {
|
||||
app.err.Fatalf(lm.FailedLoadConfig, app.configPath, err)
|
||||
}
|
||||
|
||||
oldFormLang := app.config.Section("ui").Key("language").MustString("")
|
||||
if oldFormLang != "" {
|
||||
app.storage.lang.chosenUserLang = oldFormLang
|
||||
}
|
||||
newFormLang := app.config.Section("ui").Key("language-form").MustString("")
|
||||
if newFormLang != "" {
|
||||
app.storage.lang.chosenUserLang = newFormLang
|
||||
}
|
||||
app.storage.lang.chosenAdminLang = app.config.Section("ui").Key("language-admin").MustString("en-us")
|
||||
app.storage.lang.chosenEmailLang = app.config.Section("email").Key("language").MustString("en-us")
|
||||
app.storage.lang.chosenPWRLang = app.config.Section("password_resets").Key("language").MustString("en-us")
|
||||
app.storage.lang.chosenTelegramLang = app.config.Section("telegram").Key("language").MustString("en-us")
|
||||
|
||||
app.email = NewEmailer(app)
|
||||
|
||||
return nil
|
||||
app.config.ReloadDependents(app)
|
||||
app.info.Printf(lm.LoadConfig, app.configPath)
|
||||
}
|
||||
|
||||
func (app *appContext) PatchConfigBase() {
|
||||
|
||||
372
customcontent.go
Normal file
372
customcontent.go
Normal file
@@ -0,0 +1,372 @@
|
||||
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
|
||||
}
|
||||
|
||||
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",
|
||||
"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")
|
||||
},
|
||||
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: "email",
|
||||
},
|
||||
},
|
||||
"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")
|
||||
},
|
||||
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": "",
|
||||
"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"
|
||||
},
|
||||
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
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
return true
|
||||
}()
|
||||
@@ -735,7 +735,7 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
|
||||
|
||||
var msg *Message
|
||||
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 {
|
||||
// Print extra message, ideally we'd just print this, or get rid of it though.
|
||||
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, invite.Code, err)
|
||||
|
||||
490
email_test.go
Normal file
490
email_test.go
Normal file
@@ -0,0 +1,490 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
//go:embed build/data/config-default.ini
|
||||
var configFS embed.FS
|
||||
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) {
|
||||
if binaryType != "external" {
|
||||
return nil, errors.New("test only supported with -tags \"external\"")
|
||||
}
|
||||
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{},
|
||||
}
|
||||
dConfig, err := fs.ReadFile(configFS, "build/data/config-default.ini")
|
||||
if err != nil {
|
||||
return emailer, err
|
||||
}
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return emailer, err
|
||||
}
|
||||
|
||||
// Force emailer to construct markdown
|
||||
discordEnabled = true
|
||||
// Use working directory
|
||||
localFS = dirFS(filepath.Join(wd, "build", "data"))
|
||||
langFS = dirFS(filepath.Join(wd, "build", "data", "lang"))
|
||||
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()
|
||||
msg, err := e.constructDeleted(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 n)ot 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()
|
||||
msg, err := e.constructDisabled(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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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()
|
||||
msg, err := e.constructEnabled(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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
18
external.go
18
external.go
@@ -4,7 +4,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -15,9 +14,6 @@ const binaryType = "external"
|
||||
|
||||
func BuildTagsExternal() { buildTags = append(buildTags, "external") }
|
||||
|
||||
var localFS dirFS
|
||||
var langFS dirFS
|
||||
|
||||
// When using os.DirFS, even on Windows the separator seems to be '/'.
|
||||
// func FSJoin(elem ...string) string { return filepath.Join(elem...) }
|
||||
func FSJoin(elem ...string) string {
|
||||
@@ -32,20 +28,6 @@ func FSJoin(elem ...string) string {
|
||||
return strings.TrimSuffix(path, sep)
|
||||
}
|
||||
|
||||
type dirFS string
|
||||
|
||||
func (dir dirFS) Open(name string) (fs.File, error) {
|
||||
return os.Open(string(dir) + "/" + name)
|
||||
}
|
||||
|
||||
func (dir dirFS) ReadFile(name string) ([]byte, error) {
|
||||
return os.ReadFile(string(dir) + "/" + name)
|
||||
}
|
||||
|
||||
func (dir dirFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
return os.ReadDir(string(dir) + "/" + name)
|
||||
}
|
||||
|
||||
func loadFilesystems() {
|
||||
log.Println("Using external storage")
|
||||
executable, _ := os.Executable()
|
||||
|
||||
29
fs.go
Normal file
29
fs.go
Normal 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)
|
||||
}
|
||||
@@ -19,9 +19,6 @@ var loFS embed.FS
|
||||
//go:embed lang/common lang/admin lang/email lang/form lang/setup lang/pwreset lang/telegram
|
||||
var laFS embed.FS
|
||||
|
||||
var langFS rewriteFS
|
||||
var localFS rewriteFS
|
||||
|
||||
type rewriteFS struct {
|
||||
fs embed.FS
|
||||
prefix string
|
||||
|
||||
10
lang.go
10
lang.go
@@ -1,6 +1,10 @@
|
||||
package main
|
||||
|
||||
import "github.com/hrfee/jfa-go/common"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/hrfee/jfa-go/common"
|
||||
)
|
||||
|
||||
type langMeta struct {
|
||||
Name string `json:"name"`
|
||||
@@ -166,7 +170,7 @@ func (ts *telegramLangs) getOptions() []common.Option {
|
||||
}
|
||||
|
||||
type langSection map[string]string
|
||||
type tmpl map[string]string
|
||||
type tmpl = map[string]any
|
||||
|
||||
func templateString(text string, vals tmpl) string {
|
||||
start, previousEnd := -1, -1
|
||||
@@ -183,7 +187,7 @@ func templateString(text string, vals tmpl) string {
|
||||
start = -1
|
||||
continue
|
||||
}
|
||||
out += text[previousEnd+1:start] + val
|
||||
out += text[previousEnd+1:start] + fmt.Sprint(val)
|
||||
previousEnd = i
|
||||
start = -1
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
// }
|
||||
|
||||
type Logger struct {
|
||||
empty bool
|
||||
Empty bool
|
||||
logger *log.Logger
|
||||
shortfile bool
|
||||
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) {
|
||||
l = &Logger{
|
||||
empty: true,
|
||||
Empty: true,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (l *Logger) Printf(format string, v ...interface{}) {
|
||||
if l.empty {
|
||||
if l.Empty {
|
||||
return
|
||||
}
|
||||
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{}) {
|
||||
if l.empty {
|
||||
if l.Empty {
|
||||
return
|
||||
}
|
||||
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{}) {
|
||||
if l.empty {
|
||||
if l.Empty {
|
||||
return
|
||||
}
|
||||
l.logger.Print(l.printer.Sprintf(format, v...))
|
||||
}
|
||||
|
||||
func (l *Logger) Print(v ...interface{}) {
|
||||
if l.empty {
|
||||
if l.Empty {
|
||||
return
|
||||
}
|
||||
var out string
|
||||
@@ -124,7 +124,7 @@ func (l *Logger) Print(v ...interface{}) {
|
||||
}
|
||||
|
||||
func (l *Logger) Println(v ...interface{}) {
|
||||
if l.empty {
|
||||
if l.Empty {
|
||||
return
|
||||
}
|
||||
var out string
|
||||
@@ -136,7 +136,7 @@ func (l *Logger) Println(v ...interface{}) {
|
||||
}
|
||||
|
||||
func (l *Logger) Fatal(v ...interface{}) {
|
||||
if l.empty {
|
||||
if l.Empty {
|
||||
return
|
||||
}
|
||||
var out string
|
||||
@@ -148,7 +148,7 @@ func (l *Logger) Fatal(v ...interface{}) {
|
||||
}
|
||||
|
||||
func (l *Logger) Fatalf(format string, v ...interface{}) {
|
||||
if l.empty {
|
||||
if l.Empty {
|
||||
return
|
||||
}
|
||||
var out string
|
||||
|
||||
121
main.go
121
main.go
@@ -24,7 +24,6 @@ import (
|
||||
"github.com/fatih/color"
|
||||
"github.com/hrfee/jfa-go/common"
|
||||
_ "github.com/hrfee/jfa-go/docs"
|
||||
"github.com/hrfee/jfa-go/easyproxy"
|
||||
"github.com/hrfee/jfa-go/jellyseerr"
|
||||
"github.com/hrfee/jfa-go/logger"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
@@ -81,6 +80,11 @@ var serverTypes = map[string]string{
|
||||
var serverType = mediabrowser.JellyfinServer
|
||||
var substituteStrings = ""
|
||||
|
||||
var externalURI, externalDomain string // The latter lower-case as should be accessed through app.ExternalDomain()
|
||||
var UseProxyHost bool
|
||||
|
||||
var datePattern, timePattern string
|
||||
|
||||
// User is used for auth purposes.
|
||||
type User struct {
|
||||
UserID string `json:"id"`
|
||||
@@ -88,10 +92,15 @@ type User struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// Set of the usual log channels, for ease of passing between things.
|
||||
type LoggerSet struct {
|
||||
info, debug, err *logger.Logger
|
||||
}
|
||||
|
||||
// contains (almost) everything the application needs, essentially. This was a dumb design decision imo.
|
||||
type appContext struct {
|
||||
// defaults *Config
|
||||
config *ini.File
|
||||
config *Config
|
||||
configPath string
|
||||
configBasePath string
|
||||
configBase common.Config
|
||||
@@ -103,39 +112,32 @@ type appContext struct {
|
||||
adminUsers []User
|
||||
invalidTokens []string
|
||||
// Keeping jf name because I can't think of a better one
|
||||
jf *mediabrowser.MediaBrowser
|
||||
authJf *mediabrowser.MediaBrowser
|
||||
ombi *OmbiWrapper
|
||||
js *JellyseerrWrapper
|
||||
thirdPartyServices []ThirdPartyService
|
||||
datePattern string
|
||||
timePattern string
|
||||
storage Storage
|
||||
validator Validator
|
||||
email *Emailer
|
||||
telegram *TelegramDaemon
|
||||
discord *DiscordDaemon
|
||||
matrix *MatrixDaemon
|
||||
contactMethods []ContactMethodLinker
|
||||
info, debug, err *logger.Logger
|
||||
host string
|
||||
port int
|
||||
version string
|
||||
externalURI, externalDomain string // The latter lower-case as should be accessed through app.ExternalDomain()
|
||||
UseProxyHost bool
|
||||
updater *Updater
|
||||
webhooks *WebhookSender
|
||||
newUpdate bool // Whether whatever's in update is new.
|
||||
tag Tag
|
||||
update Update
|
||||
proxyEnabled bool
|
||||
proxyTransport *http.Transport
|
||||
proxyConfig easyproxy.ProxyConfig
|
||||
internalPWRs map[string]InternalPWR
|
||||
pwrCaptchas map[string]Captcha
|
||||
ConfirmationKeys map[string]map[string]ConfirmationKey // Map of invite code to jwt to request
|
||||
confirmationKeysLock sync.Mutex
|
||||
userCache *UserCache
|
||||
jf *mediabrowser.MediaBrowser
|
||||
authJf *mediabrowser.MediaBrowser
|
||||
ombi *OmbiWrapper
|
||||
js *JellyseerrWrapper
|
||||
thirdPartyServices []ThirdPartyService
|
||||
storage *Storage
|
||||
validator Validator
|
||||
email *Emailer
|
||||
telegram *TelegramDaemon
|
||||
discord *DiscordDaemon
|
||||
matrix *MatrixDaemon
|
||||
contactMethods []ContactMethodLinker
|
||||
LoggerSet
|
||||
host string
|
||||
port int
|
||||
version string
|
||||
updater *Updater
|
||||
webhooks *WebhookSender
|
||||
newUpdate bool // Whether whatever's in update is new.
|
||||
tag Tag
|
||||
update Update
|
||||
internalPWRs map[string]InternalPWR
|
||||
pwrCaptchas map[string]Captcha
|
||||
ConfirmationKeys map[string]map[string]ConfirmationKey // Map of invite code to jwt to request
|
||||
confirmationKeysLock sync.Mutex
|
||||
userCache *UserCache
|
||||
}
|
||||
|
||||
func generateSecret(length int) (string, error) {
|
||||
@@ -244,7 +246,9 @@ func start(asDaemon, firstCall bool) {
|
||||
|
||||
var debugMode bool
|
||||
var address string
|
||||
if err := app.loadConfig(); err != nil {
|
||||
var err error = nil
|
||||
app.config, err = NewConfig(app.configPath, app.dataPath, app.LoggerSet)
|
||||
if err != nil {
|
||||
app.err.Fatalf(lm.FailedLoadConfig, app.configPath, err)
|
||||
}
|
||||
app.info.Printf(lm.LoadConfig, app.configPath)
|
||||
@@ -262,12 +266,8 @@ func start(asDaemon, firstCall bool) {
|
||||
}
|
||||
if debugMode {
|
||||
app.debug = logger.NewLogger(os.Stdout, "[DEBUG] ", log.Ltime|log.Lshortfile, color.FgYellow)
|
||||
// Bind debug log
|
||||
app.storage.debug = app.debug
|
||||
app.storage.logActions = generateLogActions(app.config)
|
||||
} else {
|
||||
app.debug = logger.NewEmptyLogger()
|
||||
app.storage.debug = nil
|
||||
}
|
||||
if *PPROF {
|
||||
app.info.Print(warning("\n\nWARNING: Don't use pprof in production.\n\n"))
|
||||
@@ -312,14 +312,17 @@ func start(asDaemon, firstCall bool) {
|
||||
}()
|
||||
}
|
||||
|
||||
app.storage.lang.CommonPath = "common"
|
||||
app.storage.lang.UserPath = "form"
|
||||
app.storage.lang.AdminPath = "admin"
|
||||
app.storage.lang.EmailPath = "email"
|
||||
app.storage.lang.TelegramPath = "telegram"
|
||||
app.storage.lang.PasswordResetPath = "pwreset"
|
||||
dbPath := filepath.Join(app.dataPath, "db")
|
||||
if debugMode {
|
||||
app.storage = NewStorage(dbPath, app.debug, generateLogActions(app.config))
|
||||
} else {
|
||||
app.storage = NewStorage(dbPath, app.debug, nil)
|
||||
}
|
||||
|
||||
// Placed here, since storage.chosenXLang is set by this function.
|
||||
app.config.ReloadDependents(app)
|
||||
|
||||
externalLang := app.config.Section("files").Key("lang_files").MustString("")
|
||||
var err error
|
||||
if externalLang == "" {
|
||||
err = app.storage.loadLang(langFS)
|
||||
} else {
|
||||
@@ -362,7 +365,7 @@ func start(asDaemon, firstCall bool) {
|
||||
}
|
||||
address = fmt.Sprintf("%s:%d", app.host, app.port)
|
||||
|
||||
// NOTE: As of writing this, the order in app.thirdPartServices doesn't matter,
|
||||
// NOTE: As of writing this, the order in app.thirdPartyServices doesn't matter,
|
||||
// but in future it might (like app.contactMethods does), so append to the end!
|
||||
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
||||
app.ombi = &OmbiWrapper{}
|
||||
@@ -391,10 +394,12 @@ func start(asDaemon, firstCall bool) {
|
||||
|
||||
}
|
||||
|
||||
app.storage.db_path = filepath.Join(app.dataPath, "db")
|
||||
app.loadPendingBackup()
|
||||
app.ConnectDB()
|
||||
defer app.storage.db.Close()
|
||||
if err := app.storage.Connect(app.config); err != nil {
|
||||
app.err.Fatalf(lm.FailedConnectDB, dbPath, err)
|
||||
}
|
||||
app.info.Printf(lm.ConnectDB, dbPath)
|
||||
defer app.storage.Close()
|
||||
|
||||
// copy it to app.patchedConfig, and patch in settings from app.config, and language stuff.
|
||||
app.PatchConfigBase()
|
||||
@@ -475,10 +480,9 @@ func start(asDaemon, firstCall bool) {
|
||||
time.Minute*time.Duration(app.config.Section("jellyfin").Key("web_cache_sync_timeout").MustInt()),
|
||||
)
|
||||
|
||||
// Since email depends on language, the email reload in loadConfig won't work first time.
|
||||
// Since email depends on language, the email reload in NewConfig won't work first time.
|
||||
// Email also handles its own proxying, as (SMTP atleast) doesn't use a HTTP transport.
|
||||
app.email = NewEmailer(app)
|
||||
app.loadStrftime()
|
||||
app.email = NewEmailer(app.config, app.storage, app.LoggerSet)
|
||||
|
||||
var validatorConf ValidatorConf
|
||||
|
||||
@@ -579,13 +583,13 @@ func start(asDaemon, firstCall bool) {
|
||||
)
|
||||
|
||||
// Updater proxy set in config.go, don't worry!
|
||||
if app.proxyEnabled {
|
||||
app.jf.SetTransport(app.proxyTransport)
|
||||
if app.config.proxyConfig != nil {
|
||||
app.jf.SetTransport(app.config.proxyTransport)
|
||||
for _, c := range app.thirdPartyServices {
|
||||
c.SetTransport(app.proxyTransport)
|
||||
c.SetTransport(app.config.proxyTransport)
|
||||
}
|
||||
for _, c := range app.contactMethods {
|
||||
c.SetTransport(app.proxyTransport)
|
||||
c.SetTransport(app.config.proxyTransport)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -601,7 +605,6 @@ func start(asDaemon, firstCall bool) {
|
||||
app.host = "0.0.0.0"
|
||||
}
|
||||
address = fmt.Sprintf("%s:%d", app.host, app.port)
|
||||
app.storage.lang.SetupPath = "setup"
|
||||
err := app.storage.loadLangSetup(langFS)
|
||||
if err != nil {
|
||||
app.info.Fatalf(lm.FailedLangLoad, err)
|
||||
|
||||
@@ -81,7 +81,7 @@ func migrateEmailConfig(app *appContext) {
|
||||
app.err.Fatalf("Failed to save config: %v", err)
|
||||
return
|
||||
}
|
||||
app.loadConfig()
|
||||
app.ReloadConfig()
|
||||
}
|
||||
|
||||
// Migrate pre-0.3.6 email settings to the new messages section.
|
||||
@@ -245,7 +245,7 @@ func loadLegacyData(app *appContext) {
|
||||
app.storage.customEmails_path = app.config.Section("files").Key("custom_emails").String()
|
||||
app.storage.loadCustomEmails()
|
||||
|
||||
app.MustSetValue("user_page", "enabled", "true")
|
||||
app.config.MustSetValue("user_page", "enabled", "true")
|
||||
if app.config.Section("user_page").Key("enabled").MustBool(false) {
|
||||
app.storage.userPage_path = app.config.Section("files").Key("custom_user_page_content").String()
|
||||
app.storage.loadUserPageContent()
|
||||
|
||||
@@ -29,8 +29,8 @@ func (app *appContext) GenInternalReset(userID string) (InternalPWR, error) {
|
||||
}
|
||||
|
||||
// GenResetLink generates and returns a password reset link.
|
||||
func (app *appContext) GenResetLink(pin string) (string, error) {
|
||||
url := app.ExternalURI(nil)
|
||||
func GenResetLink(pin string) (string, error) {
|
||||
url := ExternalURI(nil)
|
||||
var pinLink string
|
||||
if url == "" {
|
||||
return pinLink, errors.New(lm.NoExternalHost)
|
||||
@@ -104,7 +104,7 @@ func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
|
||||
uid := user.ID
|
||||
name := app.getAddressOrName(uid)
|
||||
if name != "" {
|
||||
msg, err := app.email.constructReset(pwr, app, false)
|
||||
msg, err := app.email.constructReset(pwr, false)
|
||||
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
|
||||
|
||||
16
scripts/scrape-custom-content-schema.py
Normal file
16
scripts/scrape-custom-content-schema.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# Quick script to scrape custom content names, variables and conditionals. The decision to generate vars and conds dynamically from the included plaintext emails, then bodge extra variables on top was stupid, and is only done -once- before getting stored in the DB indefinitely, meaning new variables can't easily be added. Output of this will be coalesced into a predefined list included with the software.
|
||||
import requests, json
|
||||
|
||||
content = requests.get("http://localhost:8056/config/emails?lang=en-gb&filter=user").json()
|
||||
|
||||
out = {}
|
||||
|
||||
for key in content:
|
||||
resp = requests.get("http://localhost:8056/config/emails/"+key)
|
||||
out[key] = resp.json()
|
||||
del out[key]["html"]
|
||||
del out[key]["plaintext"]
|
||||
del out[key]["content"]
|
||||
|
||||
print(json.dumps(out, indent=4))
|
||||
|
||||
93
storage.go
93
storage.go
@@ -15,10 +15,8 @@ import (
|
||||
"github.com/hrfee/jfa-go/common"
|
||||
"github.com/hrfee/jfa-go/jellyseerr"
|
||||
"github.com/hrfee/jfa-go/logger"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
type discordStore map[string]DiscordUser
|
||||
@@ -80,7 +78,7 @@ const (
|
||||
|
||||
type Storage struct {
|
||||
debug *logger.Logger
|
||||
logActions map[string]DebugLogAction
|
||||
logActions func(k string) DebugLogAction
|
||||
|
||||
timePattern string
|
||||
|
||||
@@ -104,6 +102,44 @@ type Storage struct {
|
||||
lang Lang
|
||||
}
|
||||
|
||||
// NewStorage returns a new Storage object with values initialised.
|
||||
func NewStorage(dbPath string, debugLogger *logger.Logger, logActions func(k string) DebugLogAction) *Storage {
|
||||
if debugLogger.Empty {
|
||||
debugLogger = nil
|
||||
}
|
||||
st := &Storage{
|
||||
debug: debugLogger,
|
||||
logActions: logActions,
|
||||
db_path: dbPath,
|
||||
}
|
||||
st.lang.CommonPath = "common"
|
||||
st.lang.UserPath = "form"
|
||||
st.lang.AdminPath = "admin"
|
||||
st.lang.EmailPath = "email"
|
||||
st.lang.TelegramPath = "telegram"
|
||||
st.lang.PasswordResetPath = "pwreset"
|
||||
st.lang.SetupPath = "setup"
|
||||
return st
|
||||
}
|
||||
|
||||
// Connect connects to the underlying data storage method (e.g. db).
|
||||
// Call Close() once finished.
|
||||
func (st *Storage) Connect(config *Config) error {
|
||||
opts := badgerhold.DefaultOptions
|
||||
// ValueLogFileSize is in bytes, so multiply by 1e6
|
||||
opts.Options.ValueLogFileSize = config.Section("advanced").Key("value_log_size").MustInt64(256) * 1e6
|
||||
opts.Dir = st.db_path
|
||||
opts.ValueDir = st.db_path
|
||||
var err error = nil
|
||||
st.db, err = badgerhold.Open(opts)
|
||||
return err
|
||||
}
|
||||
|
||||
// Close shuts down the underlying data storage method (e.g. db).
|
||||
func (st *Storage) Close() error {
|
||||
return st.db.Close()
|
||||
}
|
||||
|
||||
type StoreType int
|
||||
|
||||
// Used for debug logging of storage.
|
||||
@@ -146,7 +182,7 @@ func (st *Storage) DebugWatch(storeType StoreType, key, mainData string) {
|
||||
actionKey = "custom_content"
|
||||
}
|
||||
|
||||
logAction := st.logActions[actionKey]
|
||||
logAction := st.logActions(actionKey)
|
||||
if logAction == NoLog {
|
||||
return
|
||||
}
|
||||
@@ -159,7 +195,7 @@ func (st *Storage) DebugWatch(storeType StoreType, key, mainData string) {
|
||||
}
|
||||
}
|
||||
|
||||
func generateLogActions(c *ini.File) map[string]DebugLogAction {
|
||||
func generateLogActions(c *Config) func(k string) DebugLogAction {
|
||||
m := map[string]DebugLogAction{}
|
||||
for _, v := range []string{"emails", "discord", "telegram", "matrix", "invites", "announcements", "expirires", "profiles", "custom_content"} {
|
||||
switch c.Section("advanced").Key("debug_log_" + v).MustString("none") {
|
||||
@@ -171,21 +207,7 @@ func generateLogActions(c *ini.File) map[string]DebugLogAction {
|
||||
m[v] = LogDeletion
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (app *appContext) ConnectDB() {
|
||||
opts := badgerhold.DefaultOptions
|
||||
// ValueLogFileSize is in bytes, so multiply by 1e6
|
||||
opts.Options.ValueLogFileSize = app.config.Section("advanced").Key("value_log_size").MustInt64(256) * 1e6
|
||||
opts.Dir = app.storage.db_path
|
||||
opts.ValueDir = app.storage.db_path
|
||||
db, err := badgerhold.Open(opts)
|
||||
if err != nil {
|
||||
app.err.Fatalf(lm.FailedConnectDB, app.storage.db_path, err)
|
||||
}
|
||||
app.storage.db = db
|
||||
app.info.Printf(lm.ConnectDB, app.storage.db_path)
|
||||
return func(k string) DebugLogAction { return m[k] }
|
||||
}
|
||||
|
||||
// GetEmails returns a copy of the store.
|
||||
@@ -683,13 +705,34 @@ type customEmails struct {
|
||||
ExpiryReminder CustomContent `json:"expiryReminder"`
|
||||
}
|
||||
|
||||
type CustomContentContext = int
|
||||
|
||||
const (
|
||||
CustomMessage CustomContentContext = iota
|
||||
CustomCard
|
||||
CustomTemplate
|
||||
)
|
||||
|
||||
type ContentSourceFileInfo struct{ Section, SettingPrefix, DefaultValue string }
|
||||
|
||||
// CustomContent stores information needed for creating custom jfa-go content, including emails and user messages.
|
||||
type CustomContentInfo struct {
|
||||
Name string `json:"name" badgerhold:"key"`
|
||||
DisplayName, Description func(dict *Lang, lang string) string
|
||||
Subject func(config *Config, lang *emailLang) string
|
||||
// Config section, the main part of the setting name (without "html" or "text"), and the default filename (without ".html" or ".txt").
|
||||
SourceFile ContentSourceFileInfo
|
||||
ContentType CustomContentContext `json:"type"`
|
||||
Variables []string `json:"variables,omitempty"`
|
||||
Conditionals []string `json:"conditionals,omitempty"`
|
||||
Placeholders map[string]any `json:"values,omitempty"`
|
||||
}
|
||||
|
||||
// CustomContent stores customized versions of jfa-go content, including emails and user messages.
|
||||
type CustomContent struct {
|
||||
Name string `json:"name" badgerhold:"key"`
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Variables []string `json:"variables,omitempty"`
|
||||
Conditionals []string `json:"conditionals,omitempty"`
|
||||
Name string `json:"name" badgerhold:"key"`
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type userPageContent struct {
|
||||
|
||||
@@ -130,7 +130,7 @@ type Updater struct {
|
||||
binary string
|
||||
}
|
||||
|
||||
func newUpdater(buildroneURL, namespace, repo, version, commit, buildType string) *Updater {
|
||||
func NewUpdater(buildroneURL, namespace, repo, version, commit, buildType string) *Updater {
|
||||
// fmt.Printf(`Updater intializing with "%s", "%s", "%s", "%s", "%s", "%s"\n`, buildroneURL, namespace, repo, version, commit, buildType)
|
||||
bType := off
|
||||
tag := ""
|
||||
|
||||
@@ -81,7 +81,7 @@ func (app *appContext) checkUsers(remindBeforeExpiry *DayTimerSet) {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
msg, err := app.email.constructExpiryReminder(user.Name, expiry.Expiry, app, false)
|
||||
msg, err := app.email.constructExpiryReminder(user.Name, expiry.Expiry, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructExpiryReminderMessage, user.ID, err)
|
||||
} else if err := app.sendByID(msg, user.ID); err != nil {
|
||||
@@ -173,7 +173,7 @@ func (app *appContext) checkUsers(remindBeforeExpiry *DayTimerSet) {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
msg, err := app.email.constructUserExpired(app, false)
|
||||
msg, err := app.email.constructUserExpired(false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructExpiryMessage, user.ID, err)
|
||||
} else if err := app.sendByID(msg, user.ID); err != nil {
|
||||
|
||||
2
users.go
2
users.go
@@ -169,7 +169,7 @@ func (app *appContext) WelcomeNewUser(user mediabrowser.User, expiry time.Time)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
msg, err := app.email.constructWelcome(user.Name, expiry, app, false)
|
||||
msg, err := app.email.constructWelcome(user.Name, expiry, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructWelcomeMessage, user.ID, err)
|
||||
} else if err := app.sendByID(msg, user.ID); err != nil {
|
||||
|
||||
11
views.go
11
views.go
@@ -88,7 +88,7 @@ func (app *appContext) BasePageTemplateValues(gc *gin.Context, page Page, base g
|
||||
|
||||
pages := PagePathsDTO{
|
||||
PagePaths: PAGES,
|
||||
ExternalURI: app.ExternalURI(gc),
|
||||
ExternalURI: ExternalURI(gc),
|
||||
TrueBase: PAGES.Base,
|
||||
}
|
||||
pages.Base = app.getURLBase(gc)
|
||||
@@ -742,7 +742,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
|
||||
discord := discordEnabled && app.config.Section("discord").Key("show_on_reg").MustBool(true)
|
||||
matrix := matrixEnabled && app.config.Section("matrix").Key("show_on_reg").MustBool(true)
|
||||
|
||||
userPageAddress := app.ExternalURI(gc) + PAGES.MyAccount
|
||||
userPageAddress := ExternalURI(gc) + PAGES.MyAccount
|
||||
|
||||
fromUser := ""
|
||||
if invite.ReferrerJellyfinID != "" {
|
||||
@@ -810,14 +810,15 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
|
||||
data["discordInviteLink"] = app.discord.InviteChannel.Name != ""
|
||||
}
|
||||
if msg, ok := app.storage.GetCustomContentKey("PostSignupCard"); ok && msg.Enabled {
|
||||
cci := customContent["PostSignupCard"]
|
||||
data["customSuccessCard"] = true
|
||||
// We don't template here, since the username is only known after login.
|
||||
templated, err := templateEmail(
|
||||
msg.Content,
|
||||
msg.Variables,
|
||||
msg.Conditionals,
|
||||
cci.Variables,
|
||||
cci.Conditionals,
|
||||
map[string]any{
|
||||
"username": "{username}",
|
||||
"username": "{username}", // Value is subbed by webpage
|
||||
"myAccountURL": userPageAddress,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user