diff --git a/Makefile b/Makefile
index 479b101..5cc7019 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: configuration email typescript swagger copy compile compress inline-css variants-html install clean npm config-description config-default precompile
+.PHONY: configuration email typescript swagger copy compile compress inline-css variants-html install clean npm config-description config-default precompile test
.DEFAULT_GOAL := all
GOESBUILD ?= off
@@ -216,13 +216,16 @@ ifeq ($(INTERNAL), on)
endif
GO_SRC = $(shell find ./ -name "*.go")
-GO_TARGET = build/jfa-go
+GO_TARGET = build/jfa-go
$(GO_TARGET): $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
$(info Downloading deps)
$(GOBINARY) mod download
$(info Building)
mkdir -p build
- $(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o $(GO_TARGET)
+ $(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o $(GO_TARGET)
+
+test: $(BUILDDEPS) $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
+ $(GOBINARY) test -ldflags="$(LDFLAGS)" $(TAGS) -p 1
all: $(BUILDDEPS) $(GO_TARGET)
diff --git a/api-invites.go b/api-invites.go
index 51e2236..1ecfd47 100644
--- a/api-invites.go
+++ b/api-invites.go
@@ -135,7 +135,7 @@ func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup
wait.Add(1)
go func(addr string) {
defer wait.Done()
- msg, err := app.email.constructExpiry(data.Code, data, app, false)
+ msg, err := app.email.constructExpiry(data, false)
if err != nil {
app.err.Printf(lm.FailedConstructExpiryAdmin, data.Code, err)
} else {
@@ -218,7 +218,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
invite.SendTo = req.SendTo
}
if addressValid {
- msg, err := app.email.constructInvite(invite.Code, invite, app, false)
+ msg, err := app.email.constructInvite(invite, false)
if err != nil {
// Slight misuse of the template
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, req.SendTo, err)
@@ -343,7 +343,7 @@ func (app *appContext) GetInvites(gc *gin.Context) {
// These used to be stored formatted instead of as a unix timestamp.
unix, err := strconv.ParseInt(pair[1], 10, 64)
if err != nil {
- date, err := timefmt.Parse(pair[1], app.datePattern+" "+app.timePattern)
+ date, err := timefmt.Parse(pair[1], datePattern+" "+timePattern)
if err != nil {
app.err.Printf(lm.FailedParseTime, err)
}
diff --git a/api-messages.go b/api-messages.go
index b06cc0f..82dbd8d 100644
--- a/api-messages.go
+++ b/api-messages.go
@@ -1,7 +1,6 @@
package main
import (
- "strings"
"time"
"github.com/gin-gonic/gin"
@@ -23,26 +22,16 @@ func (app *appContext) GetCustomContent(gc *gin.Context) {
if _, ok := app.storage.lang.Email[lang]; !ok {
lang = app.storage.lang.chosenEmailLang
}
- adminLang := lang
- if _, ok := app.storage.lang.Admin[lang]; !ok {
- adminLang = app.storage.lang.chosenAdminLang
- }
- list := emailListDTO{
- "UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.MustGetCustomContentKey("UserCreated").Enabled},
- "InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.MustGetCustomContentKey("InviteExpiry").Enabled},
- "PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.MustGetCustomContentKey("PasswordReset").Enabled},
- "UserDeleted": {Name: app.storage.lang.Email[lang].UserDeleted["name"], Enabled: app.storage.MustGetCustomContentKey("UserDeleted").Enabled},
- "UserDisabled": {Name: app.storage.lang.Email[lang].UserDisabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserDisabled").Enabled},
- "UserEnabled": {Name: app.storage.lang.Email[lang].UserEnabled["name"], Enabled: app.storage.MustGetCustomContentKey("UserEnabled").Enabled},
- "UserExpiryAdjusted": {Name: app.storage.lang.Email[lang].UserExpiryAdjusted["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpiryAdjusted").Enabled},
- "InviteEmail": {Name: app.storage.lang.Email[lang].InviteEmail["name"], Enabled: app.storage.MustGetCustomContentKey("InviteEmail").Enabled},
- "WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.MustGetCustomContentKey("WelcomeEmail").Enabled},
- "EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.MustGetCustomContentKey("EmailConfirmation").Enabled},
- "UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpired").Enabled},
- "ExpiryReminder": {Name: app.storage.lang.Email[lang].ExpiryReminder["name"], Enabled: app.storage.MustGetCustomContentKey("ExpiryReminder").Enabled},
- "UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.MustGetCustomContentKey("UserLogin").Enabled},
- "UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("UserPage").Enabled},
- "PostSignupCard": {Name: app.storage.lang.Admin[adminLang].Strings["postSignupCard"], Enabled: app.storage.MustGetCustomContentKey("PostSignupCard").Enabled, Description: app.storage.lang.Admin[adminLang].Strings["postSignupCardDescription"]},
+ list := emailListDTO{}
+ for _, cc := range customContent {
+ if cc.ContentType == CustomTemplate {
+ continue
+ }
+ ccDescription := emailListEl{Name: cc.DisplayName(&app.storage.lang, lang), Enabled: app.storage.MustGetCustomContentKey(cc.Name).Enabled}
+ if cc.Description != nil {
+ ccDescription.Description = cc.Description(&app.storage.lang, lang)
+ }
+ list[cc.Name] = ccDescription
}
filter := gc.Query("filter")
@@ -74,11 +63,12 @@ func (app *appContext) SetCustomMessage(gc *gin.Context) {
respondBool(400, false, gc)
return
}
- message, ok := app.storage.GetCustomContentKey(id)
+ _, ok := customContent[id]
if !ok {
respondBool(400, false, gc)
return
}
+ message, ok := app.storage.GetCustomContentKey(id)
message.Content = req.Content
message.Enabled = true
app.storage.SetCustomContentKey(id, message)
@@ -124,151 +114,92 @@ func (app *appContext) SetCustomMessageState(gc *gin.Context) {
// @Security Bearer
// @tags Configuration
func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
- lang := app.storage.lang.chosenEmailLang
id := gc.Param("id")
- var content string
var err error
- var msg *Message
- var variables []string
- var conditionals []string
- var values map[string]interface{}
- username := app.storage.lang.Email[lang].Strings.get("username")
- emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress")
- customMessage, ok := app.storage.GetCustomContentKey(id)
+ contentInfo, ok := customContent[id]
+ // FIXME: Add announcement to customContent
if !ok && id != "Announcement" {
app.err.Printf(lm.FailedGetCustomMessage, id)
respondBool(400, false, gc)
return
}
- if id == "WelcomeEmail" {
- conditionals = []string{"{yourAccountWillExpire}"}
- customMessage.Conditionals = conditionals
- } else if id == "UserPage" {
- variables = []string{"{username}"}
- customMessage.Variables = variables
- } else if id == "UserLogin" {
- variables = []string{}
- customMessage.Variables = variables
- } else if id == "PostSignupCard" {
- variables = []string{"{username}", "{myAccountURL}"}
- customMessage.Variables = variables
+
+ content, ok := app.storage.GetCustomContentKey(id)
+
+ if contentInfo.Variables == nil {
+ contentInfo.Variables = []string{}
+ }
+ if contentInfo.Conditionals == nil {
+ contentInfo.Conditionals = []string{}
+ }
+ if contentInfo.Placeholders == nil {
+ contentInfo.Placeholders = map[string]any{}
}
- content = customMessage.Content
- noContent := content == ""
- if !noContent {
- variables = customMessage.Variables
+ // Generate content from real email, if the user hasn't already customised this message.
+ if content.Content == "" {
+ var msg *Message
+ switch id {
+ // FIXME: Add announcement to customContent
+ case "UserCreated":
+ msg, err = app.email.constructCreated("", "", time.Time{}, Invite{}, true)
+ case "InviteExpiry":
+ msg, err = app.email.constructExpiry(Invite{}, true)
+ case "PasswordReset":
+ msg, err = app.email.constructReset(PasswordReset{}, true)
+ case "UserDeleted":
+ msg, err = app.email.constructDeleted("", true)
+ case "UserDisabled":
+ msg, err = app.email.constructDisabled("", true)
+ case "UserEnabled":
+ msg, err = app.email.constructEnabled("", true)
+ case "UserExpiryAdjusted":
+ msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", true)
+ case "ExpiryReminder":
+ msg, err = app.email.constructExpiryReminder("", time.Now().AddDate(0, 0, 3), true)
+ case "InviteEmail":
+ msg, err = app.email.constructInvite(Invite{Code: ""}, true)
+ case "WelcomeEmail":
+ msg, err = app.email.constructWelcome("", time.Time{}, true)
+ case "EmailConfirmation":
+ msg, err = app.email.constructConfirmation("", "", "", true)
+ case "UserExpired":
+ msg, err = app.email.constructUserExpired(true)
+ case "Announcement":
+ case "UserPage":
+ case "UserLogin":
+ case "PostSignupCard":
+ // These don't have any example content
+ msg = nil
+ }
+ if err != nil {
+ respondBool(500, false, gc)
+ return
+ }
+ if msg != nil {
+ content.Content = msg.Text
+ }
}
- switch id {
- case "Announcement":
- // Just send the email html
- content = ""
- case "UserCreated":
- if noContent {
- msg, err = app.email.constructCreated("", "", "", Invite{}, app, true)
- }
- values = app.email.createdValues("xxxxxx", username, emailAddress, Invite{}, app, false)
- case "InviteExpiry":
- if noContent {
- msg, err = app.email.constructExpiry("", Invite{}, app, true)
- }
- values = app.email.expiryValues("xxxxxx", Invite{}, app, false)
- case "PasswordReset":
- if noContent {
- msg, err = app.email.constructReset(PasswordReset{}, app, true)
- }
- values = app.email.resetValues(PasswordReset{Pin: "12-34-56", Username: username}, app, false)
- case "UserDeleted":
- if noContent {
- msg, err = app.email.constructDeleted("", app, true)
- }
- values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
- case "UserDisabled":
- if noContent {
- msg, err = app.email.constructDisabled("", app, true)
- }
- values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
- case "UserEnabled":
- if noContent {
- msg, err = app.email.constructEnabled("", app, true)
- }
- values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false)
- case "UserExpiryAdjusted":
- if noContent {
- msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", app, true)
- }
- values = app.email.expiryAdjustedValues(username, time.Time{}, app.storage.lang.Email[lang].Strings.get("reason"), app, false, true)
- case "ExpiryReminder":
- if noContent {
- msg, err = app.email.constructExpiryReminder("", time.Now().AddDate(0, 0, 3), app, true)
- }
- values = app.email.expiryReminderValues(username, time.Now().AddDate(0, 0, 3), app, false, true)
- case "InviteEmail":
- if noContent {
- msg, err = app.email.constructInvite("", Invite{}, app, true)
- }
- values = app.email.inviteValues("xxxxxx", Invite{}, app, false)
- case "WelcomeEmail":
- if noContent {
- msg, err = app.email.constructWelcome("", time.Time{}, app, true)
- }
- values = app.email.welcomeValues(username, time.Now(), app, false, true)
- case "EmailConfirmation":
- if noContent {
- msg, err = app.email.constructConfirmation("", "", "", app, true)
- }
- values = app.email.confirmationValues("xxxxxx", username, "xxxxxx", app, false)
- case "UserExpired":
- if noContent {
- msg, err = app.email.constructUserExpired(app, true)
- }
- values = app.email.userExpiredValues(app, false)
- case "UserLogin", "UserPage", "PostSignupCard":
- values = map[string]interface{}{}
- }
- if err != nil {
- respondBool(500, false, gc)
- return
- }
- if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" && id != "PostSignupCard" {
- content = msg.Text
- variables = make([]string, strings.Count(content, "{"))
- i := 0
- found := false
- buf := ""
- for _, c := range content {
- if !found && c != '{' && c != '}' {
- continue
- }
- found = true
- buf += string(c)
- if c == '}' {
- found = false
- variables[i] = buf
- buf = ""
- i++
- }
- }
- customMessage.Variables = variables
- }
- if variables == nil {
- variables = []string{}
- }
- app.storage.SetCustomContentKey(id, customMessage)
+
var mail *Message
- if id != "UserLogin" && id != "UserPage" && id != "PostSignupCard" {
- mail, err = app.email.constructTemplate("", "
", app)
+ if contentInfo.ContentType == CustomMessage {
+ mail = &Message{}
+ err = app.email.construct(EmptyCustomContent, CustomContent{
+ Name: EmptyCustomContent.Name,
+ Enabled: true,
+ Content: "",
+ }, map[string]any{}, mail)
if err != nil {
respondBool(500, false, gc)
return
}
} else if id == "PostSignupCard" {
- // Jankiness follows.
+ // Specific workaround for the currently-unique "Post signup card".
// Source content from "Success Message" setting.
- if noContent {
- content = "# " + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("successHeader") + "\n" + app.config.Section("ui").Key("success_message").String()
+ if content.Content == "" {
+ content.Content = "# " + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("successHeader") + "\n" + app.config.Section("ui").Key("success_message").String()
if app.config.Section("user_page").Key("enabled").MustBool(false) {
- content += "\n\n
\n" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.template("userPageSuccessMessage", tmpl{
+ content.Content += "\n\n
\n" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.template("userPageSuccessMessage", tmpl{
"myAccount": "[" + app.storage.lang.User[app.storage.lang.chosenUserLang].Strings.get("myAccount") + "]({myAccountURL})",
})
}
@@ -277,13 +208,15 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
HTML: " ",
}
mail.Markdown = mail.HTML
- } else {
+ } else if contentInfo.ContentType == CustomCard {
mail = &Message{
HTML: "",
}
mail.Markdown = mail.HTML
+ } else {
+ app.err.Printf("unknown custom content type %d", contentInfo.ContentType)
}
- gc.JSON(200, customEmailDTO{Content: content, Variables: variables, Conditionals: conditionals, Values: values, HTML: mail.HTML, Plaintext: mail.Text})
+ gc.JSON(200, customEmailDTO{Content: content.Content, Variables: contentInfo.Variables, Conditionals: contentInfo.Conditionals, Values: contentInfo.Placeholders, HTML: mail.HTML, Plaintext: mail.Text})
}
// @Summary Returns a new Telegram verification PIN, and the bot username.
diff --git a/api-userpage.go b/api-userpage.go
index f608fbb..dbdc525 100644
--- a/api-userpage.go
+++ b/api-userpage.go
@@ -264,7 +264,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
}
app.debug.Printf(lm.EmailConfirmationRequired, id)
respond(401, "confirmEmail", gc)
- msg, err := app.email.constructConfirmation("", name, key, app, false)
+ msg, err := app.email.constructConfirmation("", name, key, false)
if err != nil {
app.err.Printf(lm.FailedConstructConfirmationEmail, id, err)
} else if err := app.email.send(msg, req.Email); err != nil {
@@ -643,7 +643,7 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
Username: pwr.Username,
Expiry: pwr.Expiry,
Internal: true,
- }, app, false,
+ }, false,
)
if err != nil {
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
diff --git a/api-users.go b/api-users.go
index c71f8f9..3b272c4 100644
--- a/api-users.go
+++ b/api-users.go
@@ -189,7 +189,7 @@ func (app *appContext) NewUserFromInvite(gc *gin.Context) {
app.debug.Printf(lm.EmailConfirmationRequired, req.Username)
respond(401, "confirmEmail", gc)
- msg, err := app.email.constructConfirmation(req.Code, req.Username, key, app, false)
+ msg, err := app.email.constructConfirmation(req.Code, req.Username, key, false)
if err != nil {
app.err.Printf(lm.FailedConstructConfirmationEmail, req.Code, err)
} else if err := app.email.send(msg, req.Email); err != nil {
@@ -262,7 +262,7 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
}
app.contactMethods[i].DeleteVerifiedToken(c.PIN)
c.User.SetJellyfin(nu.User.ID)
- c.User.Store(&(app.storage))
+ c.User.Store(app.storage)
}
}
@@ -290,7 +290,7 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
continue
}
go func(addr string) {
- msg, err := app.email.constructCreated(req.Code, req.Username, req.Email, invite, app, false)
+ msg, err := app.email.constructCreated(req.Username, req.Email, time.Now(), invite, false)
if err != nil {
app.err.Printf(lm.FailedConstructCreationAdmin, req.Code, err)
} else {
@@ -384,9 +384,9 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
var err error
if sendMail {
if req.Enabled {
- msg, err = app.email.constructEnabled(req.Reason, app, false)
+ msg, err = app.email.constructEnabled(req.Reason, false)
} else {
- msg, err = app.email.constructDisabled(req.Reason, app, false)
+ msg, err = app.email.constructDisabled(req.Reason, false)
}
if err != nil {
app.err.Printf(lm.FailedConstructEnableDisableMessage, "?", err)
@@ -452,7 +452,7 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
var msg *Message
var err error
if sendMail {
- msg, err = app.email.constructDeleted(req.Reason, app, false)
+ msg, err = app.email.constructDeleted(req.Reason, false)
if err != nil {
app.err.Printf(lm.FailedConstructDeletionMessage, "?", err)
sendMail = false
@@ -541,7 +541,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
if err != nil {
return
}
- msg, err := app.email.constructExpiryAdjusted(user.Name, exp, req.Reason, app, false)
+ msg, err := app.email.constructExpiryAdjusted(user.Name, exp, req.Reason, false)
if err != nil {
app.err.Printf(lm.FailedConstructExpiryAdjustmentMessage, uid, err)
return
@@ -677,7 +677,11 @@ func (app *appContext) Announce(gc *gin.Context) {
app.err.Printf(lm.FailedGetUser, userID, lm.Jellyfin, err)
continue
}
- msg, err := app.email.constructTemplate(req.Subject, req.Message, app, user.Name)
+ msg := &Message{}
+ err = app.email.construct(AnnouncementCustomContent(req.Subject), CustomContent{
+ Enabled: true,
+ Content: req.Message,
+ }, map[string]any{"username": user.Name}, msg)
if err != nil {
app.err.Printf(lm.FailedConstructAnnouncementMessage, userID, err)
respondBool(500, false, gc)
@@ -690,7 +694,11 @@ func (app *appContext) Announce(gc *gin.Context) {
}
// app.info.Printf(lm.SentAnnouncementMessage, "*", "?")
} else {
- msg, err := app.email.constructTemplate(req.Subject, req.Message, app)
+ msg := &Message{}
+ err := app.email.construct(AnnouncementCustomContent(req.Subject), CustomContent{
+ Enabled: true,
+ Content: req.Message,
+ }, map[string]any{"username": ""}, msg)
if err != nil {
app.err.Printf(lm.FailedConstructAnnouncementMessage, "*", err)
respondBool(500, false, gc)
@@ -810,7 +818,7 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) {
app.internalPWRs[pwr.PIN] = pwr
sendAddress := app.getAddressOrName(id)
if sendAddress == "" || len(req.Users) == 1 {
- resp.Link, err = app.GenResetLink(pwr.PIN)
+ resp.Link, err = GenResetLink(pwr.PIN)
linkCount++
if sendAddress == "" {
resp.Manual = true
@@ -823,7 +831,7 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) {
Username: pwr.Username,
Expiry: pwr.Expiry,
Internal: true,
- }, app, false,
+ }, false,
)
if err != nil {
app.err.Printf(lm.FailedConstructPWRMessage, id, err)
diff --git a/api.go b/api.go
index c09fece..53097df 100644
--- a/api.go
+++ b/api.go
@@ -36,23 +36,14 @@ func respondBool(code int, val bool, gc *gin.Context) {
gc.Abort()
}
-func (app *appContext) loadStrftime() {
- app.datePattern = app.config.Section("messages").Key("date_format").String()
- app.timePattern = `%H:%M`
- if val, _ := app.config.Section("messages").Key("use_24h").Bool(); !val {
- app.timePattern = `%I:%M %p`
- }
+func prettyTime(dt time.Time) (date, time string) {
+ date = timefmt.Format(dt, datePattern)
+ time = timefmt.Format(dt, timePattern)
return
}
-func (app *appContext) prettyTime(dt time.Time) (date, time string) {
- date = timefmt.Format(dt, app.datePattern)
- time = timefmt.Format(dt, app.timePattern)
- return
-}
-
-func (app *appContext) formatDatetime(dt time.Time) string {
- d, t := app.prettyTime(dt)
+func formatDatetime(dt time.Time) string {
+ d, t := prettyTime(dt)
return d + " " + t
}
@@ -310,7 +301,7 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
if req["restart-program"] != nil && req["restart-program"].(bool) {
app.Restart()
}
- app.loadConfig()
+ app.ReloadConfig()
// Patch new settings for next GetConfig
app.PatchConfigBase()
// Reinitialize password validator on config change, as opposed to every applicable request like in python.
diff --git a/backups.go b/backups.go
index 0a1ae57..33b16b2 100644
--- a/backups.go
+++ b/backups.go
@@ -14,6 +14,7 @@ import (
const (
BACKUP_PREFIX = "jfa-go-db"
+ BACKUP_PREFIX_OLD = "jfa-go-db-"
BACKUP_COMMIT_PREFIX = "-c-"
BACKUP_DATE_PREFIX = "-d-"
BACKUP_UPLOAD_PREFIX = "upload-"
@@ -33,7 +34,7 @@ func (b Backup) Equals(a Backup) bool {
return a.Date.Equal(b.Date) && a.Commit == b.Commit && a.Upload == b.Upload
}
-// Pre 21/03/25 format: "{BACKUP_PREFIX}{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-2006-01-02T15-04-05.bak"
+// Pre 21/03/25 format: "{BACKUP_PREFIX_OLD}{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-2006-01-02T15-04-05.bak"
// Post 21/03/25 format: "{BACKUP_PREFIX}-c-{commit}-d-{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-c-0b92060-d-2006-01-02T15-04-05.bak"
func (b Backup) String() string {
@@ -274,8 +275,10 @@ func (app *appContext) loadPendingBackup() {
}
app.info.Printf(lm.MoveOldDB, oldPath)
- app.ConnectDB()
- defer app.storage.db.Close()
+ if err := app.storage.Connect(app.config); err != nil {
+ app.err.Fatalf(lm.FailedConnectDB, app.storage.db_path, err)
+ }
+ defer app.storage.Close()
f, err := os.Open(LOADBAK)
if err != nil {
diff --git a/backups_test.go b/backups_test.go
index abdcda8..10c3afd 100644
--- a/backups_test.go
+++ b/backups_test.go
@@ -17,13 +17,13 @@ func testBackupParse(f string, a Backup, t *testing.T) {
}
func TestBackupParserOld(t *testing.T) {
- Q1 := BACKUP_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX
+ Q1 := BACKUP_PREFIX_OLD + "2023-12-21T21-08-00" + BACKUP_SUFFIX
A1 := Backup{}
A1.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00")
testBackupParse(Q1, A1, t)
}
func TestBackupParserOldUpload(t *testing.T) {
- Q2 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX
+ Q2 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX_OLD + "2023-12-21T21-08-00" + BACKUP_SUFFIX
A2 := Backup{
Upload: true,
}
diff --git a/config.go b/config.go
index c997705..df6daa4 100644
--- a/config.go
+++ b/config.go
@@ -4,6 +4,7 @@ import (
"fmt"
"io/fs"
"net"
+ "net/http"
"net/url"
"os"
"path/filepath"
@@ -18,6 +19,12 @@ import (
"gopkg.in/ini.v1"
)
+type Config struct {
+ *ini.File
+ proxyTransport *http.Transport
+ proxyConfig *easyproxy.ProxyConfig
+}
+
var emailEnabled = false
var messagesEnabled = false
var telegramEnabled = false
@@ -28,8 +35,8 @@ var matrixEnabled = false
// IMPORTANT: When linking straight to a page, rather than appending further to the URL (like accessing an API route), append a /.
var PAGES = PagePaths{}
-func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
- val := app.config.Section(sect).Key(key).MustString("")
+func (config *Config) GetPath(sect, key string) (fs.FS, string) {
+ val := config.Section(sect).Key(key).MustString("")
if strings.HasPrefix(val, "jfa-go:") {
return localFS, strings.TrimPrefix(val, "jfa-go:")
}
@@ -37,15 +44,15 @@ func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
return os.DirFS(dir), file
}
-func (app *appContext) MustSetValue(section, key, val string) {
- app.config.Section(section).Key(key).SetValue(app.config.Section(section).Key(key).MustString(val))
+func (config *Config) MustSetValue(section, key, val string) {
+ config.Section(section).Key(key).SetValue(config.Section(section).Key(key).MustString(val))
}
-func (app *appContext) MustSetURLPath(section, key, val string) {
+func (config *Config) MustSetURLPath(section, key, val string) {
if !strings.HasPrefix(val, "/") && val != "" {
val = "/" + val
}
- app.MustSetValue(section, key, val)
+ config.MustSetValue(section, key, val)
}
func FixFullURL(v string) string {
@@ -69,26 +76,26 @@ func FormatSubpath(path string, removeSingleSlash bool) string {
return strings.TrimSuffix(path, "/")
}
-func (app *appContext) MustCorrectURL(section, key, value string) {
- v := app.config.Section(section).Key(key).String()
+func (config *Config) MustCorrectURL(section, key, value string) {
+ v := config.Section(section).Key(key).String()
if v == "" {
v = value
}
v = FixFullURL(v)
- app.config.Section(section).Key(key).SetValue(v)
+ config.Section(section).Key(key).SetValue(v)
}
-// ExternalDomain returns the Host for the request, using the fixed app.externalDomain value unless app.UseProxyHost is true.
-func (app *appContext) ExternalDomain(gc *gin.Context) string {
- if !app.UseProxyHost || gc.Request.Host == "" {
- return app.externalDomain
+// ExternalDomain returns the Host for the request, using the fixed externalDomain value unless UseProxyHost is true.
+func ExternalDomain(gc *gin.Context) string {
+ if !UseProxyHost || gc.Request.Host == "" {
+ return externalDomain
}
return gc.Request.Host
}
-// ExternalDomainNoPort attempts to return app.ExternalDomain() with the port removed. If the internally-used method fails, it is assumed the domain has no port anyway.
+// ExternalDomainNoPort attempts to return ExternalDomain() with the port removed. If the internally-used method fails, it is assumed the domain has no port anyway.
func (app *appContext) ExternalDomainNoPort(gc *gin.Context) string {
- domain := app.ExternalDomain(gc)
+ domain := ExternalDomain(gc)
host, _, err := net.SplitHostPort(domain)
if err != nil {
return domain
@@ -96,11 +103,11 @@ func (app *appContext) ExternalDomainNoPort(gc *gin.Context) string {
return host
}
-// ExternalURI returns the External URI of jfa-go's root directory (by default, where the admin page is), using the fixed app.externalURI value unless app.UseProxyHost is true and gc is not nil.
-// When nil is passed, app.externalURI is returned.
-func (app *appContext) ExternalURI(gc *gin.Context) string {
+// ExternalURI returns the External URI of jfa-go's root directory (by default, where the admin page is), using the fixed externalURI value unless UseProxyHost is true and gc is not nil.
+// When nil is passed, externalURI is returned.
+func ExternalURI(gc *gin.Context) string {
if gc == nil {
- return app.externalURI
+ return externalURI
}
var proto string
@@ -111,10 +118,10 @@ func (app *appContext) ExternalURI(gc *gin.Context) string {
}
// app.debug.Printf("Request: %+v\n", gc.Request)
- if app.UseProxyHost && gc.Request.Host != "" {
+ if UseProxyHost && gc.Request.Host != "" {
return proto + gc.Request.Host + PAGES.Base
}
- return app.externalURI
+ return externalURI
}
func (app *appContext) EvaluateRelativePath(gc *gin.Context, path string) string {
@@ -129,177 +136,192 @@ func (app *appContext) EvaluateRelativePath(gc *gin.Context, path string) string
proto = "http://"
}
- return proto + app.ExternalDomain(gc) + path
+ return proto + ExternalDomain(gc) + path
}
-func (app *appContext) loadConfig() error {
+// NewConfig reads and patches a config file for use. Passed loggers are used only once. Some dependencies can be reloaded after this is called with ReloadDependents(app).
+func NewConfig(configPathOrContents any, dataPath string, logs LoggerSet) (*Config, error) {
var err error
- app.config, err = ini.ShadowLoad(app.configPath)
+ config := &Config{}
+ config.File, err = ini.ShadowLoad(configPathOrContents)
if err != nil {
- return err
+ return config, err
}
// URLs
- app.MustSetURLPath("ui", "url_base", "")
- app.MustSetURLPath("url_paths", "admin", "")
- app.MustSetURLPath("url_paths", "user_page", "/my/account")
- app.MustSetURLPath("url_paths", "form", "/invite")
- PAGES.Base = FormatSubpath(app.config.Section("ui").Key("url_base").String(), true)
- PAGES.Admin = FormatSubpath(app.config.Section("url_paths").Key("admin").String(), true)
- PAGES.MyAccount = FormatSubpath(app.config.Section("url_paths").Key("user_page").String(), true)
- PAGES.Form = FormatSubpath(app.config.Section("url_paths").Key("form").String(), true)
- if !(app.config.Section("user_page").Key("enabled").MustBool(true)) {
+ config.MustSetURLPath("ui", "url_base", "")
+ config.MustSetURLPath("url_paths", "admin", "")
+ config.MustSetURLPath("url_paths", "user_page", "/my/account")
+ config.MustSetURLPath("url_paths", "form", "/invite")
+ PAGES.Base = FormatSubpath(config.Section("ui").Key("url_base").String(), true)
+ PAGES.Admin = FormatSubpath(config.Section("url_paths").Key("admin").String(), true)
+ PAGES.MyAccount = FormatSubpath(config.Section("url_paths").Key("user_page").String(), true)
+ PAGES.Form = FormatSubpath(config.Section("url_paths").Key("form").String(), true)
+ if !(config.Section("user_page").Key("enabled").MustBool(true)) {
PAGES.MyAccount = "disabled"
}
if PAGES.Base == PAGES.Form || PAGES.Base == "/accounts" || PAGES.Base == "/settings" || PAGES.Base == "/activity" {
- app.err.Printf(lm.BadURLBase, PAGES.Base)
+ logs.err.Printf(lm.BadURLBase, PAGES.Base)
}
- app.info.Printf(lm.SubpathBlockMessage, PAGES.Base, PAGES.Admin, PAGES.MyAccount, PAGES.Form)
+ logs.info.Printf(lm.SubpathBlockMessage, PAGES.Base, PAGES.Admin, PAGES.MyAccount, PAGES.Form)
- app.MustCorrectURL("jellyfin", "server", "")
- app.MustCorrectURL("jellyfin", "public_server", app.config.Section("jellyfin").Key("server").String())
- app.MustCorrectURL("ui", "redirect_url", app.config.Section("jellyfin").Key("public_server").String())
+ config.MustCorrectURL("jellyfin", "server", "")
+ config.MustCorrectURL("jellyfin", "public_server", config.Section("jellyfin").Key("server").String())
+ config.MustCorrectURL("ui", "redirect_url", config.Section("jellyfin").Key("public_server").String())
- for _, key := range app.config.Section("files").Keys() {
+ for _, key := range config.Section("files").Keys() {
if name := key.Name(); name != "html_templates" && name != "lang_files" {
- key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json"))))
+ key.SetValue(key.MustString(filepath.Join(dataPath, (key.Name() + ".json"))))
}
}
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users", "announcements", "custom_user_page_content"} {
- app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json"))))
+ config.Section("files").Key(key).SetValue(config.Section("files").Key(key).MustString(filepath.Join(dataPath, (key + ".json"))))
}
for _, key := range []string{"matrix_sql"} {
- app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".db"))))
+ config.Section("files").Key(key).SetValue(config.Section("files").Key(key).MustString(filepath.Join(dataPath, (key + ".db"))))
}
- // If true, app.ExternalDomain() will return one based on the reported Host (ideally reported in "Host" or "X-Forwarded-Host" by the reverse proxy), falling back to app.externalDomain if not set.
- app.UseProxyHost = app.config.Section("ui").Key("use_proxy_host").MustBool(false)
- app.externalURI = strings.TrimSuffix(strings.TrimSuffix(app.config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/")
- if !strings.HasSuffix(app.externalURI, PAGES.Base) {
- app.err.Println(lm.NoURLSuffix)
+ // If true, ExternalDomain() will return one based on the reported Host (ideally reported in "Host" or "X-Forwarded-Host" by the reverse proxy), falling back to externalDomain if not set.
+ UseProxyHost = config.Section("ui").Key("use_proxy_host").MustBool(false)
+ externalURI = strings.TrimSuffix(strings.TrimSuffix(config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/")
+ if !strings.HasSuffix(externalURI, PAGES.Base) {
+ logs.err.Println(lm.NoURLSuffix)
}
- if app.externalURI == "" {
- if app.UseProxyHost {
- app.err.Println(lm.NoExternalHost + lm.LoginWontSave + lm.SetExternalHostDespiteUseProxyHost)
+ if externalURI == "" {
+ if UseProxyHost {
+ logs.err.Println(lm.NoExternalHost + lm.LoginWontSave + lm.SetExternalHostDespiteUseProxyHost)
} else {
- app.err.Println(lm.NoExternalHost + lm.LoginWontSave)
+ logs.err.Println(lm.NoExternalHost + lm.LoginWontSave)
}
}
- u, err := url.Parse(app.externalURI)
+ u, err := url.Parse(externalURI)
if err == nil {
- app.externalDomain = u.Hostname()
+ externalDomain = u.Hostname()
}
- app.config.Section("email").Key("no_username").SetValue(strconv.FormatBool(app.config.Section("email").Key("no_username").MustBool(false)))
+ config.Section("email").Key("no_username").SetValue(strconv.FormatBool(config.Section("email").Key("no_username").MustBool(false)))
- app.MustSetValue("password_resets", "email_html", "jfa-go:"+"email.html")
- app.MustSetValue("password_resets", "email_text", "jfa-go:"+"email.txt")
+ // FIXME: Remove all these, eventually
+ // config.MustSetValue("password_resets", "email_html", "jfa-go:"+"email.html")
+ // config.MustSetValue("password_resets", "email_text", "jfa-go:"+"email.txt")
- app.MustSetValue("invite_emails", "email_html", "jfa-go:"+"invite-email.html")
- app.MustSetValue("invite_emails", "email_text", "jfa-go:"+"invite-email.txt")
+ // config.MustSetValue("invite_emails", "email_html", "jfa-go:"+"invite-email.html")
+ // config.MustSetValue("invite_emails", "email_text", "jfa-go:"+"invite-email.txt")
- app.MustSetValue("email_confirmation", "email_html", "jfa-go:"+"confirmation.html")
- app.MustSetValue("email_confirmation", "email_text", "jfa-go:"+"confirmation.txt")
+ // config.MustSetValue("email_confirmation", "email_html", "jfa-go:"+"confirmation.html")
+ // config.MustSetValue("email_confirmation", "email_text", "jfa-go:"+"confirmation.txt")
- app.MustSetValue("notifications", "expiry_html", "jfa-go:"+"expired.html")
- app.MustSetValue("notifications", "expiry_text", "jfa-go:"+"expired.txt")
+ // config.MustSetValue("notifications", "expiry_html", "jfa-go:"+"expired.html")
+ // config.MustSetValue("notifications", "expiry_text", "jfa-go:"+"expired.txt")
- app.MustSetValue("notifications", "created_html", "jfa-go:"+"created.html")
- app.MustSetValue("notifications", "created_text", "jfa-go:"+"created.txt")
+ // config.MustSetValue("notifications", "created_html", "jfa-go:"+"created.html")
+ // config.MustSetValue("notifications", "created_text", "jfa-go:"+"created.txt")
- app.MustSetValue("deletion", "email_html", "jfa-go:"+"deleted.html")
- app.MustSetValue("deletion", "email_text", "jfa-go:"+"deleted.txt")
-
- app.MustSetValue("smtp", "hello_hostname", "localhost")
- app.MustSetValue("smtp", "cert_validation", "true")
- app.MustSetValue("smtp", "auth_type", "4")
- app.MustSetValue("smtp", "port", "465")
-
- app.MustSetValue("activity_log", "keep_n_records", "1000")
- app.MustSetValue("activity_log", "delete_after_days", "90")
-
- sc := app.config.Section("discord").Key("start_command").MustString("start")
- app.config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
+ // config.MustSetValue("deletion", "email_html", "jfa-go:"+"deleted.html")
+ // config.MustSetValue("deletion", "email_text", "jfa-go:"+"deleted.txt")
// Deletion template is good enough for these as well.
- app.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html")
- app.MustSetValue("disable_enable", "disabled_text", "jfa-go:"+"deleted.txt")
- app.MustSetValue("disable_enable", "enabled_html", "jfa-go:"+"deleted.html")
- app.MustSetValue("disable_enable", "enabled_text", "jfa-go:"+"deleted.txt")
+ // config.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html")
+ // config.MustSetValue("disable_enable", "disabled_text", "jfa-go:"+"deleted.txt")
+ // config.MustSetValue("disable_enable", "enabled_html", "jfa-go:"+"deleted.html")
+ // config.MustSetValue("disable_enable", "enabled_text", "jfa-go:"+"deleted.txt")
- app.MustSetValue("welcome_email", "email_html", "jfa-go:"+"welcome.html")
- app.MustSetValue("welcome_email", "email_text", "jfa-go:"+"welcome.txt")
+ // config.MustSetValue("welcome_email", "email_html", "jfa-go:"+"welcome.html")
+ // config.MustSetValue("welcome_email", "email_text", "jfa-go:"+"welcome.txt")
- app.MustSetValue("template_email", "email_html", "jfa-go:"+"template.html")
- app.MustSetValue("template_email", "email_text", "jfa-go:"+"template.txt")
+ // config.MustSetValue("template_email", "email_html", "jfa-go:"+"template.html")
+ // config.MustSetValue("template_email", "email_text", "jfa-go:"+"template.txt")
- app.MustSetValue("user_expiry", "behaviour", "disable_user")
- app.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html")
- app.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt")
+ config.MustSetValue("user_expiry", "behaviour", "disable_user")
+ // config.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html")
+ // config.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt")
- app.MustSetValue("user_expiry", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html")
- app.MustSetValue("user_expiry", "adjustment_email_text", "jfa-go:"+"expiry-adjusted.txt")
+ // config.MustSetValue("user_expiry", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html")
+ // config.MustSetValue("user_expiry", "adjustment_email_text", "jfa-go:"+"expiry-adjusted.txt")
- app.MustSetValue("user_expiry", "reminder_email_html", "jfa-go:"+"expiry-reminder.html")
- app.MustSetValue("user_expiry", "reminder_email_text", "jfa-go:"+"expiry-reminder.txt")
+ // config.MustSetValue("user_expiry", "reminder_email_html", "jfa-go:"+"expiry-reminder.html")
+ // config.MustSetValue("user_expiry", "reminder_email_text", "jfa-go:"+"expiry-reminder.txt")
- app.MustSetValue("email", "collect", "true")
+ fnameSettingSuffix := []string{"html", "text"}
+ fnameExtension := []string{"html", "txt"}
- app.MustSetValue("matrix", "topic", "Jellyfin notifications")
- app.MustSetValue("matrix", "show_on_reg", "true")
+ for _, cc := range customContent {
+ if cc.SourceFile.DefaultValue == "" {
+ continue
+ }
+ for i := range fnameSettingSuffix {
+ config.MustSetValue(cc.SourceFile.Section, cc.SourceFile.SettingPrefix+fnameSettingSuffix[i], "jfa-go:"+cc.SourceFile.DefaultValue+"."+fnameExtension[i])
+ }
+ }
- app.MustSetValue("discord", "show_on_reg", "true")
+ config.MustSetValue("smtp", "hello_hostname", "localhost")
+ config.MustSetValue("smtp", "cert_validation", "true")
+ config.MustSetValue("smtp", "auth_type", "4")
+ config.MustSetValue("smtp", "port", "465")
- app.MustSetValue("telegram", "show_on_reg", "true")
+ config.MustSetValue("activity_log", "keep_n_records", "1000")
+ config.MustSetValue("activity_log", "delete_after_days", "90")
- app.MustSetValue("backups", "every_n_minutes", "1440")
- app.MustSetValue("backups", "path", filepath.Join(app.dataPath, "backups"))
- app.MustSetValue("backups", "keep_n_backups", "20")
- app.MustSetValue("backups", "keep_previous_version_backup", "true")
+ sc := config.Section("discord").Key("start_command").MustString("start")
+ config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
- app.config.Section("jellyfin").Key("version").SetValue(version)
- app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
- app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
+ config.MustSetValue("email", "collect", "true")
- app.MustSetValue("jellyfin", "cache_timeout", "30")
- app.MustSetValue("jellyfin", "web_cache_async_timeout", "1")
- app.MustSetValue("jellyfin", "web_cache_sync_timeout", "10")
+ config.MustSetValue("matrix", "topic", "Jellyfin notifications")
+ config.MustSetValue("matrix", "show_on_reg", "true")
- LOGIP = app.config.Section("advanced").Key("log_ips").MustBool(false)
- LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false)
+ config.MustSetValue("discord", "show_on_reg", "true")
- app.MustSetValue("advanced", "auth_retry_count", "6")
- app.MustSetValue("advanced", "auth_retry_gap", "10")
+ config.MustSetValue("telegram", "show_on_reg", "true")
- app.MustSetValue("ui", "port", "8056")
- app.MustSetValue("advanced", "tls_port", "8057")
+ config.MustSetValue("backups", "every_n_minutes", "1440")
+ config.MustSetValue("backups", "path", filepath.Join(dataPath, "backups"))
+ config.MustSetValue("backups", "keep_n_backups", "20")
+ config.MustSetValue("backups", "keep_previous_version_backup", "true")
- app.MustSetValue("advanced", "value_log_size", "512")
+ config.Section("jellyfin").Key("version").SetValue(version)
+ config.Section("jellyfin").Key("device").SetValue("jfa-go")
+ config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
+
+ config.MustSetValue("jellyfin", "cache_timeout", "30")
+ config.MustSetValue("jellyfin", "web_cache_async_timeout", "1")
+ config.MustSetValue("jellyfin", "web_cache_sync_timeout", "10")
+
+ LOGIP = config.Section("advanced").Key("log_ips").MustBool(false)
+ LOGIPU = config.Section("advanced").Key("log_ips_users").MustBool(false)
+
+ config.MustSetValue("advanced", "auth_retry_count", "6")
+ config.MustSetValue("advanced", "auth_retry_gap", "10")
+
+ config.MustSetValue("ui", "port", "8056")
+ config.MustSetValue("advanced", "tls_port", "8057")
+
+ config.MustSetValue("advanced", "value_log_size", "512")
pwrMethods := []string{"allow_pwr_username", "allow_pwr_email", "allow_pwr_contact_method"}
allDisabled := true
for _, v := range pwrMethods {
- if app.config.Section("user_page").Key(v).MustBool(true) {
+ if config.Section("user_page").Key(v).MustBool(true) {
allDisabled = false
}
}
if allDisabled {
- app.info.Println(lm.EnableAllPWRMethods)
+ logs.info.Println(lm.EnableAllPWRMethods)
for _, v := range pwrMethods {
- app.config.Section("user_page").Key(v).SetValue("true")
+ config.Section("user_page").Key(v).SetValue("true")
}
}
- messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false)
- telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false)
- discordEnabled = app.config.Section("discord").Key("enabled").MustBool(false)
- matrixEnabled = app.config.Section("matrix").Key("enabled").MustBool(false)
+ messagesEnabled = config.Section("messages").Key("enabled").MustBool(false)
+ telegramEnabled = config.Section("telegram").Key("enabled").MustBool(false)
+ discordEnabled = config.Section("discord").Key("enabled").MustBool(false)
+ matrixEnabled = config.Section("matrix").Key("enabled").MustBool(false)
if !messagesEnabled {
emailEnabled = false
telegramEnabled = false
discordEnabled = false
matrixEnabled = false
- } else if app.config.Section("email").Key("method").MustString("") == "" {
+ } else if config.Section("email").Key("method").MustString("") == "" {
emailEnabled = false
} else {
emailEnabled = true
@@ -308,31 +330,64 @@ func (app *appContext) loadConfig() error {
messagesEnabled = false
}
- if app.proxyEnabled = app.config.Section("advanced").Key("proxy").MustBool(false); app.proxyEnabled {
- app.proxyConfig = easyproxy.ProxyConfig{}
- app.proxyConfig.Protocol = easyproxy.HTTP
- if strings.Contains(app.config.Section("advanced").Key("proxy_protocol").MustString("http"), "socks") {
- app.proxyConfig.Protocol = easyproxy.SOCKS5
+ if proxyEnabled := config.Section("advanced").Key("proxy").MustBool(false); proxyEnabled {
+ config.proxyConfig = &easyproxy.ProxyConfig{}
+ config.proxyConfig.Protocol = easyproxy.HTTP
+ if strings.Contains(config.Section("advanced").Key("proxy_protocol").MustString("http"), "socks") {
+ config.proxyConfig.Protocol = easyproxy.SOCKS5
}
- app.proxyConfig.Addr = app.config.Section("advanced").Key("proxy_address").MustString("")
- app.proxyConfig.User = app.config.Section("advanced").Key("proxy_user").MustString("")
- app.proxyConfig.Password = app.config.Section("advanced").Key("proxy_password").MustString("")
- app.proxyTransport, err = easyproxy.NewTransport(app.proxyConfig)
+ config.proxyConfig.Addr = config.Section("advanced").Key("proxy_address").MustString("")
+ config.proxyConfig.User = config.Section("advanced").Key("proxy_user").MustString("")
+ config.proxyConfig.Password = config.Section("advanced").Key("proxy_password").MustString("")
+ config.proxyTransport, err = easyproxy.NewTransport(*(config.proxyConfig))
if err != nil {
- app.err.Printf(lm.FailedInitProxy, app.proxyConfig.Addr, err)
+ logs.err.Printf(lm.FailedInitProxy, config.proxyConfig.Addr, err)
// As explained in lm.FailedInitProxy, sleep here might grab the admin's attention,
// Since we don't crash on this failing.
time.Sleep(15 * time.Second)
- app.proxyEnabled = false
+ config.proxyConfig = nil
+ config.proxyTransport = nil
} else {
- app.proxyEnabled = true
- app.info.Printf(lm.InitProxy, app.proxyConfig.Addr)
+ logs.info.Printf(lm.InitProxy, config.proxyConfig.Addr)
}
}
- app.MustSetValue("updates", "enabled", "true")
- releaseChannel := app.config.Section("updates").Key("channel").String()
- if app.config.Section("updates").Key("enabled").MustBool(false) {
+ config.MustSetValue("updates", "enabled", "true")
+
+ substituteStrings = config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("")
+
+ if substituteStrings != "" {
+ v := config.Section("ui").Key("success_message")
+ v.SetValue(strings.ReplaceAll(v.String(), "Jellyfin", substituteStrings))
+ }
+
+ datePattern = config.Section("messages").Key("date_format").String()
+ timePattern = `%H:%M`
+ if !(config.Section("messages").Key("use_24h").MustBool(true)) {
+ timePattern = `%I:%M %p`
+ }
+
+ return config, nil
+}
+
+// ReloadDependents re-initialises or applies changes to components of the app which can be reconfigured without restarting.
+func (config *Config) ReloadDependents(app *appContext) {
+ oldFormLang := config.Section("ui").Key("language").MustString("")
+ if oldFormLang != "" {
+ app.storage.lang.chosenUserLang = oldFormLang
+ }
+ newFormLang := config.Section("ui").Key("language-form").MustString("")
+ if newFormLang != "" {
+ app.storage.lang.chosenUserLang = newFormLang
+ }
+
+ app.storage.lang.chosenAdminLang = config.Section("ui").Key("language-admin").MustString("en-us")
+ app.storage.lang.chosenEmailLang = config.Section("email").Key("language").MustString("en-us")
+ app.storage.lang.chosenPWRLang = config.Section("password_resets").Key("language").MustString("en-us")
+ app.storage.lang.chosenTelegramLang = config.Section("telegram").Key("language").MustString("en-us")
+
+ releaseChannel := config.Section("updates").Key("channel").String()
+ if config.Section("updates").Key("enabled").MustBool(false) {
v := version
if releaseChannel == "stable" {
if version == "git" {
@@ -341,9 +396,9 @@ func (app *appContext) loadConfig() error {
} else if releaseChannel == "unstable" {
v = "git"
}
- app.updater = newUpdater(baseURL, namespace, repo, v, commit, updater)
- if app.proxyEnabled {
- app.updater.SetTransport(app.proxyTransport)
+ app.updater = NewUpdater(baseURL, namespace, repo, v, commit, updater)
+ if config.proxyTransport != nil {
+ app.updater.SetTransport(config.proxyTransport)
}
}
if releaseChannel == "" {
@@ -352,32 +407,21 @@ func (app *appContext) loadConfig() error {
} else {
releaseChannel = "stable"
}
- app.MustSetValue("updates", "channel", releaseChannel)
+ config.MustSetValue("updates", "channel", releaseChannel)
}
- substituteStrings = app.config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("")
+ app.email = NewEmailer(config, app.storage, app.LoggerSet)
+}
- if substituteStrings != "" {
- v := app.config.Section("ui").Key("success_message")
- v.SetValue(strings.ReplaceAll(v.String(), "Jellyfin", substituteStrings))
+func (app *appContext) ReloadConfig() {
+ var err error = nil
+ app.config, err = NewConfig(app.configPath, app.dataPath, app.LoggerSet)
+ if err != nil {
+ app.err.Fatalf(lm.FailedLoadConfig, app.configPath, err)
}
- oldFormLang := app.config.Section("ui").Key("language").MustString("")
- if oldFormLang != "" {
- app.storage.lang.chosenUserLang = oldFormLang
- }
- newFormLang := app.config.Section("ui").Key("language-form").MustString("")
- if newFormLang != "" {
- app.storage.lang.chosenUserLang = newFormLang
- }
- app.storage.lang.chosenAdminLang = app.config.Section("ui").Key("language-admin").MustString("en-us")
- app.storage.lang.chosenEmailLang = app.config.Section("email").Key("language").MustString("en-us")
- app.storage.lang.chosenPWRLang = app.config.Section("password_resets").Key("language").MustString("en-us")
- app.storage.lang.chosenTelegramLang = app.config.Section("telegram").Key("language").MustString("en-us")
-
- app.email = NewEmailer(app)
-
- return nil
+ app.config.ReloadDependents(app)
+ app.info.Printf(lm.LoadConfig, app.configPath)
}
func (app *appContext) PatchConfigBase() {
diff --git a/customcontent.go b/customcontent.go
new file mode 100644
index 0000000..09f37fd
--- /dev/null
+++ b/customcontent.go
@@ -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
+}()
diff --git a/discord.go b/discord.go
index 5bf60d3..02180be 100644
--- a/discord.go
+++ b/discord.go
@@ -735,7 +735,7 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
var msg *Message
if err == nil {
- msg, err = d.app.email.constructInvite(invite.Code, invite, d.app, false)
+ msg, err = d.app.email.constructInvite(invite, false)
if err != nil {
// Print extra message, ideally we'd just print this, or get rid of it though.
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, invite.Code, err)
diff --git a/email.go b/email.go
index 9fda36e..d5a8fdc 100644
--- a/email.go
+++ b/email.go
@@ -10,6 +10,7 @@ import (
"html/template"
"io"
"io/fs"
+ "maps"
"net/http"
"net/url"
"os"
@@ -41,6 +42,9 @@ type Emailer struct {
fromAddr, fromName string
lang emailLang
sender EmailClient
+ config *Config
+ storage *Storage
+ LoggerSet
}
// Message stores content.
@@ -51,7 +55,7 @@ type Message struct {
Markdown string `json:"markdown"`
}
-func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool, datePattern, timePattern string) (d, t, expiresIn string) {
+func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool) (d, t, expiresIn string) {
d = timefmt.Format(expiry, datePattern)
t = timefmt.Format(expiry, timePattern)
currentTime := time.Now()
@@ -73,16 +77,19 @@ func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool, datePattern
}
// NewEmailer configures and returns a new emailer.
-func NewEmailer(app *appContext) *Emailer {
+func NewEmailer(config *Config, storage *Storage, logs LoggerSet) *Emailer {
emailer := &Emailer{
- fromAddr: app.config.Section("email").Key("address").String(),
- fromName: app.config.Section("email").Key("from").String(),
- lang: app.storage.lang.Email[app.storage.lang.chosenEmailLang],
+ fromAddr: config.Section("email").Key("address").String(),
+ fromName: config.Section("email").Key("from").String(),
+ lang: storage.lang.Email[storage.lang.chosenEmailLang],
+ LoggerSet: logs,
+ config: config,
+ storage: storage,
}
- method := app.config.Section("email").Key("method").String()
+ method := emailer.config.Section("email").Key("method").String()
if method == "smtp" {
enc := sMail.EncryptionSTARTTLS
- switch app.config.Section("smtp").Key("encryption").String() {
+ switch emailer.config.Section("smtp").Key("encryption").String() {
case "ssl_tls":
enc = sMail.EncryptionSSLTLS
case "starttls":
@@ -90,22 +97,18 @@ func NewEmailer(app *appContext) *Emailer {
case "none":
enc = sMail.EncryptionNone
}
- username := app.config.Section("smtp").Key("username").MustString("")
- password := app.config.Section("smtp").Key("password").String()
+ username := emailer.config.Section("smtp").Key("username").MustString("")
+ password := emailer.config.Section("smtp").Key("password").String()
if username == "" && password != "" {
username = emailer.fromAddr
}
- var proxyConf *easyproxy.ProxyConfig = nil
- if app.proxyEnabled {
- proxyConf = &app.proxyConfig
- }
- authType := sMail.AuthType(app.config.Section("smtp").Key("auth_type").MustInt(4))
- err := emailer.NewSMTP(app.config.Section("smtp").Key("server").String(), app.config.Section("smtp").Key("port").MustInt(465), username, password, enc, app.config.Section("smtp").Key("ssl_cert").MustString(""), app.config.Section("smtp").Key("hello_hostname").String(), app.config.Section("smtp").Key("cert_validation").MustBool(true), authType, proxyConf)
+ authType := sMail.AuthType(emailer.config.Section("smtp").Key("auth_type").MustInt(4))
+ err := emailer.NewSMTP(emailer.config.Section("smtp").Key("server").String(), emailer.config.Section("smtp").Key("port").MustInt(465), username, password, enc, emailer.config.Section("smtp").Key("ssl_cert").MustString(""), emailer.config.Section("smtp").Key("hello_hostname").String(), emailer.config.Section("smtp").Key("cert_validation").MustBool(true), authType, emailer.config.proxyConfig)
if err != nil {
- app.err.Printf(lm.FailedInitSMTP, err)
+ emailer.err.Printf(lm.FailedInitSMTP, err)
}
} else if method == "mailgun" {
- emailer.NewMailgun(app.config.Section("mailgun").Key("api_url").String(), app.config.Section("mailgun").Key("api_key").String(), app.proxyTransport)
+ emailer.NewMailgun(emailer.config.Section("mailgun").Key("api_url").String(), emailer.config.Section("mailgun").Key("api_key").String(), emailer.config.proxyTransport)
} else if method == "dummy" {
emailer.sender = &DummyClient{}
}
@@ -161,7 +164,7 @@ func (emailer *Emailer) NewSMTP(server string, port int, username, password stri
var cert []byte
cert, err = os.ReadFile(certPath)
if rootCAs.AppendCertsFromPEM(cert) == false {
- err = errors.New("Failed to append cert to pool")
+ err = errors.New("failed to append cert to pool")
}
}
sender.Client.TLSConfig = &tls.Config{
@@ -243,22 +246,51 @@ type templ interface {
Execute(wr io.Writer, data interface{}) error
}
-func (emailer *Emailer) construct(app *appContext, section, keyFragment string, data map[string]interface{}) (html, text, markdown string, err error) {
+func (emailer *Emailer) construct(contentInfo CustomContentInfo, cc CustomContent, data map[string]any, msg *Message) error {
+ if cc.Enabled {
+ // Use template email, rather than the built-in's email file.
+ contentInfo.SourceFile = customContent["TemplateEmail"].SourceFile
+ content, err := templateEmail(cc.Content, contentInfo.Variables, contentInfo.Conditionals, data)
+ if err != nil {
+ emailer.err.Printf(lm.FailedConstructCustomContent, msg.Subject, err)
+ return err
+ }
+ html := markdown.ToHTML([]byte(content), nil, markdownRenderer)
+ text := stripMarkdown(content)
+ templateData := map[string]interface{}{
+ "text": template.HTML(html),
+ "plaintext": text,
+ "md": content,
+ }
+ if message, ok := data["message"]; ok {
+ templateData["message"] = message
+ }
+ data = templateData
+ }
+ var err error = nil
+ // Template the subject for bonus points
+ if subject, err := templateEmail(msg.Subject, contentInfo.Variables, contentInfo.Conditionals, data); err == nil {
+ msg.Subject = subject
+ }
+
var tpl templ
+ msg.Text = ""
+ msg.Markdown = ""
+ msg.HTML = ""
if substituteStrings == "" {
data["jellyfin"] = "Jellyfin"
} else {
data["jellyfin"] = substituteStrings
}
var keys []string
- plaintext := app.config.Section("email").Key("plaintext").MustBool(false)
+ plaintext := emailer.config.Section("email").Key("plaintext").MustBool(false)
if plaintext {
if telegramEnabled || discordEnabled {
keys = []string{"text"}
- text, markdown = "", ""
+ msg.Text, msg.Markdown = "", ""
} else {
keys = []string{"text"}
- text = ""
+ msg.Text = ""
}
} else {
if telegramEnabled || discordEnabled {
@@ -271,9 +303,9 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string,
var filesystem fs.FS
var fpath string
if key == "markdown" {
- filesystem, fpath = app.GetPath(section, keyFragment+"text")
+ filesystem, fpath = emailer.config.GetPath(contentInfo.SourceFile.Section, contentInfo.SourceFile.SettingPrefix+"text")
} else {
- filesystem, fpath = app.GetPath(section, keyFragment+key)
+ filesystem, fpath = emailer.config.GetPath(contentInfo.SourceFile.Section, contentInfo.SourceFile.SettingPrefix+key)
}
if key == "html" {
tpl, err = template.ParseFS(filesystem, fpath)
@@ -281,7 +313,7 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string,
tpl, err = textTemplate.ParseFS(filesystem, fpath)
}
if err != nil {
- return
+ return fmt.Errorf("error reading from fs path \"%s\": %v", fpath, err)
}
// For constructTemplate, if "md" is found in data it's used in stead of "text".
foundMarkdown := false
@@ -294,742 +326,296 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string,
var tplData bytes.Buffer
err = tpl.Execute(&tplData, data)
if err != nil {
- return
+ return err
}
if foundMarkdown {
data["plaintext"], data["md"] = data["md"], data["plaintext"]
}
if key == "html" {
- html = tplData.String()
+ msg.HTML = tplData.String()
} else if key == "text" {
- text = tplData.String()
+ msg.Text = tplData.String()
} else {
- markdown = tplData.String()
+ msg.Markdown = tplData.String()
}
}
- return
+ return nil
}
-func (emailer *Emailer) confirmationValues(code, username, key string, app *appContext, noSub bool) map[string]interface{} {
- template := map[string]interface{}{
+func (emailer *Emailer) baseValues(name string, username string, placeholders bool, values map[string]any) (CustomContentInfo, map[string]any, *Message) {
+ contentInfo := customContent[name]
+ template := map[string]any{
+ "username": username,
+ "message": emailer.config.Section("messages").Key("message").String(),
+ }
+ maps.Copy(template, values)
+ // When generating a version for the user to customise, we'll replace "variable" with "{variable}", so the templater used for custom content understands them.
+ if placeholders {
+ for _, v := range contentInfo.Variables {
+ template[v] = "{" + v + "}"
+ }
+ }
+ email := &Message{
+ Subject: contentInfo.Subject(emailer.config, &emailer.lang),
+ }
+ return contentInfo, template, email
+}
+
+func (emailer *Emailer) constructConfirmation(code, username, key string, placeholders bool) (*Message, error) {
+ if placeholders {
+ username = "{username}"
+ }
+ contentInfo, template, msg := emailer.baseValues("EmailConfirmation", username, placeholders, map[string]any{
+ "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
"clickBelow": emailer.lang.EmailConfirmation.get("clickBelow"),
"ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"),
"confirmEmail": emailer.lang.EmailConfirmation.get("confirmEmail"),
- "message": "",
- "username": username,
- }
- if noSub {
- template["helloUser"] = emailer.lang.Strings.get("helloUser")
- empty := []string{"confirmationURL"}
- for _, v := range empty {
- template[v] = "{" + v + "}"
- }
- } else {
- message := app.config.Section("messages").Key("message").String()
- inviteLink := app.ExternalURI(nil)
+ })
+ if !placeholders {
+ inviteLink := ExternalURI(nil)
if code == "" { // Personal email change
inviteLink = fmt.Sprintf("%s/my/confirm/%s", inviteLink, url.PathEscape(key))
} else { // Invite email confirmation
inviteLink = fmt.Sprintf("%s%s/%s?key=%s", inviteLink, PAGES.Form, code, url.PathEscape(key))
}
- template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username})
template["confirmationURL"] = inviteLink
- template["message"] = message
}
- return template
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ err := emailer.construct(contentInfo, cc, template, msg)
+ return msg, err
}
-func (emailer *Emailer) constructConfirmation(code, username, key string, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.EmailConfirmation.get("title")),
- }
- var err error
- template := emailer.confirmationValues(code, username, key, app, noSub)
- message := app.storage.MustGetCustomContentKey("EmailConfirmation")
- if message.Enabled {
- var content string
- content, err = templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- if err != nil {
- app.err.Printf(lm.FailedConstructCustomContent, emailer.lang.EmailConfirmation.get("title"), err)
- }
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "email_confirmation", "email_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-// username is optional, but should only be passed once.
-func (emailer *Emailer) constructTemplate(subject, md string, app *appContext, username ...string) (*Message, error) {
- var err error
- if len(username) != 0 {
- md, err = templateEmail(md, []string{"{username}"}, nil, map[string]interface{}{"username": username[0]})
- if err != nil {
- app.err.Printf(lm.FailedConstructCustomContent, "Template", err)
- }
- subject, err = templateEmail(subject, []string{"{username}"}, nil, map[string]interface{}{"username": username[0]})
- if err != nil {
- app.err.Printf(lm.FailedConstructCustomContent, "Template", err)
- }
- }
- if err != nil {
- return nil, err
- }
- email := &Message{Subject: subject}
- html := markdown.ToHTML([]byte(md), nil, markdownRenderer)
- text := stripMarkdown(md)
- message := app.config.Section("messages").Key("message").String()
- data := map[string]interface{}{
- "text": template.HTML(html),
- "plaintext": text,
- "message": message,
- "md": md,
- }
- if len(username) != 0 {
- data["username"] = username[0]
- }
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "template_email", "email_", data)
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-func (emailer *Emailer) inviteValues(code string, invite Invite, app *appContext, noSub bool) map[string]interface{} {
+func (emailer *Emailer) constructInvite(invite Invite, placeholders bool) (*Message, error) {
expiry := invite.ValidTill
- d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern)
- message := app.config.Section("messages").Key("message").String()
- inviteLink := fmt.Sprintf("%s%s/%s", app.ExternalURI(nil), PAGES.Form, code)
- template := map[string]interface{}{
+ d, t, expiresIn := emailer.formatExpiry(expiry, false)
+ inviteLink := fmt.Sprintf("%s%s/%s", ExternalURI(nil), PAGES.Form, invite.Code)
+ contentInfo, template, msg := emailer.baseValues("InviteEmail", "", placeholders, map[string]any{
"hello": emailer.lang.InviteEmail.get("hello"),
"youHaveBeenInvited": emailer.lang.InviteEmail.get("youHaveBeenInvited"),
"toJoin": emailer.lang.InviteEmail.get("toJoin"),
"linkButton": emailer.lang.InviteEmail.get("linkButton"),
- "message": "",
"date": d,
"time": t,
"expiresInMinutes": expiresIn,
+ "inviteURL": inviteLink,
+ "inviteExpiry": emailer.lang.InviteEmail.get("inviteExpiry"),
+ })
+ if !placeholders {
+ template["inviteExpiry"] = emailer.lang.InviteEmail.template("inviteExpiry", template)
}
- if noSub {
- template["inviteExpiry"] = emailer.lang.InviteEmail.get("inviteExpiry")
- empty := []string{"inviteURL"}
- for _, v := range empty {
- template[v] = "{" + v + "}"
- }
- } else {
- template["inviteExpiry"] = emailer.lang.InviteEmail.template("inviteExpiry", tmpl{"date": d, "time": t, "expiresInMinutes": expiresIn})
- template["inviteURL"] = inviteLink
- template["message"] = message
- }
- return template
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ err := emailer.construct(contentInfo, cc, template, msg)
+ return msg, err
}
-func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: app.config.Section("invite_emails").Key("subject").MustString(emailer.lang.InviteEmail.get("title")),
- }
- template := emailer.inviteValues(code, invite, app, noSub)
- var err error
- message := app.storage.MustGetCustomContentKey("InviteEmail")
- if message.Enabled {
- var content string
- content, err = templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- if err != nil {
- app.err.Printf(lm.FailedConstructCustomContent, emailer.lang.InviteEmail.get("title"), err)
- }
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "invite_emails", "email_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-func (emailer *Emailer) expiryValues(code string, invite Invite, app *appContext, noSub bool) map[string]interface{} {
- expiry := app.formatDatetime(invite.ValidTill)
- template := map[string]interface{}{
+func (emailer *Emailer) constructExpiry(invite Invite, placeholders bool) (*Message, error) {
+ expiry := formatDatetime(invite.ValidTill)
+ contentInfo, template, msg := emailer.baseValues("InviteExpiry", "", placeholders, map[string]any{
"inviteExpired": emailer.lang.InviteExpiry.get("inviteExpired"),
"notificationNotice": emailer.lang.InviteExpiry.get("notificationNotice"),
- "code": "\"" + code + "\"",
+ "expiredAt": emailer.lang.InviteExpiry.get("expiredAt"),
+ "code": "\"" + invite.Code + "\"",
"time": expiry,
+ })
+ if !placeholders {
+ template["expiredAt"] = emailer.lang.InviteExpiry.template("expiredAt", template)
}
- if noSub {
- template["expiredAt"] = emailer.lang.InviteExpiry.get("expiredAt")
- } else {
- template["expiredAt"] = emailer.lang.InviteExpiry.template("expiredAt", tmpl{"code": template["code"].(string), "time": template["time"].(string)})
- }
- return template
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ err := emailer.construct(contentInfo, cc, template, msg)
+ return msg, err
}
-func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: emailer.lang.InviteExpiry.get("title"),
- }
- var err error
- template := emailer.expiryValues(code, invite, app, noSub)
- message := app.storage.MustGetCustomContentKey("InviteExpiry")
- if message.Enabled {
- var content string
- content, err = templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- if err != nil {
- app.err.Printf(lm.FailedConstructCustomContent, emailer.lang.InviteExpiry.get("title"), err)
- }
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "notifications", "expiry_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-func (emailer *Emailer) createdValues(code, username, address string, invite Invite, app *appContext, noSub bool) map[string]interface{} {
- template := map[string]interface{}{
+func (emailer *Emailer) constructCreated(username, address string, when time.Time, invite Invite, placeholders bool) (*Message, error) {
+ // NOTE: This was previously invite.Created, not sure why.
+ created := formatDatetime(when)
+ contentInfo, template, msg := emailer.baseValues("UserCreated", username, placeholders, map[string]any{
+ "aUserWasCreated": emailer.lang.UserCreated.get("aUserWasCreated"),
"nameString": emailer.lang.Strings.get("name"),
"addressString": emailer.lang.Strings.get("emailAddress"),
"timeString": emailer.lang.UserCreated.get("time"),
- "notificationNotice": "",
- "code": "\"" + code + "\"",
- }
- if noSub {
- template["aUserWasCreated"] = emailer.lang.UserCreated.get("aUserWasCreated")
- empty := []string{"name", "address", "time"}
- for _, v := range empty {
- template[v] = "{" + v + "}"
+ "notificationNotice": emailer.lang.UserCreated.get("notificationNotice"),
+ "code": "\"" + invite.Code + "\"",
+ "name": username,
+ "time": created,
+ "address": address,
+ })
+ if !placeholders {
+ template["aUserWasCreated"] = emailer.lang.UserCreated.template("aUserWasCreated", template)
+ if emailer.config.Section("email").Key("no_username").MustBool(false) {
+ template["address"] = "n/a"
}
- } else {
- created := app.formatDatetime(invite.Created)
- var tplAddress string
- if app.config.Section("email").Key("no_username").MustBool(false) {
- tplAddress = "n/a"
- } else {
- tplAddress = address
- }
- template["aUserWasCreated"] = emailer.lang.UserCreated.template("aUserWasCreated", tmpl{"code": template["code"].(string)})
- template["name"] = username
- template["address"] = tplAddress
- template["time"] = created
- template["notificationNotice"] = emailer.lang.UserCreated.get("notificationNotice")
}
- return template
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ err := emailer.construct(contentInfo, cc, template, msg)
+ return msg, err
}
-func (emailer *Emailer) constructCreated(code, username, address string, invite Invite, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: emailer.lang.UserCreated.get("title"),
+func (emailer *Emailer) constructReset(pwr PasswordReset, placeholders bool) (*Message, error) {
+ if placeholders {
+ pwr.Username = "{username}"
}
- template := emailer.createdValues(code, username, address, invite, app, noSub)
- var err error
- message := app.storage.MustGetCustomContentKey("UserCreated")
- if message.Enabled {
- var content string
- content, err = templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- if err != nil {
- app.err.Printf(lm.FailedConstructCustomContent, emailer.lang.UserCreated.get("title"), err)
- }
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "notifications", "created_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bool) map[string]interface{} {
- d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern)
- message := app.config.Section("messages").Key("message").String()
- template := map[string]interface{}{
+ d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true)
+ linkResetEnabled := emailer.config.Section("password_resets").Key("link_reset").MustBool(false)
+ contentInfo, template, msg := emailer.baseValues("PasswordReset", pwr.Username, placeholders, map[string]any{
+ "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": pwr.Username}),
"someoneHasRequestedReset": emailer.lang.PasswordReset.get("someoneHasRequestedReset"),
+ "ifItWasYou": emailer.lang.PasswordReset.get("ifItWasYou"),
"ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"),
"pinString": emailer.lang.PasswordReset.get("pin"),
- "link_reset": false,
- "message": "",
- "username": pwr.Username,
+ "codeExpiry": emailer.lang.PasswordReset.get("codeExpiry"),
+ "link_reset": linkResetEnabled && !placeholders,
"date": d,
"time": t,
"expiresInMinutes": expiresIn,
- }
- linkResetEnabled := app.config.Section("password_resets").Key("link_reset").MustBool(false)
+ "pin": pwr.Pin,
+ })
if linkResetEnabled {
template["ifItWasYou"] = emailer.lang.PasswordReset.get("ifItWasYouLink")
- } else {
- template["ifItWasYou"] = emailer.lang.PasswordReset.get("ifItWasYou")
}
- if noSub {
- template["helloUser"] = emailer.lang.Strings.get("helloUser")
- template["codeExpiry"] = emailer.lang.PasswordReset.get("codeExpiry")
- empty := []string{"pin"}
- for _, v := range empty {
- template[v] = "{" + v + "}"
- }
- } else {
- template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": pwr.Username})
- template["codeExpiry"] = emailer.lang.PasswordReset.template("codeExpiry", tmpl{"date": d, "time": t, "expiresInMinutes": expiresIn})
+ if !placeholders {
+ template["codeExpiry"] = emailer.lang.PasswordReset.template("codeExpiry", template)
if linkResetEnabled {
- pinLink, err := app.GenResetLink(pwr.Pin)
- if err == nil {
- // Strip /invite form end of this URL, ik its ugly.
- template["link_reset"] = true
+ pinLink, err := GenResetLink(pwr.Pin)
+ if err != nil {
+ template["link_reset"] = false
+ emailer.info.Printf(lm.FailedGeneratePWRLink, err)
+ } else {
template["pin"] = pinLink
// Only used in html email.
template["pin_code"] = pwr.Pin
- } else {
- app.info.Printf(lm.FailedGeneratePWRLink, err)
- template["pin"] = pwr.Pin
}
- } else {
- template["pin"] = pwr.Pin
}
- template["message"] = message
}
- return template
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ err := emailer.construct(contentInfo, cc, template, msg)
+ return msg, err
}
-func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: app.config.Section("password_resets").Key("subject").MustString(emailer.lang.PasswordReset.get("title")),
+func (emailer *Emailer) constructDeleted(reason string, placeholders bool) (*Message, error) {
+ if placeholders {
+ reason = "{reason}"
}
- template := emailer.resetValues(pwr, app, noSub)
- var err error
- message := app.storage.MustGetCustomContentKey("PasswordReset")
- if message.Enabled {
- var content string
- content, err = templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- if err != nil {
- app.err.Printf(lm.FailedConstructCustomContent, app.config.Section("password_resets").Key("subject").MustString(emailer.lang.PasswordReset.get("title")), err)
- }
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "password_resets", "email_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-func (emailer *Emailer) deletedValues(reason string, app *appContext, noSub bool) map[string]interface{} {
- template := map[string]interface{}{
+ contentInfo, template, msg := emailer.baseValues("UserDeleted", "", placeholders, map[string]any{
"yourAccountWas": emailer.lang.UserDeleted.get("yourAccountWasDeleted"),
"reasonString": emailer.lang.Strings.get("reason"),
- "message": "",
- }
- if noSub {
- empty := []string{"reason"}
- for _, v := range empty {
- template[v] = "{" + v + "}"
- }
- } else {
- template["reason"] = reason
- template["message"] = app.config.Section("messages").Key("message").String()
- }
- return template
+ "reason": reason,
+ })
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ err := emailer.construct(contentInfo, cc, template, msg)
+ return msg, err
}
-func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: app.config.Section("deletion").Key("subject").MustString(emailer.lang.UserDeleted.get("title")),
+func (emailer *Emailer) constructDisabled(reason string, placeholders bool) (*Message, error) {
+ if placeholders {
+ reason = "{reason}"
}
- var err error
- template := emailer.deletedValues(reason, app, noSub)
- message := app.storage.MustGetCustomContentKey("UserDeleted")
- if message.Enabled {
- var content string
- content, err = templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- if err != nil {
- app.err.Printf(lm.FailedConstructCustomContent, app.config.Section("deletion").Key("subject").MustString(emailer.lang.UserDeleted.get("title")), err)
- }
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "deletion", "email_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-func (emailer *Emailer) disabledValues(reason string, app *appContext, noSub bool) map[string]interface{} {
- template := map[string]interface{}{
+ contentInfo, template, msg := emailer.baseValues("UserDeleted", "", placeholders, map[string]any{
"yourAccountWas": emailer.lang.UserDisabled.get("yourAccountWasDisabled"),
"reasonString": emailer.lang.Strings.get("reason"),
- "message": "",
- }
- if noSub {
- empty := []string{"reason"}
- for _, v := range empty {
- template[v] = "{" + v + "}"
- }
- } else {
- template["reason"] = reason
- template["message"] = app.config.Section("messages").Key("message").String()
- }
- return template
+ "reason": reason,
+ })
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ err := emailer.construct(contentInfo, cc, template, msg)
+ return msg, err
}
-func (emailer *Emailer) constructDisabled(reason string, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: app.config.Section("disable_enable").Key("subject_disabled").MustString(emailer.lang.UserDisabled.get("title")),
+func (emailer *Emailer) constructEnabled(reason string, placeholders bool) (*Message, error) {
+ if placeholders {
+ reason = "{reason}"
}
- var err error
- template := emailer.disabledValues(reason, app, noSub)
- message := app.storage.MustGetCustomContentKey("UserDisabled")
- if message.Enabled {
- var content string
- content, err = templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- if err != nil {
- app.err.Printf(lm.FailedConstructCustomContent, app.config.Section("disable_enable").Key("subject_disabled").MustString(emailer.lang.UserDisabled.get("title")), err)
- }
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "disable_enable", "disabled_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-func (emailer *Emailer) enabledValues(reason string, app *appContext, noSub bool) map[string]interface{} {
- template := map[string]interface{}{
+ contentInfo, template, msg := emailer.baseValues("UserDeleted", "", placeholders, map[string]any{
"yourAccountWas": emailer.lang.UserEnabled.get("yourAccountWasEnabled"),
"reasonString": emailer.lang.Strings.get("reason"),
- "message": "",
- }
- if noSub {
- empty := []string{"reason"}
- for _, v := range empty {
- template[v] = "{" + v + "}"
- }
- } else {
- template["reason"] = reason
- template["message"] = app.config.Section("messages").Key("message").String()
- }
- return template
+ "reason": reason,
+ })
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ err := emailer.construct(contentInfo, cc, template, msg)
+ return msg, err
}
-func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: app.config.Section("disable_enable").Key("subject_enabled").MustString(emailer.lang.UserEnabled.get("title")),
+func (emailer *Emailer) constructExpiryAdjusted(username string, expiry time.Time, reason string, placeholders bool) (*Message, error) {
+ if placeholders {
+ username = "{username}"
}
- var err error
- template := emailer.enabledValues(reason, app, noSub)
- message := app.storage.MustGetCustomContentKey("UserEnabled")
- if message.Enabled {
- var content string
- content, err = templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- if err != nil {
- app.err.Printf(lm.FailedConstructCustomContent, app.config.Section("disable_enable").Key("subject_enabled").MustString(emailer.lang.UserEnabled.get("title")), err)
- }
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "disable_enable", "enabled_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-func (emailer *Emailer) expiryAdjustedValues(username string, expiry time.Time, reason string, app *appContext, noSub bool, custom bool) map[string]interface{} {
- template := map[string]interface{}{
+ exp := formatDatetime(expiry)
+ contentInfo, template, msg := emailer.baseValues("UserExpiryAdjusted", username, placeholders, map[string]any{
+ "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
"yourExpiryWasAdjusted": emailer.lang.UserExpiryAdjusted.get("yourExpiryWasAdjusted"),
"ifPreviouslyDisabled": emailer.lang.UserExpiryAdjusted.get("ifPreviouslyDisabled"),
"reasonString": emailer.lang.Strings.get("reason"),
- "newExpiry": "",
- "message": "",
- }
- if noSub {
- template["helloUser"] = emailer.lang.Strings.get("helloUser")
- empty := []string{"reason", "newExpiry"}
- for _, v := range empty {
- template[v] = "{" + v + "}"
- }
- } else {
- template["reason"] = reason
- template["message"] = app.config.Section("messages").Key("message").String()
- template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username})
- exp := app.formatDatetime(expiry)
- if !expiry.IsZero() {
- if custom {
- template["newExpiry"] = exp
- } else if !expiry.IsZero() {
- template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
- "date": exp,
- })
- }
+ "reason": reason,
+ "newExpiry": exp,
+ })
+ cc := emailer.storage.MustGetCustomContentKey("UserExpiryAdjusted")
+ if !placeholders {
+ if !cc.Enabled {
+ template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
+ "date": exp,
+ })
}
}
- return template
+ err := emailer.construct(contentInfo, cc, template, msg)
+ return msg, err
}
-func (emailer *Emailer) constructExpiryAdjusted(username string, expiry time.Time, reason string, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: app.config.Section("user_expiry").Key("adjustment_subject").MustString(emailer.lang.UserExpiryAdjusted.get("title")),
+func (emailer *Emailer) constructExpiryReminder(username string, expiry time.Time, placeholders bool) (*Message, error) {
+ if placeholders {
+ username = "{username}"
}
- var err error
- var template map[string]interface{}
- message := app.storage.MustGetCustomContentKey("UserExpiryAdjusted")
- if message.Enabled {
- template = emailer.expiryAdjustedValues(username, expiry, reason, app, noSub, true)
- } else {
- template = emailer.expiryAdjustedValues(username, expiry, reason, app, noSub, false)
- }
- if noSub {
- template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
- "date": "{newExpiry}",
- })
- }
- if message.Enabled {
- var content string
- content, err = templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- if err != nil {
- app.err.Printf(lm.FailedConstructCustomContent, app.config.Section("user_expiry").Key("adjustment_subject").MustString(emailer.lang.UserExpiryAdjusted.get("title")), err)
- }
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "user_expiry", "adjustment_email_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-func (emailer *Emailer) expiryReminderValues(username string, expiry time.Time, app *appContext, noSub bool, custom bool) map[string]interface{} {
- template := map[string]interface{}{
+ d, t, expiresIn := emailer.formatExpiry(expiry, false)
+ contentInfo, template, msg := emailer.baseValues("ExpiryReminder", username, placeholders, map[string]any{
+ "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}),
"yourAccountIsDueToExpire": emailer.lang.ExpiryReminder.get("yourAccountIsDueToExpire"),
- "expiresIn": "",
- "date": "",
- "time": "",
- "message": "",
- }
- if noSub {
- template["helloUser"] = emailer.lang.Strings.get("helloUser")
- empty := []string{"date", "expiresIn"}
- for _, v := range empty {
- template[v] = "{" + v + "}"
- }
- } else {
- template["message"] = app.config.Section("messages").Key("message").String()
- template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username})
- d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern)
- if !expiry.IsZero() {
- if custom {
- template["expiresIn"] = expiresIn
- template["date"] = d
- template["time"] = t
- } else if !expiry.IsZero() {
- template["yourAccountIsDueToExpire"] = emailer.lang.ExpiryReminder.template("yourAccountIsDueToExpire", tmpl{
- "expiresIn": expiresIn,
- "date": d,
- "time": t,
- })
- }
+ "expiresIn": expiresIn,
+ "date": d,
+ "time": t,
+ })
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ if !placeholders {
+ if !cc.Enabled && !expiry.IsZero() {
+ template["yourAccountIsDueToExpire"] = emailer.lang.ExpiryReminder.template("yourAccountIsDueToExpire", template)
}
}
- return template
+ err := emailer.construct(contentInfo, cc, template, msg)
+ return msg, err
}
-func (emailer *Emailer) constructExpiryReminder(username string, expiry time.Time, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: app.config.Section("user_expiry").Key("reminder_subject").MustString(emailer.lang.ExpiryReminder.get("title")),
+func (emailer *Emailer) constructWelcome(username string, expiry time.Time, placeholders bool) (*Message, error) {
+ var exp any = formatDatetime(expiry)
+ if placeholders {
+ username = "{username}"
+ exp = "{yourAccountWillExpire}"
}
- var err error
- var template map[string]interface{}
- message := app.storage.MustGetCustomContentKey("ExpiryReminder")
- if message.Enabled {
- template = emailer.expiryReminderValues(username, expiry, app, noSub, true)
- } else {
- template = emailer.expiryReminderValues(username, expiry, app, noSub, false)
- }
- /*if noSub {
- template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{
- "date": "{newExpiry}",
- })
- }*/
- if message.Enabled {
- var content string
- content, err = templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- if err != nil {
- app.err.Printf(lm.FailedConstructCustomContent, app.config.Section("user_expiry").Key("reminder_subject").MustString(emailer.lang.ExpiryReminder.get("title")), err)
- }
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "user_expiry", "reminder_email_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
-}
-
-func (emailer *Emailer) welcomeValues(username string, expiry time.Time, app *appContext, noSub bool, custom bool) map[string]interface{} {
- template := map[string]interface{}{
- "welcome": emailer.lang.WelcomeEmail.get("welcome"),
- "youCanLoginWith": emailer.lang.WelcomeEmail.get("youCanLoginWith"),
- "jellyfinURLString": emailer.lang.WelcomeEmail.get("jellyfinURL"),
- "usernameString": emailer.lang.Strings.get("username"),
- "message": "",
- "yourAccountWillExpire": "",
- }
- if noSub {
- empty := []string{"jellyfinURL", "username", "yourAccountWillExpire"}
- for _, v := range empty {
- template[v] = "{" + v + "}"
- }
- } else {
- template["jellyfinURL"] = app.config.Section("jellyfin").Key("public_server").String()
- template["username"] = username
- template["message"] = app.config.Section("messages").Key("message").String()
- exp := app.formatDatetime(expiry)
- if !expiry.IsZero() {
- if custom {
- template["yourAccountWillExpire"] = exp
- } else if !expiry.IsZero() {
- template["yourAccountWillExpire"] = emailer.lang.WelcomeEmail.template("yourAccountWillExpire", tmpl{
- "date": exp,
- })
- }
- }
- }
- return template
-}
-
-func (emailer *Emailer) constructWelcome(username string, expiry time.Time, app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: app.config.Section("welcome_email").Key("subject").MustString(emailer.lang.WelcomeEmail.get("title")),
- }
- var err error
- var template map[string]interface{}
- message := app.storage.MustGetCustomContentKey("WelcomeEmail")
- if message.Enabled {
- template = emailer.welcomeValues(username, expiry, app, noSub, true)
- } else {
- template = emailer.welcomeValues(username, expiry, app, noSub, false)
- }
- if noSub {
+ contentInfo, template, msg := emailer.baseValues("WelcomeEmail", username, placeholders, map[string]any{
+ "welcome": emailer.lang.WelcomeEmail.get("welcome"),
+ "youCanLoginWith": emailer.lang.WelcomeEmail.get("youCanLoginWith"),
+ "jellyfinURLString": emailer.lang.WelcomeEmail.get("jellyfinURL"),
+ "jellyfinURL": emailer.config.Section("jellyfin").Key("public_server").String(),
+ "usernameString": emailer.lang.Strings.get("username"),
+ })
+ if !expiry.IsZero() || placeholders {
template["yourAccountWillExpire"] = emailer.lang.WelcomeEmail.template("yourAccountWillExpire", tmpl{
- "date": "{yourAccountWillExpire}",
+ "date": exp,
})
}
- if message.Enabled {
- var content string
- content, err = templateEmail(
- message.Content,
- message.Variables,
- message.Conditionals,
- template,
- )
- if err != nil {
- app.err.Printf(lm.FailedConstructCustomContent, app.config.Section("welcome_email").Key("subject").MustString(emailer.lang.WelcomeEmail.get("title")), err)
+ cc := emailer.storage.MustGetCustomContentKey("WelcomeEmail")
+ if !placeholders {
+ if cc.Enabled && !expiry.IsZero() {
+ template["yourAccountWillExpire"] = exp
}
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "welcome_email", "email_", template)
}
- if err != nil {
- return nil, err
- }
- return email, nil
+ err := emailer.construct(contentInfo, cc, template, msg)
+ return msg, err
}
-func (emailer *Emailer) userExpiredValues(app *appContext, noSub bool) map[string]interface{} {
- template := map[string]interface{}{
+func (emailer *Emailer) constructUserExpired(placeholders bool) (*Message, error) {
+ contentInfo, template, msg := emailer.baseValues("UserExpired", "", placeholders, map[string]any{
"yourAccountHasExpired": emailer.lang.UserExpired.get("yourAccountHasExpired"),
"contactTheAdmin": emailer.lang.UserExpired.get("contactTheAdmin"),
- "message": "",
- }
- if !noSub {
- template["message"] = app.config.Section("messages").Key("message").String()
- }
- return template
-}
-
-func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Message, error) {
- email := &Message{
- Subject: app.config.Section("user_expiry").Key("subject").MustString(emailer.lang.UserExpired.get("title")),
- }
- var err error
- template := emailer.userExpiredValues(app, noSub)
- message := app.storage.MustGetCustomContentKey("UserExpired")
- if message.Enabled {
- var content string
- content, err = templateEmail(
- message.Content,
- message.Variables,
- nil,
- template,
- )
- if err != nil {
- app.err.Printf(lm.FailedConstructCustomContent, app.config.Section("user_expiry").Key("subject").MustString(emailer.lang.UserExpired.get("title")), err)
- }
- email, err = emailer.constructTemplate(email.Subject, content, app)
- } else {
- email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "user_expiry", "email_", template)
- }
- if err != nil {
- return nil, err
- }
- return email, nil
+ })
+ cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name)
+ err := emailer.construct(contentInfo, cc, template, msg)
+ return msg, err
}
// calls the send method in the underlying emailClient.
diff --git a/email_test.go b/email_test.go
new file mode 100644
index 0000000..0e33225
--- /dev/null
+++ b/email_test.go
@@ -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)
+ }
+ }
+ })
+ })
+}
diff --git a/external.go b/external.go
index b493317..df69e94 100644
--- a/external.go
+++ b/external.go
@@ -4,7 +4,6 @@
package main
import (
- "io/fs"
"log"
"os"
"path/filepath"
@@ -15,9 +14,6 @@ const binaryType = "external"
func BuildTagsExternal() { buildTags = append(buildTags, "external") }
-var localFS dirFS
-var langFS dirFS
-
// When using os.DirFS, even on Windows the separator seems to be '/'.
// func FSJoin(elem ...string) string { return filepath.Join(elem...) }
func FSJoin(elem ...string) string {
@@ -32,20 +28,6 @@ func FSJoin(elem ...string) string {
return strings.TrimSuffix(path, sep)
}
-type dirFS string
-
-func (dir dirFS) Open(name string) (fs.File, error) {
- return os.Open(string(dir) + "/" + name)
-}
-
-func (dir dirFS) ReadFile(name string) ([]byte, error) {
- return os.ReadFile(string(dir) + "/" + name)
-}
-
-func (dir dirFS) ReadDir(name string) ([]fs.DirEntry, error) {
- return os.ReadDir(string(dir) + "/" + name)
-}
-
func loadFilesystems() {
log.Println("Using external storage")
executable, _ := os.Executable()
diff --git a/fs.go b/fs.go
new file mode 100644
index 0000000..02f3dff
--- /dev/null
+++ b/fs.go
@@ -0,0 +1,29 @@
+package main
+
+import (
+ "io/fs"
+ "os"
+)
+
+type genericFS interface {
+ fs.FS
+ fs.ReadDirFS
+ fs.ReadFileFS
+}
+
+var localFS genericFS
+var langFS genericFS
+
+type dirFS string
+
+func (dir dirFS) Open(name string) (fs.File, error) {
+ return os.Open(string(dir) + "/" + name)
+}
+
+func (dir dirFS) ReadFile(name string) ([]byte, error) {
+ return os.ReadFile(string(dir) + "/" + name)
+}
+
+func (dir dirFS) ReadDir(name string) ([]fs.DirEntry, error) {
+ return os.ReadDir(string(dir) + "/" + name)
+}
diff --git a/internal.go b/internal.go
index 050e346..2f6046e 100644
--- a/internal.go
+++ b/internal.go
@@ -19,9 +19,6 @@ var loFS embed.FS
//go:embed lang/common lang/admin lang/email lang/form lang/setup lang/pwreset lang/telegram
var laFS embed.FS
-var langFS rewriteFS
-var localFS rewriteFS
-
type rewriteFS struct {
fs embed.FS
prefix string
diff --git a/lang.go b/lang.go
index a131296..f0a82b3 100644
--- a/lang.go
+++ b/lang.go
@@ -1,6 +1,10 @@
package main
-import "github.com/hrfee/jfa-go/common"
+import (
+ "fmt"
+
+ "github.com/hrfee/jfa-go/common"
+)
type langMeta struct {
Name string `json:"name"`
@@ -166,7 +170,7 @@ func (ts *telegramLangs) getOptions() []common.Option {
}
type langSection map[string]string
-type tmpl map[string]string
+type tmpl = map[string]any
func templateString(text string, vals tmpl) string {
start, previousEnd := -1, -1
@@ -183,7 +187,7 @@ func templateString(text string, vals tmpl) string {
start = -1
continue
}
- out += text[previousEnd+1:start] + val
+ out += text[previousEnd+1:start] + fmt.Sprint(val)
previousEnd = i
start = -1
}
diff --git a/logger/logger.go b/logger/logger.go
index 354d554..110a06c 100644
--- a/logger/logger.go
+++ b/logger/logger.go
@@ -21,7 +21,7 @@ import (
// }
type Logger struct {
- empty bool
+ Empty bool
logger *log.Logger
shortfile bool
printer *c.Color
@@ -75,13 +75,13 @@ func NewLogger(out io.Writer, prefix string, flag int, color c.Attribute) (l *Lo
func NewEmptyLogger() (l *Logger) {
l = &Logger{
- empty: true,
+ Empty: true,
}
return
}
func (l *Logger) Printf(format string, v ...interface{}) {
- if l.empty {
+ if l.Empty {
return
}
var out string
@@ -93,7 +93,7 @@ func (l *Logger) Printf(format string, v ...interface{}) {
}
func (l *Logger) PrintfCustomLevel(level int, format string, v ...interface{}) {
- if l.empty {
+ if l.Empty {
return
}
var out string
@@ -105,14 +105,14 @@ func (l *Logger) PrintfCustomLevel(level int, format string, v ...interface{}) {
}
func (l *Logger) PrintfNoFile(format string, v ...interface{}) {
- if l.empty {
+ if l.Empty {
return
}
l.logger.Print(l.printer.Sprintf(format, v...))
}
func (l *Logger) Print(v ...interface{}) {
- if l.empty {
+ if l.Empty {
return
}
var out string
@@ -124,7 +124,7 @@ func (l *Logger) Print(v ...interface{}) {
}
func (l *Logger) Println(v ...interface{}) {
- if l.empty {
+ if l.Empty {
return
}
var out string
@@ -136,7 +136,7 @@ func (l *Logger) Println(v ...interface{}) {
}
func (l *Logger) Fatal(v ...interface{}) {
- if l.empty {
+ if l.Empty {
return
}
var out string
@@ -148,7 +148,7 @@ func (l *Logger) Fatal(v ...interface{}) {
}
func (l *Logger) Fatalf(format string, v ...interface{}) {
- if l.empty {
+ if l.Empty {
return
}
var out string
diff --git a/main.go b/main.go
index 2993b3e..8c6c712 100644
--- a/main.go
+++ b/main.go
@@ -24,7 +24,6 @@ import (
"github.com/fatih/color"
"github.com/hrfee/jfa-go/common"
_ "github.com/hrfee/jfa-go/docs"
- "github.com/hrfee/jfa-go/easyproxy"
"github.com/hrfee/jfa-go/jellyseerr"
"github.com/hrfee/jfa-go/logger"
lm "github.com/hrfee/jfa-go/logmessages"
@@ -81,6 +80,11 @@ var serverTypes = map[string]string{
var serverType = mediabrowser.JellyfinServer
var substituteStrings = ""
+var externalURI, externalDomain string // The latter lower-case as should be accessed through app.ExternalDomain()
+var UseProxyHost bool
+
+var datePattern, timePattern string
+
// User is used for auth purposes.
type User struct {
UserID string `json:"id"`
@@ -88,10 +92,15 @@ type User struct {
Password string `json:"password"`
}
+// Set of the usual log channels, for ease of passing between things.
+type LoggerSet struct {
+ info, debug, err *logger.Logger
+}
+
// contains (almost) everything the application needs, essentially. This was a dumb design decision imo.
type appContext struct {
// defaults *Config
- config *ini.File
+ config *Config
configPath string
configBasePath string
configBase common.Config
@@ -103,39 +112,32 @@ type appContext struct {
adminUsers []User
invalidTokens []string
// Keeping jf name because I can't think of a better one
- jf *mediabrowser.MediaBrowser
- authJf *mediabrowser.MediaBrowser
- ombi *OmbiWrapper
- js *JellyseerrWrapper
- thirdPartyServices []ThirdPartyService
- datePattern string
- timePattern string
- storage Storage
- validator Validator
- email *Emailer
- telegram *TelegramDaemon
- discord *DiscordDaemon
- matrix *MatrixDaemon
- contactMethods []ContactMethodLinker
- info, debug, err *logger.Logger
- host string
- port int
- version string
- externalURI, externalDomain string // The latter lower-case as should be accessed through app.ExternalDomain()
- UseProxyHost bool
- updater *Updater
- webhooks *WebhookSender
- newUpdate bool // Whether whatever's in update is new.
- tag Tag
- update Update
- proxyEnabled bool
- proxyTransport *http.Transport
- proxyConfig easyproxy.ProxyConfig
- internalPWRs map[string]InternalPWR
- pwrCaptchas map[string]Captcha
- ConfirmationKeys map[string]map[string]ConfirmationKey // Map of invite code to jwt to request
- confirmationKeysLock sync.Mutex
- userCache *UserCache
+ jf *mediabrowser.MediaBrowser
+ authJf *mediabrowser.MediaBrowser
+ ombi *OmbiWrapper
+ js *JellyseerrWrapper
+ thirdPartyServices []ThirdPartyService
+ storage *Storage
+ validator Validator
+ email *Emailer
+ telegram *TelegramDaemon
+ discord *DiscordDaemon
+ matrix *MatrixDaemon
+ contactMethods []ContactMethodLinker
+ LoggerSet
+ host string
+ port int
+ version string
+ updater *Updater
+ webhooks *WebhookSender
+ newUpdate bool // Whether whatever's in update is new.
+ tag Tag
+ update Update
+ internalPWRs map[string]InternalPWR
+ pwrCaptchas map[string]Captcha
+ ConfirmationKeys map[string]map[string]ConfirmationKey // Map of invite code to jwt to request
+ confirmationKeysLock sync.Mutex
+ userCache *UserCache
}
func generateSecret(length int) (string, error) {
@@ -244,7 +246,9 @@ func start(asDaemon, firstCall bool) {
var debugMode bool
var address string
- if err := app.loadConfig(); err != nil {
+ var err error = nil
+ app.config, err = NewConfig(app.configPath, app.dataPath, app.LoggerSet)
+ if err != nil {
app.err.Fatalf(lm.FailedLoadConfig, app.configPath, err)
}
app.info.Printf(lm.LoadConfig, app.configPath)
@@ -262,12 +266,8 @@ func start(asDaemon, firstCall bool) {
}
if debugMode {
app.debug = logger.NewLogger(os.Stdout, "[DEBUG] ", log.Ltime|log.Lshortfile, color.FgYellow)
- // Bind debug log
- app.storage.debug = app.debug
- app.storage.logActions = generateLogActions(app.config)
} else {
app.debug = logger.NewEmptyLogger()
- app.storage.debug = nil
}
if *PPROF {
app.info.Print(warning("\n\nWARNING: Don't use pprof in production.\n\n"))
@@ -312,14 +312,17 @@ func start(asDaemon, firstCall bool) {
}()
}
- app.storage.lang.CommonPath = "common"
- app.storage.lang.UserPath = "form"
- app.storage.lang.AdminPath = "admin"
- app.storage.lang.EmailPath = "email"
- app.storage.lang.TelegramPath = "telegram"
- app.storage.lang.PasswordResetPath = "pwreset"
+ dbPath := filepath.Join(app.dataPath, "db")
+ if debugMode {
+ app.storage = NewStorage(dbPath, app.debug, generateLogActions(app.config))
+ } else {
+ app.storage = NewStorage(dbPath, app.debug, nil)
+ }
+
+ // Placed here, since storage.chosenXLang is set by this function.
+ app.config.ReloadDependents(app)
+
externalLang := app.config.Section("files").Key("lang_files").MustString("")
- var err error
if externalLang == "" {
err = app.storage.loadLang(langFS)
} else {
@@ -362,7 +365,7 @@ func start(asDaemon, firstCall bool) {
}
address = fmt.Sprintf("%s:%d", app.host, app.port)
- // NOTE: As of writing this, the order in app.thirdPartServices doesn't matter,
+ // NOTE: As of writing this, the order in app.thirdPartyServices doesn't matter,
// but in future it might (like app.contactMethods does), so append to the end!
if app.config.Section("ombi").Key("enabled").MustBool(false) {
app.ombi = &OmbiWrapper{}
@@ -391,10 +394,12 @@ func start(asDaemon, firstCall bool) {
}
- app.storage.db_path = filepath.Join(app.dataPath, "db")
app.loadPendingBackup()
- app.ConnectDB()
- defer app.storage.db.Close()
+ if err := app.storage.Connect(app.config); err != nil {
+ app.err.Fatalf(lm.FailedConnectDB, dbPath, err)
+ }
+ app.info.Printf(lm.ConnectDB, dbPath)
+ defer app.storage.Close()
// copy it to app.patchedConfig, and patch in settings from app.config, and language stuff.
app.PatchConfigBase()
@@ -475,10 +480,9 @@ func start(asDaemon, firstCall bool) {
time.Minute*time.Duration(app.config.Section("jellyfin").Key("web_cache_sync_timeout").MustInt()),
)
- // Since email depends on language, the email reload in loadConfig won't work first time.
+ // Since email depends on language, the email reload in NewConfig won't work first time.
// Email also handles its own proxying, as (SMTP atleast) doesn't use a HTTP transport.
- app.email = NewEmailer(app)
- app.loadStrftime()
+ app.email = NewEmailer(app.config, app.storage, app.LoggerSet)
var validatorConf ValidatorConf
@@ -579,13 +583,13 @@ func start(asDaemon, firstCall bool) {
)
// Updater proxy set in config.go, don't worry!
- if app.proxyEnabled {
- app.jf.SetTransport(app.proxyTransport)
+ if app.config.proxyConfig != nil {
+ app.jf.SetTransport(app.config.proxyTransport)
for _, c := range app.thirdPartyServices {
- c.SetTransport(app.proxyTransport)
+ c.SetTransport(app.config.proxyTransport)
}
for _, c := range app.contactMethods {
- c.SetTransport(app.proxyTransport)
+ c.SetTransport(app.config.proxyTransport)
}
}
} else {
@@ -601,7 +605,6 @@ func start(asDaemon, firstCall bool) {
app.host = "0.0.0.0"
}
address = fmt.Sprintf("%s:%d", app.host, app.port)
- app.storage.lang.SetupPath = "setup"
err := app.storage.loadLangSetup(langFS)
if err != nil {
app.info.Fatalf(lm.FailedLangLoad, err)
diff --git a/migrations.go b/migrations.go
index 1286537..254dbdf 100644
--- a/migrations.go
+++ b/migrations.go
@@ -81,7 +81,7 @@ func migrateEmailConfig(app *appContext) {
app.err.Fatalf("Failed to save config: %v", err)
return
}
- app.loadConfig()
+ app.ReloadConfig()
}
// Migrate pre-0.3.6 email settings to the new messages section.
@@ -245,7 +245,7 @@ func loadLegacyData(app *appContext) {
app.storage.customEmails_path = app.config.Section("files").Key("custom_emails").String()
app.storage.loadCustomEmails()
- app.MustSetValue("user_page", "enabled", "true")
+ app.config.MustSetValue("user_page", "enabled", "true")
if app.config.Section("user_page").Key("enabled").MustBool(false) {
app.storage.userPage_path = app.config.Section("files").Key("custom_user_page_content").String()
app.storage.loadUserPageContent()
diff --git a/pwreset.go b/pwreset.go
index 961b64a..9fa7053 100644
--- a/pwreset.go
+++ b/pwreset.go
@@ -29,8 +29,8 @@ func (app *appContext) GenInternalReset(userID string) (InternalPWR, error) {
}
// GenResetLink generates and returns a password reset link.
-func (app *appContext) GenResetLink(pin string) (string, error) {
- url := app.ExternalURI(nil)
+func GenResetLink(pin string) (string, error) {
+ url := ExternalURI(nil)
var pinLink string
if url == "" {
return pinLink, errors.New(lm.NoExternalHost)
@@ -104,7 +104,7 @@ func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
uid := user.ID
name := app.getAddressOrName(uid)
if name != "" {
- msg, err := app.email.constructReset(pwr, app, false)
+ msg, err := app.email.constructReset(pwr, false)
if err != nil {
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
diff --git a/scripts/scrape-custom-content-schema.py b/scripts/scrape-custom-content-schema.py
new file mode 100644
index 0000000..4a8c17c
--- /dev/null
+++ b/scripts/scrape-custom-content-schema.py
@@ -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))
+
diff --git a/storage.go b/storage.go
index 02eb6be..119ca89 100644
--- a/storage.go
+++ b/storage.go
@@ -15,10 +15,8 @@ import (
"github.com/hrfee/jfa-go/common"
"github.com/hrfee/jfa-go/jellyseerr"
"github.com/hrfee/jfa-go/logger"
- lm "github.com/hrfee/jfa-go/logmessages"
"github.com/hrfee/mediabrowser"
"github.com/timshannon/badgerhold/v4"
- "gopkg.in/ini.v1"
)
type discordStore map[string]DiscordUser
@@ -80,7 +78,7 @@ const (
type Storage struct {
debug *logger.Logger
- logActions map[string]DebugLogAction
+ logActions func(k string) DebugLogAction
timePattern string
@@ -104,6 +102,44 @@ type Storage struct {
lang Lang
}
+// NewStorage returns a new Storage object with values initialised.
+func NewStorage(dbPath string, debugLogger *logger.Logger, logActions func(k string) DebugLogAction) *Storage {
+ if debugLogger.Empty {
+ debugLogger = nil
+ }
+ st := &Storage{
+ debug: debugLogger,
+ logActions: logActions,
+ db_path: dbPath,
+ }
+ st.lang.CommonPath = "common"
+ st.lang.UserPath = "form"
+ st.lang.AdminPath = "admin"
+ st.lang.EmailPath = "email"
+ st.lang.TelegramPath = "telegram"
+ st.lang.PasswordResetPath = "pwreset"
+ st.lang.SetupPath = "setup"
+ return st
+}
+
+// Connect connects to the underlying data storage method (e.g. db).
+// Call Close() once finished.
+func (st *Storage) Connect(config *Config) error {
+ opts := badgerhold.DefaultOptions
+ // ValueLogFileSize is in bytes, so multiply by 1e6
+ opts.Options.ValueLogFileSize = config.Section("advanced").Key("value_log_size").MustInt64(256) * 1e6
+ opts.Dir = st.db_path
+ opts.ValueDir = st.db_path
+ var err error = nil
+ st.db, err = badgerhold.Open(opts)
+ return err
+}
+
+// Close shuts down the underlying data storage method (e.g. db).
+func (st *Storage) Close() error {
+ return st.db.Close()
+}
+
type StoreType int
// Used for debug logging of storage.
@@ -146,7 +182,7 @@ func (st *Storage) DebugWatch(storeType StoreType, key, mainData string) {
actionKey = "custom_content"
}
- logAction := st.logActions[actionKey]
+ logAction := st.logActions(actionKey)
if logAction == NoLog {
return
}
@@ -159,7 +195,7 @@ func (st *Storage) DebugWatch(storeType StoreType, key, mainData string) {
}
}
-func generateLogActions(c *ini.File) map[string]DebugLogAction {
+func generateLogActions(c *Config) func(k string) DebugLogAction {
m := map[string]DebugLogAction{}
for _, v := range []string{"emails", "discord", "telegram", "matrix", "invites", "announcements", "expirires", "profiles", "custom_content"} {
switch c.Section("advanced").Key("debug_log_" + v).MustString("none") {
@@ -171,21 +207,7 @@ func generateLogActions(c *ini.File) map[string]DebugLogAction {
m[v] = LogDeletion
}
}
- return m
-}
-
-func (app *appContext) ConnectDB() {
- opts := badgerhold.DefaultOptions
- // ValueLogFileSize is in bytes, so multiply by 1e6
- opts.Options.ValueLogFileSize = app.config.Section("advanced").Key("value_log_size").MustInt64(256) * 1e6
- opts.Dir = app.storage.db_path
- opts.ValueDir = app.storage.db_path
- db, err := badgerhold.Open(opts)
- if err != nil {
- app.err.Fatalf(lm.FailedConnectDB, app.storage.db_path, err)
- }
- app.storage.db = db
- app.info.Printf(lm.ConnectDB, app.storage.db_path)
+ return func(k string) DebugLogAction { return m[k] }
}
// GetEmails returns a copy of the store.
@@ -683,13 +705,34 @@ type customEmails struct {
ExpiryReminder CustomContent `json:"expiryReminder"`
}
+type CustomContentContext = int
+
+const (
+ CustomMessage CustomContentContext = iota
+ CustomCard
+ CustomTemplate
+)
+
+type ContentSourceFileInfo struct{ Section, SettingPrefix, DefaultValue string }
+
+// CustomContent stores information needed for creating custom jfa-go content, including emails and user messages.
+type CustomContentInfo struct {
+ Name string `json:"name" badgerhold:"key"`
+ DisplayName, Description func(dict *Lang, lang string) string
+ Subject func(config *Config, lang *emailLang) string
+ // Config section, the main part of the setting name (without "html" or "text"), and the default filename (without ".html" or ".txt").
+ SourceFile ContentSourceFileInfo
+ ContentType CustomContentContext `json:"type"`
+ Variables []string `json:"variables,omitempty"`
+ Conditionals []string `json:"conditionals,omitempty"`
+ Placeholders map[string]any `json:"values,omitempty"`
+}
+
// CustomContent stores customized versions of jfa-go content, including emails and user messages.
type CustomContent struct {
- Name string `json:"name" badgerhold:"key"`
- Enabled bool `json:"enabled,omitempty"`
- Content string `json:"content"`
- Variables []string `json:"variables,omitempty"`
- Conditionals []string `json:"conditionals,omitempty"`
+ Name string `json:"name" badgerhold:"key"`
+ Enabled bool `json:"enabled,omitempty"`
+ Content string `json:"content"`
}
type userPageContent struct {
diff --git a/updater.go b/updater.go
index 41bc19f..c9836e8 100644
--- a/updater.go
+++ b/updater.go
@@ -130,7 +130,7 @@ type Updater struct {
binary string
}
-func newUpdater(buildroneURL, namespace, repo, version, commit, buildType string) *Updater {
+func NewUpdater(buildroneURL, namespace, repo, version, commit, buildType string) *Updater {
// fmt.Printf(`Updater intializing with "%s", "%s", "%s", "%s", "%s", "%s"\n`, buildroneURL, namespace, repo, version, commit, buildType)
bType := off
tag := ""
diff --git a/user-d.go b/user-d.go
index 97a72d3..b605d97 100644
--- a/user-d.go
+++ b/user-d.go
@@ -81,7 +81,7 @@ func (app *appContext) checkUsers(remindBeforeExpiry *DayTimerSet) {
if name == "" {
continue
}
- msg, err := app.email.constructExpiryReminder(user.Name, expiry.Expiry, app, false)
+ msg, err := app.email.constructExpiryReminder(user.Name, expiry.Expiry, false)
if err != nil {
app.err.Printf(lm.FailedConstructExpiryReminderMessage, user.ID, err)
} else if err := app.sendByID(msg, user.ID); err != nil {
@@ -173,7 +173,7 @@ func (app *appContext) checkUsers(remindBeforeExpiry *DayTimerSet) {
if name == "" {
continue
}
- msg, err := app.email.constructUserExpired(app, false)
+ msg, err := app.email.constructUserExpired(false)
if err != nil {
app.err.Printf(lm.FailedConstructExpiryMessage, user.ID, err)
} else if err := app.sendByID(msg, user.ID); err != nil {
diff --git a/users.go b/users.go
index f981651..4fa9822 100644
--- a/users.go
+++ b/users.go
@@ -169,7 +169,7 @@ func (app *appContext) WelcomeNewUser(user mediabrowser.User, expiry time.Time)
if name == "" {
return
}
- msg, err := app.email.constructWelcome(user.Name, expiry, app, false)
+ msg, err := app.email.constructWelcome(user.Name, expiry, false)
if err != nil {
app.err.Printf(lm.FailedConstructWelcomeMessage, user.ID, err)
} else if err := app.sendByID(msg, user.ID); err != nil {
diff --git a/views.go b/views.go
index b932930..3609fc5 100644
--- a/views.go
+++ b/views.go
@@ -88,7 +88,7 @@ func (app *appContext) BasePageTemplateValues(gc *gin.Context, page Page, base g
pages := PagePathsDTO{
PagePaths: PAGES,
- ExternalURI: app.ExternalURI(gc),
+ ExternalURI: ExternalURI(gc),
TrueBase: PAGES.Base,
}
pages.Base = app.getURLBase(gc)
@@ -742,7 +742,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
discord := discordEnabled && app.config.Section("discord").Key("show_on_reg").MustBool(true)
matrix := matrixEnabled && app.config.Section("matrix").Key("show_on_reg").MustBool(true)
- userPageAddress := app.ExternalURI(gc) + PAGES.MyAccount
+ userPageAddress := ExternalURI(gc) + PAGES.MyAccount
fromUser := ""
if invite.ReferrerJellyfinID != "" {
@@ -810,14 +810,15 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
data["discordInviteLink"] = app.discord.InviteChannel.Name != ""
}
if msg, ok := app.storage.GetCustomContentKey("PostSignupCard"); ok && msg.Enabled {
+ cci := customContent["PostSignupCard"]
data["customSuccessCard"] = true
// We don't template here, since the username is only known after login.
templated, err := templateEmail(
msg.Content,
- msg.Variables,
- msg.Conditionals,
+ cci.Variables,
+ cci.Conditionals,
map[string]any{
- "username": "{username}",
+ "username": "{username}", // Value is subbed by webpage
"myAccountURL": userPageAddress,
},
)