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:
Harvey Tindall
2025-08-30 14:21:26 +01:00
parent 0b43ad4ed5
commit 60dbfa2d1e
27 changed files with 1646 additions and 1141 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: configuration email typescript swagger copy compile compress inline-css variants-html install clean npm config-description config-default precompile .PHONY: configuration email typescript swagger copy compile compress inline-css variants-html install clean npm config-description config-default precompile test
.DEFAULT_GOAL := all .DEFAULT_GOAL := all
GOESBUILD ?= off GOESBUILD ?= off
@@ -216,13 +216,16 @@ ifeq ($(INTERNAL), on)
endif endif
GO_SRC = $(shell find ./ -name "*.go") GO_SRC = $(shell find ./ -name "*.go")
GO_TARGET = build/jfa-go GO_TARGET = build/jfa-go
$(GO_TARGET): $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum $(GO_TARGET): $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
$(info Downloading deps) $(info Downloading deps)
$(GOBINARY) mod download $(GOBINARY) mod download
$(info Building) $(info Building)
mkdir -p build mkdir -p build
$(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o $(GO_TARGET) $(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o $(GO_TARGET)
test: $(BUILDDEPS) $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
$(GOBINARY) test -ldflags="$(LDFLAGS)" $(TAGS) -p 1
all: $(BUILDDEPS) $(GO_TARGET) all: $(BUILDDEPS) $(GO_TARGET)

View File

@@ -135,7 +135,7 @@ func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup
wait.Add(1) wait.Add(1)
go func(addr string) { go func(addr string) {
defer wait.Done() defer wait.Done()
msg, err := app.email.constructExpiry(data.Code, data, app, false) msg, err := app.email.constructExpiry(data, false)
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructExpiryAdmin, data.Code, err) app.err.Printf(lm.FailedConstructExpiryAdmin, data.Code, err)
} else { } else {
@@ -218,7 +218,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
invite.SendTo = req.SendTo invite.SendTo = req.SendTo
} }
if addressValid { if addressValid {
msg, err := app.email.constructInvite(invite.Code, invite, app, false) msg, err := app.email.constructInvite(invite, false)
if err != nil { if err != nil {
// Slight misuse of the template // Slight misuse of the template
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, req.SendTo, err) invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, req.SendTo, err)
@@ -343,7 +343,7 @@ func (app *appContext) GetInvites(gc *gin.Context) {
// These used to be stored formatted instead of as a unix timestamp. // These used to be stored formatted instead of as a unix timestamp.
unix, err := strconv.ParseInt(pair[1], 10, 64) unix, err := strconv.ParseInt(pair[1], 10, 64)
if err != nil { if err != nil {
date, err := timefmt.Parse(pair[1], app.datePattern+" "+app.timePattern) date, err := timefmt.Parse(pair[1], datePattern+" "+timePattern)
if err != nil { if err != nil {
app.err.Printf(lm.FailedParseTime, err) app.err.Printf(lm.FailedParseTime, err)
} }

View File

@@ -1,7 +1,6 @@
package main package main
import ( import (
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -23,26 +22,16 @@ func (app *appContext) GetCustomContent(gc *gin.Context) {
if _, ok := app.storage.lang.Email[lang]; !ok { if _, ok := app.storage.lang.Email[lang]; !ok {
lang = app.storage.lang.chosenEmailLang lang = app.storage.lang.chosenEmailLang
} }
adminLang := lang list := emailListDTO{}
if _, ok := app.storage.lang.Admin[lang]; !ok { for _, cc := range customContent {
adminLang = app.storage.lang.chosenAdminLang if cc.ContentType == CustomTemplate {
} continue
list := emailListDTO{ }
"UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.MustGetCustomContentKey("UserCreated").Enabled}, ccDescription := emailListEl{Name: cc.DisplayName(&app.storage.lang, lang), Enabled: app.storage.MustGetCustomContentKey(cc.Name).Enabled}
"InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.MustGetCustomContentKey("InviteExpiry").Enabled}, if cc.Description != nil {
"PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.MustGetCustomContentKey("PasswordReset").Enabled}, ccDescription.Description = cc.Description(&app.storage.lang, lang)
"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}, list[cc.Name] = ccDescription
"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"]},
} }
filter := gc.Query("filter") filter := gc.Query("filter")
@@ -74,11 +63,12 @@ func (app *appContext) SetCustomMessage(gc *gin.Context) {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
message, ok := app.storage.GetCustomContentKey(id) _, ok := customContent[id]
if !ok { if !ok {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
message, ok := app.storage.GetCustomContentKey(id)
message.Content = req.Content message.Content = req.Content
message.Enabled = true message.Enabled = true
app.storage.SetCustomContentKey(id, message) app.storage.SetCustomContentKey(id, message)
@@ -124,151 +114,92 @@ func (app *appContext) SetCustomMessageState(gc *gin.Context) {
// @Security Bearer // @Security Bearer
// @tags Configuration // @tags Configuration
func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) { func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
lang := app.storage.lang.chosenEmailLang
id := gc.Param("id") id := gc.Param("id")
var content string
var err error var err error
var msg *Message contentInfo, ok := customContent[id]
var variables []string // FIXME: Add announcement to customContent
var conditionals []string
var values map[string]interface{}
username := app.storage.lang.Email[lang].Strings.get("username")
emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress")
customMessage, ok := app.storage.GetCustomContentKey(id)
if !ok && id != "Announcement" { if !ok && id != "Announcement" {
app.err.Printf(lm.FailedGetCustomMessage, id) app.err.Printf(lm.FailedGetCustomMessage, id)
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
if id == "WelcomeEmail" {
conditionals = []string{"{yourAccountWillExpire}"} content, ok := app.storage.GetCustomContentKey(id)
customMessage.Conditionals = conditionals
} else if id == "UserPage" { if contentInfo.Variables == nil {
variables = []string{"{username}"} contentInfo.Variables = []string{}
customMessage.Variables = variables }
} else if id == "UserLogin" { if contentInfo.Conditionals == nil {
variables = []string{} contentInfo.Conditionals = []string{}
customMessage.Variables = variables }
} else if id == "PostSignupCard" { if contentInfo.Placeholders == nil {
variables = []string{"{username}", "{myAccountURL}"} contentInfo.Placeholders = map[string]any{}
customMessage.Variables = variables
} }
content = customMessage.Content // Generate content from real email, if the user hasn't already customised this message.
noContent := content == "" if content.Content == "" {
if !noContent { var msg *Message
variables = customMessage.Variables switch id {
// 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 var mail *Message
if id != "UserLogin" && id != "UserPage" && id != "PostSignupCard" { if contentInfo.ContentType == CustomMessage {
mail, err = app.email.constructTemplate("", "<div class=\"preview-content\"></div>", app) 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 { if err != nil {
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
} else if id == "PostSignupCard" { } else if id == "PostSignupCard" {
// Jankiness follows. // Specific workaround for the currently-unique "Post signup card".
// Source content from "Success Message" setting. // Source content from "Success Message" setting.
if noContent { if content.Content == "" {
content = "# " + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("successHeader") + "\n" + app.config.Section("ui").Key("success_message").String() content.Content = "# " + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("successHeader") + "\n" + app.config.Section("ui").Key("success_message").String()
if app.config.Section("user_page").Key("enabled").MustBool(false) { if app.config.Section("user_page").Key("enabled").MustBool(false) {
content += "\n\n<br>\n" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.template("userPageSuccessMessage", tmpl{ content.Content += "\n\n<br>\n" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.template("userPageSuccessMessage", tmpl{
"myAccount": "[" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("myAccount") + "]({myAccountURL})", "myAccount": "[" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("myAccount") + "]({myAccountURL})",
}) })
} }
@@ -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>", HTML: "<div class=\"card ~neutral dark:~d_neutral @low\"><div class=\"preview-content\"></div><br><button class=\"button ~urge dark:~d_urge @low full-width center supra submit\">" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("continue") + "</a></div>",
} }
mail.Markdown = mail.HTML mail.Markdown = mail.HTML
} else { } else if contentInfo.ContentType == CustomCard {
mail = &Message{ mail = &Message{
HTML: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>", HTML: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
} }
mail.Markdown = mail.HTML mail.Markdown = mail.HTML
} else {
app.err.Printf("unknown custom content type %d", contentInfo.ContentType)
} }
gc.JSON(200, customEmailDTO{Content: content, Variables: variables, Conditionals: conditionals, Values: values, HTML: mail.HTML, Plaintext: mail.Text}) gc.JSON(200, customEmailDTO{Content: content.Content, Variables: contentInfo.Variables, Conditionals: contentInfo.Conditionals, Values: contentInfo.Placeholders, HTML: mail.HTML, Plaintext: mail.Text})
} }
// @Summary Returns a new Telegram verification PIN, and the bot username. // @Summary Returns a new Telegram verification PIN, and the bot username.

View File

@@ -264,7 +264,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
} }
app.debug.Printf(lm.EmailConfirmationRequired, id) app.debug.Printf(lm.EmailConfirmationRequired, id)
respond(401, "confirmEmail", gc) respond(401, "confirmEmail", gc)
msg, err := app.email.constructConfirmation("", name, key, app, false) msg, err := app.email.constructConfirmation("", name, key, false)
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructConfirmationEmail, id, err) app.err.Printf(lm.FailedConstructConfirmationEmail, id, err)
} else if err := app.email.send(msg, req.Email); err != nil { } else if err := app.email.send(msg, req.Email); err != nil {
@@ -643,7 +643,7 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
Username: pwr.Username, Username: pwr.Username,
Expiry: pwr.Expiry, Expiry: pwr.Expiry,
Internal: true, Internal: true,
}, app, false, }, false,
) )
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err) app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)

View File

@@ -189,7 +189,7 @@ func (app *appContext) NewUserFromInvite(gc *gin.Context) {
app.debug.Printf(lm.EmailConfirmationRequired, req.Username) app.debug.Printf(lm.EmailConfirmationRequired, req.Username)
respond(401, "confirmEmail", gc) respond(401, "confirmEmail", gc)
msg, err := app.email.constructConfirmation(req.Code, req.Username, key, app, false) msg, err := app.email.constructConfirmation(req.Code, req.Username, key, false)
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructConfirmationEmail, req.Code, err) app.err.Printf(lm.FailedConstructConfirmationEmail, req.Code, err)
} else if err := app.email.send(msg, req.Email); err != nil { } else if err := app.email.send(msg, req.Email); err != nil {
@@ -262,7 +262,7 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
} }
app.contactMethods[i].DeleteVerifiedToken(c.PIN) app.contactMethods[i].DeleteVerifiedToken(c.PIN)
c.User.SetJellyfin(nu.User.ID) c.User.SetJellyfin(nu.User.ID)
c.User.Store(&(app.storage)) c.User.Store(app.storage)
} }
} }
@@ -290,7 +290,7 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
continue continue
} }
go func(addr string) { go func(addr string) {
msg, err := app.email.constructCreated(req.Code, req.Username, req.Email, invite, app, false) msg, err := app.email.constructCreated(req.Username, req.Email, time.Now(), invite, false)
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructCreationAdmin, req.Code, err) app.err.Printf(lm.FailedConstructCreationAdmin, req.Code, err)
} else { } else {
@@ -384,9 +384,9 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
var err error var err error
if sendMail { if sendMail {
if req.Enabled { if req.Enabled {
msg, err = app.email.constructEnabled(req.Reason, app, false) msg, err = app.email.constructEnabled(req.Reason, false)
} else { } else {
msg, err = app.email.constructDisabled(req.Reason, app, false) msg, err = app.email.constructDisabled(req.Reason, false)
} }
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructEnableDisableMessage, "?", err) app.err.Printf(lm.FailedConstructEnableDisableMessage, "?", err)
@@ -452,7 +452,7 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
var msg *Message var msg *Message
var err error var err error
if sendMail { if sendMail {
msg, err = app.email.constructDeleted(req.Reason, app, false) msg, err = app.email.constructDeleted(req.Reason, false)
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructDeletionMessage, "?", err) app.err.Printf(lm.FailedConstructDeletionMessage, "?", err)
sendMail = false sendMail = false
@@ -541,7 +541,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
if err != nil { if err != nil {
return return
} }
msg, err := app.email.constructExpiryAdjusted(user.Name, exp, req.Reason, app, false) msg, err := app.email.constructExpiryAdjusted(user.Name, exp, req.Reason, false)
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructExpiryAdjustmentMessage, uid, err) app.err.Printf(lm.FailedConstructExpiryAdjustmentMessage, uid, err)
return return
@@ -677,7 +677,11 @@ func (app *appContext) Announce(gc *gin.Context) {
app.err.Printf(lm.FailedGetUser, userID, lm.Jellyfin, err) app.err.Printf(lm.FailedGetUser, userID, lm.Jellyfin, err)
continue continue
} }
msg, err := app.email.constructTemplate(req.Subject, req.Message, app, user.Name) msg := &Message{}
err = app.email.construct(AnnouncementCustomContent(req.Subject), CustomContent{
Enabled: true,
Content: req.Message,
}, map[string]any{"username": user.Name}, msg)
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructAnnouncementMessage, userID, err) app.err.Printf(lm.FailedConstructAnnouncementMessage, userID, err)
respondBool(500, false, gc) respondBool(500, false, gc)
@@ -690,7 +694,11 @@ func (app *appContext) Announce(gc *gin.Context) {
} }
// app.info.Printf(lm.SentAnnouncementMessage, "*", "?") // app.info.Printf(lm.SentAnnouncementMessage, "*", "?")
} else { } else {
msg, err := app.email.constructTemplate(req.Subject, req.Message, app) msg := &Message{}
err := app.email.construct(AnnouncementCustomContent(req.Subject), CustomContent{
Enabled: true,
Content: req.Message,
}, map[string]any{"username": ""}, msg)
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructAnnouncementMessage, "*", err) app.err.Printf(lm.FailedConstructAnnouncementMessage, "*", err)
respondBool(500, false, gc) respondBool(500, false, gc)
@@ -810,7 +818,7 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) {
app.internalPWRs[pwr.PIN] = pwr app.internalPWRs[pwr.PIN] = pwr
sendAddress := app.getAddressOrName(id) sendAddress := app.getAddressOrName(id)
if sendAddress == "" || len(req.Users) == 1 { if sendAddress == "" || len(req.Users) == 1 {
resp.Link, err = app.GenResetLink(pwr.PIN) resp.Link, err = GenResetLink(pwr.PIN)
linkCount++ linkCount++
if sendAddress == "" { if sendAddress == "" {
resp.Manual = true resp.Manual = true
@@ -823,7 +831,7 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) {
Username: pwr.Username, Username: pwr.Username,
Expiry: pwr.Expiry, Expiry: pwr.Expiry,
Internal: true, Internal: true,
}, app, false, }, false,
) )
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructPWRMessage, id, err) app.err.Printf(lm.FailedConstructPWRMessage, id, err)

21
api.go
View File

@@ -36,23 +36,14 @@ func respondBool(code int, val bool, gc *gin.Context) {
gc.Abort() gc.Abort()
} }
func (app *appContext) loadStrftime() { func prettyTime(dt time.Time) (date, time string) {
app.datePattern = app.config.Section("messages").Key("date_format").String() date = timefmt.Format(dt, datePattern)
app.timePattern = `%H:%M` time = timefmt.Format(dt, timePattern)
if val, _ := app.config.Section("messages").Key("use_24h").Bool(); !val {
app.timePattern = `%I:%M %p`
}
return return
} }
func (app *appContext) prettyTime(dt time.Time) (date, time string) { func formatDatetime(dt time.Time) string {
date = timefmt.Format(dt, app.datePattern) d, t := prettyTime(dt)
time = timefmt.Format(dt, app.timePattern)
return
}
func (app *appContext) formatDatetime(dt time.Time) string {
d, t := app.prettyTime(dt)
return d + " " + t return d + " " + t
} }
@@ -310,7 +301,7 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
if req["restart-program"] != nil && req["restart-program"].(bool) { if req["restart-program"] != nil && req["restart-program"].(bool) {
app.Restart() app.Restart()
} }
app.loadConfig() app.ReloadConfig()
// Patch new settings for next GetConfig // Patch new settings for next GetConfig
app.PatchConfigBase() app.PatchConfigBase()
// Reinitialize password validator on config change, as opposed to every applicable request like in python. // Reinitialize password validator on config change, as opposed to every applicable request like in python.

View File

@@ -14,6 +14,7 @@ import (
const ( const (
BACKUP_PREFIX = "jfa-go-db" BACKUP_PREFIX = "jfa-go-db"
BACKUP_PREFIX_OLD = "jfa-go-db-"
BACKUP_COMMIT_PREFIX = "-c-" BACKUP_COMMIT_PREFIX = "-c-"
BACKUP_DATE_PREFIX = "-d-" BACKUP_DATE_PREFIX = "-d-"
BACKUP_UPLOAD_PREFIX = "upload-" BACKUP_UPLOAD_PREFIX = "upload-"
@@ -33,7 +34,7 @@ func (b Backup) Equals(a Backup) bool {
return a.Date.Equal(b.Date) && a.Commit == b.Commit && a.Upload == b.Upload return a.Date.Equal(b.Date) && a.Commit == b.Commit && a.Upload == b.Upload
} }
// Pre 21/03/25 format: "{BACKUP_PREFIX}{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-2006-01-02T15-04-05.bak" // Pre 21/03/25 format: "{BACKUP_PREFIX_OLD}{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-2006-01-02T15-04-05.bak"
// Post 21/03/25 format: "{BACKUP_PREFIX}-c-{commit}-d-{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-c-0b92060-d-2006-01-02T15-04-05.bak" // Post 21/03/25 format: "{BACKUP_PREFIX}-c-{commit}-d-{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-c-0b92060-d-2006-01-02T15-04-05.bak"
func (b Backup) String() string { func (b Backup) String() string {
@@ -274,8 +275,10 @@ func (app *appContext) loadPendingBackup() {
} }
app.info.Printf(lm.MoveOldDB, oldPath) app.info.Printf(lm.MoveOldDB, oldPath)
app.ConnectDB() if err := app.storage.Connect(app.config); err != nil {
defer app.storage.db.Close() app.err.Fatalf(lm.FailedConnectDB, app.storage.db_path, err)
}
defer app.storage.Close()
f, err := os.Open(LOADBAK) f, err := os.Open(LOADBAK)
if err != nil { if err != nil {

View File

@@ -17,13 +17,13 @@ func testBackupParse(f string, a Backup, t *testing.T) {
} }
func TestBackupParserOld(t *testing.T) { func TestBackupParserOld(t *testing.T) {
Q1 := BACKUP_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX Q1 := BACKUP_PREFIX_OLD + "2023-12-21T21-08-00" + BACKUP_SUFFIX
A1 := Backup{} A1 := Backup{}
A1.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00") A1.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
testBackupParse(Q1, A1, t) testBackupParse(Q1, A1, t)
} }
func TestBackupParserOldUpload(t *testing.T) { func TestBackupParserOldUpload(t *testing.T) {
Q2 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX Q2 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX_OLD + "2023-12-21T21-08-00" + BACKUP_SUFFIX
A2 := Backup{ A2 := Backup{
Upload: true, Upload: true,
} }

370
config.go
View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"io/fs" "io/fs"
"net" "net"
"net/http"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
@@ -18,6 +19,12 @@ import (
"gopkg.in/ini.v1" "gopkg.in/ini.v1"
) )
type Config struct {
*ini.File
proxyTransport *http.Transport
proxyConfig *easyproxy.ProxyConfig
}
var emailEnabled = false var emailEnabled = false
var messagesEnabled = false var messagesEnabled = false
var telegramEnabled = false var telegramEnabled = false
@@ -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 /. // IMPORTANT: When linking straight to a page, rather than appending further to the URL (like accessing an API route), append a /.
var PAGES = PagePaths{} var PAGES = PagePaths{}
func (app *appContext) GetPath(sect, key string) (fs.FS, string) { func (config *Config) GetPath(sect, key string) (fs.FS, string) {
val := app.config.Section(sect).Key(key).MustString("") val := config.Section(sect).Key(key).MustString("")
if strings.HasPrefix(val, "jfa-go:") { if strings.HasPrefix(val, "jfa-go:") {
return localFS, strings.TrimPrefix(val, "jfa-go:") return localFS, strings.TrimPrefix(val, "jfa-go:")
} }
@@ -37,15 +44,15 @@ func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
return os.DirFS(dir), file return os.DirFS(dir), file
} }
func (app *appContext) MustSetValue(section, key, val string) { func (config *Config) MustSetValue(section, key, val string) {
app.config.Section(section).Key(key).SetValue(app.config.Section(section).Key(key).MustString(val)) config.Section(section).Key(key).SetValue(config.Section(section).Key(key).MustString(val))
} }
func (app *appContext) MustSetURLPath(section, key, val string) { func (config *Config) MustSetURLPath(section, key, val string) {
if !strings.HasPrefix(val, "/") && val != "" { if !strings.HasPrefix(val, "/") && val != "" {
val = "/" + val val = "/" + val
} }
app.MustSetValue(section, key, val) config.MustSetValue(section, key, val)
} }
func FixFullURL(v string) string { func FixFullURL(v string) string {
@@ -69,26 +76,26 @@ func FormatSubpath(path string, removeSingleSlash bool) string {
return strings.TrimSuffix(path, "/") return strings.TrimSuffix(path, "/")
} }
func (app *appContext) MustCorrectURL(section, key, value string) { func (config *Config) MustCorrectURL(section, key, value string) {
v := app.config.Section(section).Key(key).String() v := config.Section(section).Key(key).String()
if v == "" { if v == "" {
v = value v = value
} }
v = FixFullURL(v) v = FixFullURL(v)
app.config.Section(section).Key(key).SetValue(v) config.Section(section).Key(key).SetValue(v)
} }
// ExternalDomain returns the Host for the request, using the fixed app.externalDomain value unless app.UseProxyHost is true. // ExternalDomain returns the Host for the request, using the fixed externalDomain value unless UseProxyHost is true.
func (app *appContext) ExternalDomain(gc *gin.Context) string { func ExternalDomain(gc *gin.Context) string {
if !app.UseProxyHost || gc.Request.Host == "" { if !UseProxyHost || gc.Request.Host == "" {
return app.externalDomain return externalDomain
} }
return gc.Request.Host return gc.Request.Host
} }
// ExternalDomainNoPort attempts to return app.ExternalDomain() with the port removed. If the internally-used method fails, it is assumed the domain has no port anyway. // ExternalDomainNoPort attempts to return ExternalDomain() with the port removed. If the internally-used method fails, it is assumed the domain has no port anyway.
func (app *appContext) ExternalDomainNoPort(gc *gin.Context) string { func (app *appContext) ExternalDomainNoPort(gc *gin.Context) string {
domain := app.ExternalDomain(gc) domain := ExternalDomain(gc)
host, _, err := net.SplitHostPort(domain) host, _, err := net.SplitHostPort(domain)
if err != nil { if err != nil {
return domain return domain
@@ -96,11 +103,11 @@ func (app *appContext) ExternalDomainNoPort(gc *gin.Context) string {
return host return host
} }
// ExternalURI returns the External URI of jfa-go's root directory (by default, where the admin page is), using the fixed app.externalURI value unless app.UseProxyHost is true and gc is not nil. // ExternalURI returns the External URI of jfa-go's root directory (by default, where the admin page is), using the fixed externalURI value unless UseProxyHost is true and gc is not nil.
// When nil is passed, app.externalURI is returned. // When nil is passed, externalURI is returned.
func (app *appContext) ExternalURI(gc *gin.Context) string { func ExternalURI(gc *gin.Context) string {
if gc == nil { if gc == nil {
return app.externalURI return externalURI
} }
var proto string var proto string
@@ -111,10 +118,10 @@ func (app *appContext) ExternalURI(gc *gin.Context) string {
} }
// app.debug.Printf("Request: %+v\n", gc.Request) // app.debug.Printf("Request: %+v\n", gc.Request)
if app.UseProxyHost && gc.Request.Host != "" { if UseProxyHost && gc.Request.Host != "" {
return proto + gc.Request.Host + PAGES.Base return proto + gc.Request.Host + PAGES.Base
} }
return app.externalURI return externalURI
} }
func (app *appContext) EvaluateRelativePath(gc *gin.Context, path string) string { func (app *appContext) EvaluateRelativePath(gc *gin.Context, path string) string {
@@ -129,177 +136,192 @@ func (app *appContext) EvaluateRelativePath(gc *gin.Context, path string) string
proto = "http://" proto = "http://"
} }
return proto + app.ExternalDomain(gc) + path return proto + ExternalDomain(gc) + path
} }
func (app *appContext) loadConfig() error { // NewConfig reads and patches a config file for use. Passed loggers are used only once. Some dependencies can be reloaded after this is called with ReloadDependents(app).
func NewConfig(configPathOrContents any, dataPath string, logs LoggerSet) (*Config, error) {
var err error var err error
app.config, err = ini.ShadowLoad(app.configPath) config := &Config{}
config.File, err = ini.ShadowLoad(configPathOrContents)
if err != nil { if err != nil {
return err return config, err
} }
// URLs // URLs
app.MustSetURLPath("ui", "url_base", "") config.MustSetURLPath("ui", "url_base", "")
app.MustSetURLPath("url_paths", "admin", "") config.MustSetURLPath("url_paths", "admin", "")
app.MustSetURLPath("url_paths", "user_page", "/my/account") config.MustSetURLPath("url_paths", "user_page", "/my/account")
app.MustSetURLPath("url_paths", "form", "/invite") config.MustSetURLPath("url_paths", "form", "/invite")
PAGES.Base = FormatSubpath(app.config.Section("ui").Key("url_base").String(), true) PAGES.Base = FormatSubpath(config.Section("ui").Key("url_base").String(), true)
PAGES.Admin = FormatSubpath(app.config.Section("url_paths").Key("admin").String(), true) PAGES.Admin = FormatSubpath(config.Section("url_paths").Key("admin").String(), true)
PAGES.MyAccount = FormatSubpath(app.config.Section("url_paths").Key("user_page").String(), true) PAGES.MyAccount = FormatSubpath(config.Section("url_paths").Key("user_page").String(), true)
PAGES.Form = FormatSubpath(app.config.Section("url_paths").Key("form").String(), true) PAGES.Form = FormatSubpath(config.Section("url_paths").Key("form").String(), true)
if !(app.config.Section("user_page").Key("enabled").MustBool(true)) { if !(config.Section("user_page").Key("enabled").MustBool(true)) {
PAGES.MyAccount = "disabled" PAGES.MyAccount = "disabled"
} }
if PAGES.Base == PAGES.Form || PAGES.Base == "/accounts" || PAGES.Base == "/settings" || PAGES.Base == "/activity" { if PAGES.Base == PAGES.Form || PAGES.Base == "/accounts" || PAGES.Base == "/settings" || PAGES.Base == "/activity" {
app.err.Printf(lm.BadURLBase, PAGES.Base) logs.err.Printf(lm.BadURLBase, PAGES.Base)
} }
app.info.Printf(lm.SubpathBlockMessage, PAGES.Base, PAGES.Admin, PAGES.MyAccount, PAGES.Form) logs.info.Printf(lm.SubpathBlockMessage, PAGES.Base, PAGES.Admin, PAGES.MyAccount, PAGES.Form)
app.MustCorrectURL("jellyfin", "server", "") config.MustCorrectURL("jellyfin", "server", "")
app.MustCorrectURL("jellyfin", "public_server", app.config.Section("jellyfin").Key("server").String()) config.MustCorrectURL("jellyfin", "public_server", config.Section("jellyfin").Key("server").String())
app.MustCorrectURL("ui", "redirect_url", app.config.Section("jellyfin").Key("public_server").String()) config.MustCorrectURL("ui", "redirect_url", config.Section("jellyfin").Key("public_server").String())
for _, key := range app.config.Section("files").Keys() { for _, key := range config.Section("files").Keys() {
if name := key.Name(); name != "html_templates" && name != "lang_files" { if name := key.Name(); name != "html_templates" && name != "lang_files" {
key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json")))) key.SetValue(key.MustString(filepath.Join(dataPath, (key.Name() + ".json"))))
} }
} }
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users", "announcements", "custom_user_page_content"} { for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users", "announcements", "custom_user_page_content"} {
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json")))) config.Section("files").Key(key).SetValue(config.Section("files").Key(key).MustString(filepath.Join(dataPath, (key + ".json"))))
} }
for _, key := range []string{"matrix_sql"} { for _, key := range []string{"matrix_sql"} {
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".db")))) config.Section("files").Key(key).SetValue(config.Section("files").Key(key).MustString(filepath.Join(dataPath, (key + ".db"))))
} }
// If true, app.ExternalDomain() will return one based on the reported Host (ideally reported in "Host" or "X-Forwarded-Host" by the reverse proxy), falling back to app.externalDomain if not set. // If true, ExternalDomain() will return one based on the reported Host (ideally reported in "Host" or "X-Forwarded-Host" by the reverse proxy), falling back to externalDomain if not set.
app.UseProxyHost = app.config.Section("ui").Key("use_proxy_host").MustBool(false) UseProxyHost = config.Section("ui").Key("use_proxy_host").MustBool(false)
app.externalURI = strings.TrimSuffix(strings.TrimSuffix(app.config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/") externalURI = strings.TrimSuffix(strings.TrimSuffix(config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/")
if !strings.HasSuffix(app.externalURI, PAGES.Base) { if !strings.HasSuffix(externalURI, PAGES.Base) {
app.err.Println(lm.NoURLSuffix) logs.err.Println(lm.NoURLSuffix)
} }
if app.externalURI == "" { if externalURI == "" {
if app.UseProxyHost { if UseProxyHost {
app.err.Println(lm.NoExternalHost + lm.LoginWontSave + lm.SetExternalHostDespiteUseProxyHost) logs.err.Println(lm.NoExternalHost + lm.LoginWontSave + lm.SetExternalHostDespiteUseProxyHost)
} else { } else {
app.err.Println(lm.NoExternalHost + lm.LoginWontSave) logs.err.Println(lm.NoExternalHost + lm.LoginWontSave)
} }
} }
u, err := url.Parse(app.externalURI) u, err := url.Parse(externalURI)
if err == nil { if err == nil {
app.externalDomain = u.Hostname() externalDomain = u.Hostname()
} }
app.config.Section("email").Key("no_username").SetValue(strconv.FormatBool(app.config.Section("email").Key("no_username").MustBool(false))) config.Section("email").Key("no_username").SetValue(strconv.FormatBool(config.Section("email").Key("no_username").MustBool(false)))
app.MustSetValue("password_resets", "email_html", "jfa-go:"+"email.html") // FIXME: Remove all these, eventually
app.MustSetValue("password_resets", "email_text", "jfa-go:"+"email.txt") // config.MustSetValue("password_resets", "email_html", "jfa-go:"+"email.html")
// config.MustSetValue("password_resets", "email_text", "jfa-go:"+"email.txt")
app.MustSetValue("invite_emails", "email_html", "jfa-go:"+"invite-email.html") // config.MustSetValue("invite_emails", "email_html", "jfa-go:"+"invite-email.html")
app.MustSetValue("invite_emails", "email_text", "jfa-go:"+"invite-email.txt") // config.MustSetValue("invite_emails", "email_text", "jfa-go:"+"invite-email.txt")
app.MustSetValue("email_confirmation", "email_html", "jfa-go:"+"confirmation.html") // config.MustSetValue("email_confirmation", "email_html", "jfa-go:"+"confirmation.html")
app.MustSetValue("email_confirmation", "email_text", "jfa-go:"+"confirmation.txt") // config.MustSetValue("email_confirmation", "email_text", "jfa-go:"+"confirmation.txt")
app.MustSetValue("notifications", "expiry_html", "jfa-go:"+"expired.html") // config.MustSetValue("notifications", "expiry_html", "jfa-go:"+"expired.html")
app.MustSetValue("notifications", "expiry_text", "jfa-go:"+"expired.txt") // config.MustSetValue("notifications", "expiry_text", "jfa-go:"+"expired.txt")
app.MustSetValue("notifications", "created_html", "jfa-go:"+"created.html") // config.MustSetValue("notifications", "created_html", "jfa-go:"+"created.html")
app.MustSetValue("notifications", "created_text", "jfa-go:"+"created.txt") // config.MustSetValue("notifications", "created_text", "jfa-go:"+"created.txt")
app.MustSetValue("deletion", "email_html", "jfa-go:"+"deleted.html") // config.MustSetValue("deletion", "email_html", "jfa-go:"+"deleted.html")
app.MustSetValue("deletion", "email_text", "jfa-go:"+"deleted.txt") // config.MustSetValue("deletion", "email_text", "jfa-go:"+"deleted.txt")
app.MustSetValue("smtp", "hello_hostname", "localhost")
app.MustSetValue("smtp", "cert_validation", "true")
app.MustSetValue("smtp", "auth_type", "4")
app.MustSetValue("smtp", "port", "465")
app.MustSetValue("activity_log", "keep_n_records", "1000")
app.MustSetValue("activity_log", "delete_after_days", "90")
sc := app.config.Section("discord").Key("start_command").MustString("start")
app.config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
// Deletion template is good enough for these as well. // Deletion template is good enough for these as well.
app.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html") // config.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html")
app.MustSetValue("disable_enable", "disabled_text", "jfa-go:"+"deleted.txt") // config.MustSetValue("disable_enable", "disabled_text", "jfa-go:"+"deleted.txt")
app.MustSetValue("disable_enable", "enabled_html", "jfa-go:"+"deleted.html") // config.MustSetValue("disable_enable", "enabled_html", "jfa-go:"+"deleted.html")
app.MustSetValue("disable_enable", "enabled_text", "jfa-go:"+"deleted.txt") // config.MustSetValue("disable_enable", "enabled_text", "jfa-go:"+"deleted.txt")
app.MustSetValue("welcome_email", "email_html", "jfa-go:"+"welcome.html") // config.MustSetValue("welcome_email", "email_html", "jfa-go:"+"welcome.html")
app.MustSetValue("welcome_email", "email_text", "jfa-go:"+"welcome.txt") // config.MustSetValue("welcome_email", "email_text", "jfa-go:"+"welcome.txt")
app.MustSetValue("template_email", "email_html", "jfa-go:"+"template.html") // config.MustSetValue("template_email", "email_html", "jfa-go:"+"template.html")
app.MustSetValue("template_email", "email_text", "jfa-go:"+"template.txt") // config.MustSetValue("template_email", "email_text", "jfa-go:"+"template.txt")
app.MustSetValue("user_expiry", "behaviour", "disable_user") config.MustSetValue("user_expiry", "behaviour", "disable_user")
app.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html") // config.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html")
app.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt") // config.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt")
app.MustSetValue("user_expiry", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html") // config.MustSetValue("user_expiry", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html")
app.MustSetValue("user_expiry", "adjustment_email_text", "jfa-go:"+"expiry-adjusted.txt") // config.MustSetValue("user_expiry", "adjustment_email_text", "jfa-go:"+"expiry-adjusted.txt")
app.MustSetValue("user_expiry", "reminder_email_html", "jfa-go:"+"expiry-reminder.html") // config.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_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") for _, cc := range customContent {
app.MustSetValue("matrix", "show_on_reg", "true") 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") sc := config.Section("discord").Key("start_command").MustString("start")
app.MustSetValue("backups", "path", filepath.Join(app.dataPath, "backups")) config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
app.MustSetValue("backups", "keep_n_backups", "20")
app.MustSetValue("backups", "keep_previous_version_backup", "true")
app.config.Section("jellyfin").Key("version").SetValue(version) config.MustSetValue("email", "collect", "true")
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))
app.MustSetValue("jellyfin", "cache_timeout", "30") config.MustSetValue("matrix", "topic", "Jellyfin notifications")
app.MustSetValue("jellyfin", "web_cache_async_timeout", "1") config.MustSetValue("matrix", "show_on_reg", "true")
app.MustSetValue("jellyfin", "web_cache_sync_timeout", "10")
LOGIP = app.config.Section("advanced").Key("log_ips").MustBool(false) config.MustSetValue("discord", "show_on_reg", "true")
LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false)
app.MustSetValue("advanced", "auth_retry_count", "6") config.MustSetValue("telegram", "show_on_reg", "true")
app.MustSetValue("advanced", "auth_retry_gap", "10")
app.MustSetValue("ui", "port", "8056") config.MustSetValue("backups", "every_n_minutes", "1440")
app.MustSetValue("advanced", "tls_port", "8057") 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"} pwrMethods := []string{"allow_pwr_username", "allow_pwr_email", "allow_pwr_contact_method"}
allDisabled := true allDisabled := true
for _, v := range pwrMethods { for _, v := range pwrMethods {
if app.config.Section("user_page").Key(v).MustBool(true) { if config.Section("user_page").Key(v).MustBool(true) {
allDisabled = false allDisabled = false
} }
} }
if allDisabled { if allDisabled {
app.info.Println(lm.EnableAllPWRMethods) logs.info.Println(lm.EnableAllPWRMethods)
for _, v := range pwrMethods { for _, v := range pwrMethods {
app.config.Section("user_page").Key(v).SetValue("true") config.Section("user_page").Key(v).SetValue("true")
} }
} }
messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false) messagesEnabled = config.Section("messages").Key("enabled").MustBool(false)
telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false) telegramEnabled = config.Section("telegram").Key("enabled").MustBool(false)
discordEnabled = app.config.Section("discord").Key("enabled").MustBool(false) discordEnabled = config.Section("discord").Key("enabled").MustBool(false)
matrixEnabled = app.config.Section("matrix").Key("enabled").MustBool(false) matrixEnabled = config.Section("matrix").Key("enabled").MustBool(false)
if !messagesEnabled { if !messagesEnabled {
emailEnabled = false emailEnabled = false
telegramEnabled = false telegramEnabled = false
discordEnabled = false discordEnabled = false
matrixEnabled = false matrixEnabled = false
} else if app.config.Section("email").Key("method").MustString("") == "" { } else if config.Section("email").Key("method").MustString("") == "" {
emailEnabled = false emailEnabled = false
} else { } else {
emailEnabled = true emailEnabled = true
@@ -308,31 +330,64 @@ func (app *appContext) loadConfig() error {
messagesEnabled = false messagesEnabled = false
} }
if app.proxyEnabled = app.config.Section("advanced").Key("proxy").MustBool(false); app.proxyEnabled { if proxyEnabled := config.Section("advanced").Key("proxy").MustBool(false); proxyEnabled {
app.proxyConfig = easyproxy.ProxyConfig{} config.proxyConfig = &easyproxy.ProxyConfig{}
app.proxyConfig.Protocol = easyproxy.HTTP config.proxyConfig.Protocol = easyproxy.HTTP
if strings.Contains(app.config.Section("advanced").Key("proxy_protocol").MustString("http"), "socks") { if strings.Contains(config.Section("advanced").Key("proxy_protocol").MustString("http"), "socks") {
app.proxyConfig.Protocol = easyproxy.SOCKS5 config.proxyConfig.Protocol = easyproxy.SOCKS5
} }
app.proxyConfig.Addr = app.config.Section("advanced").Key("proxy_address").MustString("") config.proxyConfig.Addr = config.Section("advanced").Key("proxy_address").MustString("")
app.proxyConfig.User = app.config.Section("advanced").Key("proxy_user").MustString("") config.proxyConfig.User = config.Section("advanced").Key("proxy_user").MustString("")
app.proxyConfig.Password = app.config.Section("advanced").Key("proxy_password").MustString("") config.proxyConfig.Password = config.Section("advanced").Key("proxy_password").MustString("")
app.proxyTransport, err = easyproxy.NewTransport(app.proxyConfig) config.proxyTransport, err = easyproxy.NewTransport(*(config.proxyConfig))
if err != nil { if err != nil {
app.err.Printf(lm.FailedInitProxy, app.proxyConfig.Addr, err) logs.err.Printf(lm.FailedInitProxy, config.proxyConfig.Addr, err)
// As explained in lm.FailedInitProxy, sleep here might grab the admin's attention, // As explained in lm.FailedInitProxy, sleep here might grab the admin's attention,
// Since we don't crash on this failing. // Since we don't crash on this failing.
time.Sleep(15 * time.Second) time.Sleep(15 * time.Second)
app.proxyEnabled = false config.proxyConfig = nil
config.proxyTransport = nil
} else { } else {
app.proxyEnabled = true logs.info.Printf(lm.InitProxy, config.proxyConfig.Addr)
app.info.Printf(lm.InitProxy, app.proxyConfig.Addr)
} }
} }
app.MustSetValue("updates", "enabled", "true") config.MustSetValue("updates", "enabled", "true")
releaseChannel := app.config.Section("updates").Key("channel").String()
if app.config.Section("updates").Key("enabled").MustBool(false) { substituteStrings = config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("")
if substituteStrings != "" {
v := config.Section("ui").Key("success_message")
v.SetValue(strings.ReplaceAll(v.String(), "Jellyfin", substituteStrings))
}
datePattern = config.Section("messages").Key("date_format").String()
timePattern = `%H:%M`
if !(config.Section("messages").Key("use_24h").MustBool(true)) {
timePattern = `%I:%M %p`
}
return config, nil
}
// ReloadDependents re-initialises or applies changes to components of the app which can be reconfigured without restarting.
func (config *Config) ReloadDependents(app *appContext) {
oldFormLang := config.Section("ui").Key("language").MustString("")
if oldFormLang != "" {
app.storage.lang.chosenUserLang = oldFormLang
}
newFormLang := config.Section("ui").Key("language-form").MustString("")
if newFormLang != "" {
app.storage.lang.chosenUserLang = newFormLang
}
app.storage.lang.chosenAdminLang = config.Section("ui").Key("language-admin").MustString("en-us")
app.storage.lang.chosenEmailLang = config.Section("email").Key("language").MustString("en-us")
app.storage.lang.chosenPWRLang = config.Section("password_resets").Key("language").MustString("en-us")
app.storage.lang.chosenTelegramLang = config.Section("telegram").Key("language").MustString("en-us")
releaseChannel := config.Section("updates").Key("channel").String()
if config.Section("updates").Key("enabled").MustBool(false) {
v := version v := version
if releaseChannel == "stable" { if releaseChannel == "stable" {
if version == "git" { if version == "git" {
@@ -341,9 +396,9 @@ func (app *appContext) loadConfig() error {
} else if releaseChannel == "unstable" { } else if releaseChannel == "unstable" {
v = "git" v = "git"
} }
app.updater = newUpdater(baseURL, namespace, repo, v, commit, updater) app.updater = NewUpdater(baseURL, namespace, repo, v, commit, updater)
if app.proxyEnabled { if config.proxyTransport != nil {
app.updater.SetTransport(app.proxyTransport) app.updater.SetTransport(config.proxyTransport)
} }
} }
if releaseChannel == "" { if releaseChannel == "" {
@@ -352,32 +407,21 @@ func (app *appContext) loadConfig() error {
} else { } else {
releaseChannel = "stable" releaseChannel = "stable"
} }
app.MustSetValue("updates", "channel", releaseChannel) config.MustSetValue("updates", "channel", releaseChannel)
} }
substituteStrings = app.config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("") app.email = NewEmailer(config, app.storage, app.LoggerSet)
}
if substituteStrings != "" { func (app *appContext) ReloadConfig() {
v := app.config.Section("ui").Key("success_message") var err error = nil
v.SetValue(strings.ReplaceAll(v.String(), "Jellyfin", substituteStrings)) 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("") app.config.ReloadDependents(app)
if oldFormLang != "" { app.info.Printf(lm.LoadConfig, app.configPath)
app.storage.lang.chosenUserLang = oldFormLang
}
newFormLang := app.config.Section("ui").Key("language-form").MustString("")
if newFormLang != "" {
app.storage.lang.chosenUserLang = newFormLang
}
app.storage.lang.chosenAdminLang = app.config.Section("ui").Key("language-admin").MustString("en-us")
app.storage.lang.chosenEmailLang = app.config.Section("email").Key("language").MustString("en-us")
app.storage.lang.chosenPWRLang = app.config.Section("password_resets").Key("language").MustString("en-us")
app.storage.lang.chosenTelegramLang = app.config.Section("telegram").Key("language").MustString("en-us")
app.email = NewEmailer(app)
return nil
} }
func (app *appContext) PatchConfigBase() { func (app *appContext) PatchConfigBase() {

372
customcontent.go Normal file
View 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
}()

View File

@@ -735,7 +735,7 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
var msg *Message var msg *Message
if err == nil { if err == nil {
msg, err = d.app.email.constructInvite(invite.Code, invite, d.app, false) msg, err = d.app.email.constructInvite(invite, false)
if err != nil { if err != nil {
// Print extra message, ideally we'd just print this, or get rid of it though. // Print extra message, ideally we'd just print this, or get rid of it though.
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, invite.Code, err) invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, invite.Code, err)

898
email.go

File diff suppressed because it is too large Load Diff

490
email_test.go Normal file
View 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)
}
}
})
})
}

View File

@@ -4,7 +4,6 @@
package main package main
import ( import (
"io/fs"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
@@ -15,9 +14,6 @@ const binaryType = "external"
func BuildTagsExternal() { buildTags = append(buildTags, "external") } func BuildTagsExternal() { buildTags = append(buildTags, "external") }
var localFS dirFS
var langFS dirFS
// When using os.DirFS, even on Windows the separator seems to be '/'. // When using os.DirFS, even on Windows the separator seems to be '/'.
// func FSJoin(elem ...string) string { return filepath.Join(elem...) } // func FSJoin(elem ...string) string { return filepath.Join(elem...) }
func FSJoin(elem ...string) string { func FSJoin(elem ...string) string {
@@ -32,20 +28,6 @@ func FSJoin(elem ...string) string {
return strings.TrimSuffix(path, sep) 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() { func loadFilesystems() {
log.Println("Using external storage") log.Println("Using external storage")
executable, _ := os.Executable() executable, _ := os.Executable()

29
fs.go Normal file
View File

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

View File

@@ -19,9 +19,6 @@ var loFS embed.FS
//go:embed lang/common lang/admin lang/email lang/form lang/setup lang/pwreset lang/telegram //go:embed lang/common lang/admin lang/email lang/form lang/setup lang/pwreset lang/telegram
var laFS embed.FS var laFS embed.FS
var langFS rewriteFS
var localFS rewriteFS
type rewriteFS struct { type rewriteFS struct {
fs embed.FS fs embed.FS
prefix string prefix string

10
lang.go
View File

@@ -1,6 +1,10 @@
package main package main
import "github.com/hrfee/jfa-go/common" import (
"fmt"
"github.com/hrfee/jfa-go/common"
)
type langMeta struct { type langMeta struct {
Name string `json:"name"` Name string `json:"name"`
@@ -166,7 +170,7 @@ func (ts *telegramLangs) getOptions() []common.Option {
} }
type langSection map[string]string type langSection map[string]string
type tmpl map[string]string type tmpl = map[string]any
func templateString(text string, vals tmpl) string { func templateString(text string, vals tmpl) string {
start, previousEnd := -1, -1 start, previousEnd := -1, -1
@@ -183,7 +187,7 @@ func templateString(text string, vals tmpl) string {
start = -1 start = -1
continue continue
} }
out += text[previousEnd+1:start] + val out += text[previousEnd+1:start] + fmt.Sprint(val)
previousEnd = i previousEnd = i
start = -1 start = -1
} }

View File

@@ -21,7 +21,7 @@ import (
// } // }
type Logger struct { type Logger struct {
empty bool Empty bool
logger *log.Logger logger *log.Logger
shortfile bool shortfile bool
printer *c.Color printer *c.Color
@@ -75,13 +75,13 @@ func NewLogger(out io.Writer, prefix string, flag int, color c.Attribute) (l *Lo
func NewEmptyLogger() (l *Logger) { func NewEmptyLogger() (l *Logger) {
l = &Logger{ l = &Logger{
empty: true, Empty: true,
} }
return return
} }
func (l *Logger) Printf(format string, v ...interface{}) { func (l *Logger) Printf(format string, v ...interface{}) {
if l.empty { if l.Empty {
return return
} }
var out string var out string
@@ -93,7 +93,7 @@ func (l *Logger) Printf(format string, v ...interface{}) {
} }
func (l *Logger) PrintfCustomLevel(level int, format string, v ...interface{}) { func (l *Logger) PrintfCustomLevel(level int, format string, v ...interface{}) {
if l.empty { if l.Empty {
return return
} }
var out string var out string
@@ -105,14 +105,14 @@ func (l *Logger) PrintfCustomLevel(level int, format string, v ...interface{}) {
} }
func (l *Logger) PrintfNoFile(format string, v ...interface{}) { func (l *Logger) PrintfNoFile(format string, v ...interface{}) {
if l.empty { if l.Empty {
return return
} }
l.logger.Print(l.printer.Sprintf(format, v...)) l.logger.Print(l.printer.Sprintf(format, v...))
} }
func (l *Logger) Print(v ...interface{}) { func (l *Logger) Print(v ...interface{}) {
if l.empty { if l.Empty {
return return
} }
var out string var out string
@@ -124,7 +124,7 @@ func (l *Logger) Print(v ...interface{}) {
} }
func (l *Logger) Println(v ...interface{}) { func (l *Logger) Println(v ...interface{}) {
if l.empty { if l.Empty {
return return
} }
var out string var out string
@@ -136,7 +136,7 @@ func (l *Logger) Println(v ...interface{}) {
} }
func (l *Logger) Fatal(v ...interface{}) { func (l *Logger) Fatal(v ...interface{}) {
if l.empty { if l.Empty {
return return
} }
var out string var out string
@@ -148,7 +148,7 @@ func (l *Logger) Fatal(v ...interface{}) {
} }
func (l *Logger) Fatalf(format string, v ...interface{}) { func (l *Logger) Fatalf(format string, v ...interface{}) {
if l.empty { if l.Empty {
return return
} }
var out string var out string

121
main.go
View File

@@ -24,7 +24,6 @@ import (
"github.com/fatih/color" "github.com/fatih/color"
"github.com/hrfee/jfa-go/common" "github.com/hrfee/jfa-go/common"
_ "github.com/hrfee/jfa-go/docs" _ "github.com/hrfee/jfa-go/docs"
"github.com/hrfee/jfa-go/easyproxy"
"github.com/hrfee/jfa-go/jellyseerr" "github.com/hrfee/jfa-go/jellyseerr"
"github.com/hrfee/jfa-go/logger" "github.com/hrfee/jfa-go/logger"
lm "github.com/hrfee/jfa-go/logmessages" lm "github.com/hrfee/jfa-go/logmessages"
@@ -81,6 +80,11 @@ var serverTypes = map[string]string{
var serverType = mediabrowser.JellyfinServer var serverType = mediabrowser.JellyfinServer
var substituteStrings = "" 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. // User is used for auth purposes.
type User struct { type User struct {
UserID string `json:"id"` UserID string `json:"id"`
@@ -88,10 +92,15 @@ type User struct {
Password string `json:"password"` 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. // contains (almost) everything the application needs, essentially. This was a dumb design decision imo.
type appContext struct { type appContext struct {
// defaults *Config // defaults *Config
config *ini.File config *Config
configPath string configPath string
configBasePath string configBasePath string
configBase common.Config configBase common.Config
@@ -103,39 +112,32 @@ type appContext struct {
adminUsers []User adminUsers []User
invalidTokens []string invalidTokens []string
// Keeping jf name because I can't think of a better one // Keeping jf name because I can't think of a better one
jf *mediabrowser.MediaBrowser jf *mediabrowser.MediaBrowser
authJf *mediabrowser.MediaBrowser authJf *mediabrowser.MediaBrowser
ombi *OmbiWrapper ombi *OmbiWrapper
js *JellyseerrWrapper js *JellyseerrWrapper
thirdPartyServices []ThirdPartyService thirdPartyServices []ThirdPartyService
datePattern string storage *Storage
timePattern string validator Validator
storage Storage email *Emailer
validator Validator telegram *TelegramDaemon
email *Emailer discord *DiscordDaemon
telegram *TelegramDaemon matrix *MatrixDaemon
discord *DiscordDaemon contactMethods []ContactMethodLinker
matrix *MatrixDaemon LoggerSet
contactMethods []ContactMethodLinker host string
info, debug, err *logger.Logger port int
host string version string
port int updater *Updater
version string webhooks *WebhookSender
externalURI, externalDomain string // The latter lower-case as should be accessed through app.ExternalDomain() newUpdate bool // Whether whatever's in update is new.
UseProxyHost bool tag Tag
updater *Updater update Update
webhooks *WebhookSender internalPWRs map[string]InternalPWR
newUpdate bool // Whether whatever's in update is new. pwrCaptchas map[string]Captcha
tag Tag ConfirmationKeys map[string]map[string]ConfirmationKey // Map of invite code to jwt to request
update Update confirmationKeysLock sync.Mutex
proxyEnabled bool userCache *UserCache
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
} }
func generateSecret(length int) (string, error) { func generateSecret(length int) (string, error) {
@@ -244,7 +246,9 @@ func start(asDaemon, firstCall bool) {
var debugMode bool var debugMode bool
var address string 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.err.Fatalf(lm.FailedLoadConfig, app.configPath, err)
} }
app.info.Printf(lm.LoadConfig, app.configPath) app.info.Printf(lm.LoadConfig, app.configPath)
@@ -262,12 +266,8 @@ func start(asDaemon, firstCall bool) {
} }
if debugMode { if debugMode {
app.debug = logger.NewLogger(os.Stdout, "[DEBUG] ", log.Ltime|log.Lshortfile, color.FgYellow) 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 { } else {
app.debug = logger.NewEmptyLogger() app.debug = logger.NewEmptyLogger()
app.storage.debug = nil
} }
if *PPROF { if *PPROF {
app.info.Print(warning("\n\nWARNING: Don't use pprof in production.\n\n")) 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" dbPath := filepath.Join(app.dataPath, "db")
app.storage.lang.UserPath = "form" if debugMode {
app.storage.lang.AdminPath = "admin" app.storage = NewStorage(dbPath, app.debug, generateLogActions(app.config))
app.storage.lang.EmailPath = "email" } else {
app.storage.lang.TelegramPath = "telegram" app.storage = NewStorage(dbPath, app.debug, nil)
app.storage.lang.PasswordResetPath = "pwreset" }
// Placed here, since storage.chosenXLang is set by this function.
app.config.ReloadDependents(app)
externalLang := app.config.Section("files").Key("lang_files").MustString("") externalLang := app.config.Section("files").Key("lang_files").MustString("")
var err error
if externalLang == "" { if externalLang == "" {
err = app.storage.loadLang(langFS) err = app.storage.loadLang(langFS)
} else { } else {
@@ -362,7 +365,7 @@ func start(asDaemon, firstCall bool) {
} }
address = fmt.Sprintf("%s:%d", app.host, app.port) 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! // but in future it might (like app.contactMethods does), so append to the end!
if app.config.Section("ombi").Key("enabled").MustBool(false) { if app.config.Section("ombi").Key("enabled").MustBool(false) {
app.ombi = &OmbiWrapper{} app.ombi = &OmbiWrapper{}
@@ -391,10 +394,12 @@ func start(asDaemon, firstCall bool) {
} }
app.storage.db_path = filepath.Join(app.dataPath, "db")
app.loadPendingBackup() app.loadPendingBackup()
app.ConnectDB() if err := app.storage.Connect(app.config); err != nil {
defer app.storage.db.Close() 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. // copy it to app.patchedConfig, and patch in settings from app.config, and language stuff.
app.PatchConfigBase() 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()), 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. // Email also handles its own proxying, as (SMTP atleast) doesn't use a HTTP transport.
app.email = NewEmailer(app) app.email = NewEmailer(app.config, app.storage, app.LoggerSet)
app.loadStrftime()
var validatorConf ValidatorConf var validatorConf ValidatorConf
@@ -579,13 +583,13 @@ func start(asDaemon, firstCall bool) {
) )
// Updater proxy set in config.go, don't worry! // Updater proxy set in config.go, don't worry!
if app.proxyEnabled { if app.config.proxyConfig != nil {
app.jf.SetTransport(app.proxyTransport) app.jf.SetTransport(app.config.proxyTransport)
for _, c := range app.thirdPartyServices { for _, c := range app.thirdPartyServices {
c.SetTransport(app.proxyTransport) c.SetTransport(app.config.proxyTransport)
} }
for _, c := range app.contactMethods { for _, c := range app.contactMethods {
c.SetTransport(app.proxyTransport) c.SetTransport(app.config.proxyTransport)
} }
} }
} else { } else {
@@ -601,7 +605,6 @@ func start(asDaemon, firstCall bool) {
app.host = "0.0.0.0" app.host = "0.0.0.0"
} }
address = fmt.Sprintf("%s:%d", app.host, app.port) address = fmt.Sprintf("%s:%d", app.host, app.port)
app.storage.lang.SetupPath = "setup"
err := app.storage.loadLangSetup(langFS) err := app.storage.loadLangSetup(langFS)
if err != nil { if err != nil {
app.info.Fatalf(lm.FailedLangLoad, err) app.info.Fatalf(lm.FailedLangLoad, err)

View File

@@ -81,7 +81,7 @@ func migrateEmailConfig(app *appContext) {
app.err.Fatalf("Failed to save config: %v", err) app.err.Fatalf("Failed to save config: %v", err)
return return
} }
app.loadConfig() app.ReloadConfig()
} }
// Migrate pre-0.3.6 email settings to the new messages section. // 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.customEmails_path = app.config.Section("files").Key("custom_emails").String()
app.storage.loadCustomEmails() 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) { 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.userPage_path = app.config.Section("files").Key("custom_user_page_content").String()
app.storage.loadUserPageContent() app.storage.loadUserPageContent()

View File

@@ -29,8 +29,8 @@ func (app *appContext) GenInternalReset(userID string) (InternalPWR, error) {
} }
// GenResetLink generates and returns a password reset link. // GenResetLink generates and returns a password reset link.
func (app *appContext) GenResetLink(pin string) (string, error) { func GenResetLink(pin string) (string, error) {
url := app.ExternalURI(nil) url := ExternalURI(nil)
var pinLink string var pinLink string
if url == "" { if url == "" {
return pinLink, errors.New(lm.NoExternalHost) return pinLink, errors.New(lm.NoExternalHost)
@@ -104,7 +104,7 @@ func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
uid := user.ID uid := user.ID
name := app.getAddressOrName(uid) name := app.getAddressOrName(uid)
if name != "" { if name != "" {
msg, err := app.email.constructReset(pwr, app, false) msg, err := app.email.constructReset(pwr, false)
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err) app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)

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

View File

@@ -15,10 +15,8 @@ import (
"github.com/hrfee/jfa-go/common" "github.com/hrfee/jfa-go/common"
"github.com/hrfee/jfa-go/jellyseerr" "github.com/hrfee/jfa-go/jellyseerr"
"github.com/hrfee/jfa-go/logger" "github.com/hrfee/jfa-go/logger"
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser" "github.com/hrfee/mediabrowser"
"github.com/timshannon/badgerhold/v4" "github.com/timshannon/badgerhold/v4"
"gopkg.in/ini.v1"
) )
type discordStore map[string]DiscordUser type discordStore map[string]DiscordUser
@@ -80,7 +78,7 @@ const (
type Storage struct { type Storage struct {
debug *logger.Logger debug *logger.Logger
logActions map[string]DebugLogAction logActions func(k string) DebugLogAction
timePattern string timePattern string
@@ -104,6 +102,44 @@ type Storage struct {
lang Lang 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 type StoreType int
// Used for debug logging of storage. // Used for debug logging of storage.
@@ -146,7 +182,7 @@ func (st *Storage) DebugWatch(storeType StoreType, key, mainData string) {
actionKey = "custom_content" actionKey = "custom_content"
} }
logAction := st.logActions[actionKey] logAction := st.logActions(actionKey)
if logAction == NoLog { if logAction == NoLog {
return 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{} m := map[string]DebugLogAction{}
for _, v := range []string{"emails", "discord", "telegram", "matrix", "invites", "announcements", "expirires", "profiles", "custom_content"} { for _, v := range []string{"emails", "discord", "telegram", "matrix", "invites", "announcements", "expirires", "profiles", "custom_content"} {
switch c.Section("advanced").Key("debug_log_" + v).MustString("none") { 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 m[v] = LogDeletion
} }
} }
return m return func(k string) DebugLogAction { return m[k] }
}
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)
} }
// GetEmails returns a copy of the store. // GetEmails returns a copy of the store.
@@ -683,13 +705,34 @@ type customEmails struct {
ExpiryReminder CustomContent `json:"expiryReminder"` 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. // CustomContent stores customized versions of jfa-go content, including emails and user messages.
type CustomContent struct { type CustomContent struct {
Name string `json:"name" badgerhold:"key"` Name string `json:"name" badgerhold:"key"`
Enabled bool `json:"enabled,omitempty"` Enabled bool `json:"enabled,omitempty"`
Content string `json:"content"` Content string `json:"content"`
Variables []string `json:"variables,omitempty"`
Conditionals []string `json:"conditionals,omitempty"`
} }
type userPageContent struct { type userPageContent struct {

View File

@@ -130,7 +130,7 @@ type Updater struct {
binary string 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) // fmt.Printf(`Updater intializing with "%s", "%s", "%s", "%s", "%s", "%s"\n`, buildroneURL, namespace, repo, version, commit, buildType)
bType := off bType := off
tag := "" tag := ""

View File

@@ -81,7 +81,7 @@ func (app *appContext) checkUsers(remindBeforeExpiry *DayTimerSet) {
if name == "" { if name == "" {
continue 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 { if err != nil {
app.err.Printf(lm.FailedConstructExpiryReminderMessage, user.ID, err) app.err.Printf(lm.FailedConstructExpiryReminderMessage, user.ID, err)
} else if err := app.sendByID(msg, user.ID); err != nil { } else if err := app.sendByID(msg, user.ID); err != nil {
@@ -173,7 +173,7 @@ func (app *appContext) checkUsers(remindBeforeExpiry *DayTimerSet) {
if name == "" { if name == "" {
continue continue
} }
msg, err := app.email.constructUserExpired(app, false) msg, err := app.email.constructUserExpired(false)
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructExpiryMessage, user.ID, err) app.err.Printf(lm.FailedConstructExpiryMessage, user.ID, err)
} else if err := app.sendByID(msg, user.ID); err != nil { } else if err := app.sendByID(msg, user.ID); err != nil {

View File

@@ -169,7 +169,7 @@ func (app *appContext) WelcomeNewUser(user mediabrowser.User, expiry time.Time)
if name == "" { if name == "" {
return return
} }
msg, err := app.email.constructWelcome(user.Name, expiry, app, false) msg, err := app.email.constructWelcome(user.Name, expiry, false)
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructWelcomeMessage, user.ID, err) app.err.Printf(lm.FailedConstructWelcomeMessage, user.ID, err)
} else if err := app.sendByID(msg, user.ID); err != nil { } else if err := app.sendByID(msg, user.ID); err != nil {

View File

@@ -88,7 +88,7 @@ func (app *appContext) BasePageTemplateValues(gc *gin.Context, page Page, base g
pages := PagePathsDTO{ pages := PagePathsDTO{
PagePaths: PAGES, PagePaths: PAGES,
ExternalURI: app.ExternalURI(gc), ExternalURI: ExternalURI(gc),
TrueBase: PAGES.Base, TrueBase: PAGES.Base,
} }
pages.Base = app.getURLBase(gc) 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) discord := discordEnabled && app.config.Section("discord").Key("show_on_reg").MustBool(true)
matrix := matrixEnabled && app.config.Section("matrix").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 := "" fromUser := ""
if invite.ReferrerJellyfinID != "" { if invite.ReferrerJellyfinID != "" {
@@ -810,14 +810,15 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
data["discordInviteLink"] = app.discord.InviteChannel.Name != "" data["discordInviteLink"] = app.discord.InviteChannel.Name != ""
} }
if msg, ok := app.storage.GetCustomContentKey("PostSignupCard"); ok && msg.Enabled { if msg, ok := app.storage.GetCustomContentKey("PostSignupCard"); ok && msg.Enabled {
cci := customContent["PostSignupCard"]
data["customSuccessCard"] = true data["customSuccessCard"] = true
// We don't template here, since the username is only known after login. // We don't template here, since the username is only known after login.
templated, err := templateEmail( templated, err := templateEmail(
msg.Content, msg.Content,
msg.Variables, cci.Variables,
msg.Conditionals, cci.Conditionals,
map[string]any{ map[string]any{
"username": "{username}", "username": "{username}", // Value is subbed by webpage
"myAccountURL": userPageAddress, "myAccountURL": userPageAddress,
}, },
) )