From 5d49a56d946a630a77800c8a13993d745e542203 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sun, 3 Aug 2025 17:36:33 +0100 Subject: [PATCH 01/90] template: add tests, fix up easy holes should cope with double-braced blocks now (treating them the same as single-braced. templateEmail now returns an error, which should not be seen as catastrophic, but reports likely mistakes. --- template.go | 99 +++++++++++++++++++++++------------- template_test.go | 128 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 34 deletions(-) create mode 100644 template_test.go diff --git a/template.go b/template.go index 8458729..d0fadb8 100644 --- a/template.go +++ b/template.go @@ -1,6 +1,9 @@ package main -import "fmt" +import ( + "fmt" + "slices" +) func truthy(val interface{}) bool { switch v := val.(type) { @@ -18,43 +21,72 @@ func truthy(val interface{}) bool { // Variables should be written as {varName}. // If statements should be written as {if (!)varName}...{endif}. // Strings are true if != "", ints are true if != 0. -func templateEmail(content string, variables []string, conditionals []string, values map[string]interface{}) string { +// Errors returned are likely warnings only. +func templateEmail(content string, variables []string, conditionals []string, values map[string]interface{}) (string, error) { + // minimum length for templatable content (albeit just "{}" -> "") + if len(content) < 2 { + return content, nil + } ifStart, ifEnd := -1, -1 ifTrue := false invalidIf := false previousEnd := -2 - cStart, cEnd := -1, -1 + blockRawStart := -1 + blockContentStart, blockContentEnd := -1, -1 varStart, varEnd := -1, -1 varName := "" out := "" + var err error = nil + + oob := func(i int) bool { return i < 0 || i >= len(content) } + for i, c := range content { if c == '{' { - cStart = i + 1 - for content[cStart] == ' ' { - cStart++ + blockContentStart = i + 1 + blockRawStart = i + if content[i+1] == '{' { + err = fmt.Errorf(`double braces ("{{") at position %d, use single brace only`, i) + blockContentStart++ } - if content[cStart:cStart+3] == "if " { - varStart = cStart + 3 + for !oob(blockContentStart) && content[blockContentStart] == ' ' { + blockContentStart++ + } + if oob(blockContentStart) { + continue + } + if !oob(blockContentStart+3) && content[blockContentStart:blockContentStart+3] == "if " { + varStart = blockContentStart + 3 for content[varStart] == ' ' { varStart++ } } - if ifStart == -1 { + if ifStart == -1 && (oob(i-1) || content[i-1] != '{') { out += content[previousEnd+2 : i] } - if content[cStart:cStart+5] != "endif" || invalidIf { + if invalidIf || oob(blockContentStart+5) || content[blockContentStart:blockContentStart+5] != "endif" { continue } ifEnd = i - 1 if ifTrue { - out += templateEmail(content[ifStart:ifEnd+1], variables, conditionals, values) + toAppend, subErr := templateEmail(content[ifStart:ifEnd+1], variables, conditionals, values) + out += toAppend + if subErr != nil { + err = subErr + } ifTrue = false } } else if c == '}' { + doubleBraced := !oob(i+1) && content[i+1] == '}' + if doubleBraced { + err = fmt.Errorf(`double braces ("}}") at position %d, use single brace only`, i) + } + if !oob(i-1) && content[i-1] == '}' { + continue + } if varStart != -1 { ifStart = i + 1 varEnd = i - 1 - for content[varEnd] == ' ' { + for !oob(varEnd) && content[varEnd] == ' ' { varEnd-- } varName = content[varStart : varEnd+1] @@ -63,14 +95,8 @@ func templateEmail(content string, variables []string, conditionals []string, va positive = false varName = varName[1:] } - validVar := false wrappedVarName := "{" + varName + "}" - for _, v := range conditionals { - if v == wrappedVarName { - validVar = true - break - } - } + validVar := slices.Contains(conditionals, wrappedVarName) if validVar { ifTrue = positive == truthy(values[varName]) } else { @@ -79,27 +105,26 @@ func templateEmail(content string, variables []string, conditionals []string, va } varStart, varEnd = -1, -1 } - cEnd = i - 1 - for content[cEnd] == ' ' { - cEnd-- + blockContentEnd = i - 1 + for content[blockContentEnd] == ' ' { + blockContentEnd-- } previousEnd = i - 1 - if content[cEnd-4:cEnd+1] == "endif" && !invalidIf { + // Skip the extra brace + if doubleBraced { + previousEnd++ + } + if !oob(blockContentEnd-4) && !oob(blockContentEnd+1) && content[blockContentEnd-4:blockContentEnd+1] == "endif" && !invalidIf { continue } - validVar := false - varName = content[cStart : cEnd+1] - cStart, cEnd = -1, -1 + varName = content[blockContentStart : blockContentEnd+1] + blockContentStart, blockContentEnd = -1, -1 + blockRawStart = -1 if ifStart != -1 { continue } wrappedVarName := "{" + varName + "}" - for _, v := range variables { - if v == wrappedVarName { - validVar = true - break - } - } + validVar := slices.Contains(variables, wrappedVarName) if !validVar { out += wrappedVarName continue @@ -107,11 +132,17 @@ func templateEmail(content string, variables []string, conditionals []string, va out += fmt.Sprint(values[varName]) } } + if blockContentStart != -1 && blockContentEnd == -1 { + err = fmt.Errorf(`incomplete block (single "{") near position %d`, blockContentStart) + // Include the brace, maybe the user wants it. + previousEnd = blockRawStart - 2 + } if previousEnd+1 != len(content)-1 { out += content[previousEnd+2:] + } if out == "" { - return content + return content, err } - return out + return out, err } diff --git a/template_test.go b/template_test.go new file mode 100644 index 0000000..465bc2b --- /dev/null +++ b/template_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "strings" + "testing" +) + +// In == Out when nothing is meant to be templated. +func TestBlankTemplate(t *testing.T) { + in := `Success, user! Your account has been created. Log in at myAccountURL with your username to get started.` + + out, err := templateEmail(in, []string{}, []string{}, map[string]any{}) + + if err != nil { + t.Fatalf("error: %+v", err) + } + + if out != in { + t.Fatalf(`returned string doesn't match input: "%+v" != "%+v"`, out, in) + } +} + +func testConditional(isTrue bool, t *testing.T) { + in := `Success, {username}! Your account has been created. {if myCondition}Log in at {myAccountURL} with username {username} to get started.{endif}` + + vars := []string{"{username}", "{myAccountURL}", "{myCondition}"} + conds := vars + vals := map[string]any{ + "username": "TemplateUsername", + "myAccountURL": "TemplateURL", + "myCondition": isTrue, + } + + out, err := templateEmail(in, vars, conds, vals) + + target := "" + if isTrue { + target = `Success, {username}! Your account has been created. Log in at {myAccountURL} with username {username} to get started.` + } else { + target = `Success, {username}! Your account has been created. ` + } + + target = strings.ReplaceAll(target, "{username}", vals["username"].(string)) + target = strings.ReplaceAll(target, "{myAccountURL}", vals["myAccountURL"].(string)) + + if err != nil { + t.Fatalf("error: %+v", err) + } + + if out != target { + t.Fatalf(`returned string doesn't match desired output: "%+v" != "%+v"`, out, target) + } +} + +func TestConditionalTrue(t *testing.T) { + testConditional(true, t) +} + +func TestConditionalFalse(t *testing.T) { + testConditional(false, t) +} + +// Template mistakenly double-braced values, but return a warning. +func TestTemplateDoubleBraceGracefulHandling(t *testing.T) { + in := `Success, {{username}}! Your account has been created. Log in at {myAccountURL} with username {username} to get started.` + + vars := []string{"{username}", "{myAccountURL}"} + vals := map[string]any{ + "username": "TemplateUsername", + "myAccountURL": "TemplateURL", + } + + target := strings.ReplaceAll(in, "{{username}}", vals["username"].(string)) + target = strings.ReplaceAll(target, "{username}", vals["username"].(string)) + target = strings.ReplaceAll(target, "{myAccountURL}", vals["myAccountURL"].(string)) + + out, err := templateEmail(in, vars, []string{}, vals) + + if err == nil { + t.Fatal("no error when given double-braced variable") + } + + if out != target { + t.Fatalf(`returned string doesn't match desired output: "%+v" != "%+v"`, out, target) + } +} + +func TestVarAtAnyPosition(t *testing.T) { + in := `Success, user! Your account has been created. Log in at myAccountURL with your username to get started.` + vars := []string{"{username}", "{myAccountURL}"} + vals := map[string]any{ + "username": "TemplateUsername", + "myAccountURL": "TemplateURL", + } + + for i := range in { + newIn := in[0:i] + vars[0] + in[i:] + + target := strings.ReplaceAll(newIn, vars[0], vals["username"].(string)) + + out, err := templateEmail(newIn, vars, []string{}, vals) + + if err != nil { + t.Fatalf("error: %+v", err) + } + + if out != target { + t.Fatalf(`returned string doesn't match desired output: "%+v" != "%+v"`, out, target) + } + } +} + +func TestIncompleteBlock(t *testing.T) { + in := `Success, user! Your account has been created. Log in at myAccountURL with your username to get started.` + for i := range in { + newIn := in[0:i] + "{" + in[i:] + + out, err := templateEmail(newIn, []string{"a"}, []string{"a"}, map[string]any{"a": "a"}) + + if out != newIn { + t.Fatalf(`returned string for position %d/%d doesn't match desired output: "%+v" != "%+v"`, i+1, len(newIn), out, newIn) + } + if err == nil { + t.Fatalf("no error when given incomplete block with brace at position %d/%d", i+1, len(newIn)) + } + + } +} From aab8d6ed774273dc5bb35d6d91f86d1e9b825d6b Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sun, 3 Aug 2025 18:39:47 +0100 Subject: [PATCH 02/90] template: report errors/warnings error field is now logged at all points of use. --- email.go | 81 +++++++++++++++++++++++++++++++------- html/setup.html | 2 +- logmessages/logmessages.go | 2 + views.go | 25 ++++++------ 4 files changed, 84 insertions(+), 26 deletions(-) diff --git a/email.go b/email.go index 0e447c8..0b244b2 100644 --- a/email.go +++ b/email.go @@ -347,12 +347,16 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, app *a template := emailer.confirmationValues(code, username, key, app, noSub) message := app.storage.MustGetCustomContentKey("EmailConfirmation") if message.Enabled { - content := templateEmail( + 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) @@ -365,15 +369,24 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, app *a // 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 = templateEmail(md, []string{"{username}"}, nil, map[string]interface{}{"username": username[0]}) - subject = templateEmail(subject, []string{"{username}"}, nil, map[string]interface{}{"username": 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() - var err error data := map[string]interface{}{ "text": template.HTML(html), "plaintext": text, @@ -427,12 +440,16 @@ func (emailer *Emailer) constructInvite(code string, invite Invite, app *appCont var err error message := app.storage.MustGetCustomContentKey("InviteEmail") if message.Enabled { - content := templateEmail( + 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) @@ -467,12 +484,16 @@ func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appCont template := emailer.expiryValues(code, invite, app, noSub) message := app.storage.MustGetCustomContentKey("InviteExpiry") if message.Enabled { - content := templateEmail( + 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) @@ -522,12 +543,16 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite var err error message := app.storage.MustGetCustomContentKey("UserCreated") if message.Enabled { - content := templateEmail( + 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) @@ -596,12 +621,16 @@ func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub var err error message := app.storage.MustGetCustomContentKey("PasswordReset") if message.Enabled { - content := templateEmail( + 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) @@ -638,12 +667,16 @@ func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub b template := emailer.deletedValues(reason, app, noSub) message := app.storage.MustGetCustomContentKey("UserDeleted") if message.Enabled { - content := templateEmail( + 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) @@ -680,12 +713,16 @@ func (emailer *Emailer) constructDisabled(reason string, app *appContext, noSub template := emailer.disabledValues(reason, app, noSub) message := app.storage.MustGetCustomContentKey("UserDisabled") if message.Enabled { - content := templateEmail( + 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) @@ -722,12 +759,16 @@ func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub b template := emailer.enabledValues(reason, app, noSub) message := app.storage.MustGetCustomContentKey("UserEnabled") if message.Enabled { - content := templateEmail( + 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) @@ -788,12 +829,16 @@ func (emailer *Emailer) constructExpiryAdjusted(username string, expiry time.Tim }) } if message.Enabled { - content := templateEmail( + 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) @@ -854,12 +899,16 @@ func (emailer *Emailer) constructWelcome(username string, expiry time.Time, app }) } if message.Enabled { - content := templateEmail( + 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) + } email, err = emailer.constructTemplate(email.Subject, content, app) } else { email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "welcome_email", "email_", template) @@ -890,12 +939,16 @@ func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Mess template := emailer.userExpiredValues(app, noSub) message := app.storage.MustGetCustomContentKey("UserExpired") if message.Enabled { - content := templateEmail( + 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) diff --git a/html/setup.html b/html/setup.html index 31bd42b..9c8839e 100644 --- a/html/setup.html +++ b/html/setup.html @@ -106,7 +106,7 @@

{{ .lang.General.urlBaseNotice }}

diff --git a/logmessages/logmessages.go b/logmessages/logmessages.go index e3f7039..aa10579 100644 --- a/logmessages/logmessages.go +++ b/logmessages/logmessages.go @@ -345,6 +345,8 @@ const ( ) const ( + FailedConstructCustomContent = "Possible error in custom content \"%s\": %v" + FailedConstructExpiryAdmin = "Failed to construct expiry notification for \"%s\": %v" FailedSendExpiryAdmin = "Failed to send expiry notification for \"%s\" to \"%s\": %v" SentExpiryAdmin = "Sent expiry notification for \"%s\" to \"%s\"" diff --git a/views.go b/views.go index b05937e..b932930 100644 --- a/views.go +++ b/views.go @@ -812,18 +812,21 @@ func (app *appContext) InviteProxy(gc *gin.Context) { if msg, ok := app.storage.GetCustomContentKey("PostSignupCard"); ok && msg.Enabled { 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, + map[string]any{ + "username": "{username}", + "myAccountURL": userPageAddress, + }, + ) + if err != nil { + app.err.Printf(lm.FailedConstructCustomContent, "PostSignupCard", err) + } data["customSuccessCardContent"] = template.HTML(markdown.ToHTML( - []byte(templateEmail( - msg.Content, - msg.Variables, - msg.Conditionals, - map[string]interface{}{ - "username": "{username}", - "myAccountURL": userPageAddress, - }, - ), - ), nil, markdownRenderer, - )) + []byte(templated), nil, markdownRenderer), + ) } // if discordEnabled { From db1e8121902d8272e074c96b63e49df79295217f Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sun, 3 Aug 2025 20:05:22 +0100 Subject: [PATCH 03/90] discord: retry auth/command register, do latter in bulk re-use the auth retry options from the config for initial d.bot.Open and for registering commands. The latetr is now done with the BulkOverwrite method, since it seems to work now. For #427. --- common/common.go | 9 ++++++ discord.go | 71 ++++++++++++++++++++++++++++++++++++++++-------- main.go | 2 +- 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/common/common.go b/common/common.go index 6f33115..a56e1ff 100644 --- a/common/common.go +++ b/common/common.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" "strings" + "time" lm "github.com/hrfee/jfa-go/logmessages" ) @@ -155,3 +156,11 @@ func decodeResp(resp *http.Response) (string, error) { } return buf.String(), nil } + +// MustAuthenticateOptions is used to control the behaviour of the MustAuthenticate-like methods. +type MustAuthenticateOptions struct { + RetryCount int // Number of Retries before failure. + RetryGap time.Duration // Duration to wait between tries. + LogFailures bool // Whether or not to print failures to the log. + Counter int // The current retry count. +} diff --git a/discord.go b/discord.go index af0d52e..5bf60d3 100644 --- a/discord.go +++ b/discord.go @@ -8,6 +8,7 @@ import ( "time" dg "github.com/bwmarrin/discordgo" + "github.com/hrfee/jfa-go/common" lm "github.com/hrfee/jfa-go/logmessages" "github.com/timshannon/badgerhold/v4" ) @@ -28,6 +29,7 @@ type DiscordDaemon struct { commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string) commandIDs []string commandDescriptions []*dg.ApplicationCommand + retryOpts *common.MustAuthenticateOptions } func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) { @@ -59,6 +61,16 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) { dd.users[user.ID] = user } + dd.retryOpts = &common.MustAuthenticateOptions{ + RetryCount: app.config.Section("advanced").Key("auth_retry_count").MustInt(6), + RetryGap: time.Duration(app.config.Section("advanced").Key("auth_retry_gap").MustInt(10)) * time.Second, + LogFailures: true, + } + + dd.bot.AddHandler(dd.commandHandler) + + dd.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers | dg.IntentsGuildInvites + return dd, nil } @@ -99,13 +111,27 @@ func (d *DiscordDaemon) MustGetUser(channelID, userID, discrim, username string) return d.NewUnknownUser(channelID, userID, discrim, username) } -func (d *DiscordDaemon) run() { - d.bot.AddHandler(d.commandHandler) +func (d *DiscordDaemon) Run() { + ro := common.MustAuthenticateOptions{} + ro = *d.retryOpts + ro.Counter = 0 + d.run(&ro) +} - d.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers | dg.IntentsGuildInvites +func (d *DiscordDaemon) run(retry *common.MustAuthenticateOptions) { if err := d.bot.Open(); err != nil { - d.app.err.Printf(lm.FailedStartDaemon, lm.Discord, err) - return + if retry == nil || retry.LogFailures { + d.app.err.Printf(lm.FailedStartDaemon, lm.Discord, err) + } + if retry != nil { + retry.Counter += 1 + if retry.Counter >= retry.RetryCount { + return + } + time.Sleep(retry.RetryGap) + d.run(retry) + return + } } // Wait for everything to populate, it's slow sometimes. for d.bot.State == nil { @@ -135,15 +161,18 @@ func (d *DiscordDaemon) run() { d.InviteChannel.Name = invChannel } } - err = d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start")) + d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start")) defer d.deregisterCommands() defer d.bot.Close() - go d.registerCommands() + ro := common.MustAuthenticateOptions{} + ro = *(d.retryOpts) + ro.Counter = 0 + + go d.registerCommands(&ro) <-d.ShutdownChannel d.ShutdownChannel <- "Down" - return } // ListRoles returns a list of available (excluding bot and @everyone) roles in a guild as a list of containing an array of the guild ID and its name. @@ -333,7 +362,7 @@ func (d *DiscordDaemon) Shutdown() { close(d.ShutdownChannel) } -func (d *DiscordDaemon) registerCommands() { +func (d *DiscordDaemon) registerCommands(retry *common.MustAuthenticateOptions) { d.commandDescriptions = []*dg.ApplicationCommand{ { Name: d.app.config.Section("discord").Key("start_command").MustString("start"), @@ -430,7 +459,27 @@ func (d *DiscordDaemon) registerCommands() { // if err != nil { // d.app.err.Printf("Discord: Cannot create commands: %v", err) // } - for i, cmd := range d.commandDescriptions { + + cCommands, err := d.bot.ApplicationCommandBulkOverwrite(d.bot.State.User.ID, d.guildID, d.commandDescriptions) + if err != nil { + if retry == nil || retry.LogFailures { + d.app.err.Printf(lm.FailedRegisterDiscordCommand, "*", err) + } + if retry != nil { + retry.Counter += 1 + if retry.Counter >= retry.RetryCount { + return + } + time.Sleep(retry.RetryGap) + d.registerCommands(retry) + } + } else { + for i := range len(d.commandDescriptions) { + d.commandIDs[i] = cCommands[i].ID + } + d.app.debug.Printf(lm.RegisterDiscordCommand, "*") + } + /* for i, cmd := range d.commandDescriptions { command, err := d.bot.ApplicationCommandCreate(d.bot.State.User.ID, d.guildID, cmd) if err != nil { d.app.err.Printf(lm.FailedRegisterDiscordCommand, cmd.Name, err) @@ -438,7 +487,7 @@ func (d *DiscordDaemon) registerCommands() { d.app.debug.Printf(lm.RegisterDiscordCommand, cmd.Name) d.commandIDs[i] = command.ID } - } + } */ } func (d *DiscordDaemon) deregisterCommands() { diff --git a/main.go b/main.go index 04ac95a..2993b3e 100644 --- a/main.go +++ b/main.go @@ -542,7 +542,7 @@ func start(asDaemon, firstCall bool) { discordEnabled = false } else { app.debug.Println(lm.InitDiscord) - go app.discord.run() + go app.discord.Run() defer app.discord.Shutdown() app.contactMethods = append(app.contactMethods, app.discord) } From 5fe0e0ab9f10243ab9bf899598156bede5d341ff Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 4 Aug 2025 16:02:04 +0100 Subject: [PATCH 04/90] timer: add scheduler/timer with day precision timer.go has a timer struct for scheduling things to happen once every n days before or after a given time. Pass a string list of day deltas to parse, a unit to parse these as (only 24/-24 hours really make sense), then call Check() on the returned struct with your "since" time, and the time a timer was last fired. If one goes off, store the time so you can pass it in subsequent calls. To be used in the user daemon for "remind every N days" functionality. Was initially gonna allow more precision than days, but ran into problems, most likely from me overcomplicating it and not wanting to store too much data. some tests also in timer_test.go. --- timer.go | 92 +++++++++++++++++++++++++++++++++ timer_test.go | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 timer.go create mode 100644 timer_test.go diff --git a/timer.go b/timer.go new file mode 100644 index 0000000..cbcddbd --- /dev/null +++ b/timer.go @@ -0,0 +1,92 @@ +package main + +import ( + "strconv" + "time" +) + +const ( + // The maximum duration distance from a trigger time that it will be triggered. If multiple trigger times are provided closer than this value, the smallest will be used instead. + MAX_MIN_INTERVAL = 18 * time.Hour +) + +type Clock interface { + Now() time.Time + Since(t time.Time) time.Duration +} + +type realClock struct{} + +func (realClock) Now() time.Time { return time.Now() } +func (realClock) Since(t time.Time) time.Duration { return time.Since(t) } + +// DayTimerSet holds information required to trigger timers. Can be generated with NewTimerSet. Does not have it's own event loop, one should check as regularly as they like whether and which of the timers should go off with Check(), passing the last known time one fired (you should track this yourself, the DayTimerSet struct isn't something that needs to be stored). +type DayTimerSet struct { + deltas []time.Duration + clock Clock +} + +func NewDayTimerSet(deltaStrings []string, unit time.Duration) DayTimerSet { + as := DayTimerSet{ + deltas: make([]time.Duration, 0, len(deltaStrings)), + clock: realClock{}, + } + for i := range deltaStrings { + // d, err := strconv.ParseFloat(deltaStrings[i], 64) + d, err := strconv.ParseInt(deltaStrings[i], 10, 64) + if err == nil { + as.deltas = append(as.deltas, time.Duration(d)*unit) + } + } + + return as +} + +// Returns one or no time.Duration values, Giving the delta for the timer which went off. Pass a non-zero lastFired to stop too many going off at once, and store the returned time.Time value to pass as this later. +func (as DayTimerSet) Check(since time.Time, lastFired time.Time) time.Duration { + // Keep track of the timer that's most recently gone off, so we don't for example send a "your account expires in 3 days" 1 day away from expiry if the server's been turned off for a while. + soonestTimerDesiredDelta := time.Duration(0) + soonestTimerRealDelta := 1e5 * time.Hour + for _, dt := range as.deltas { + if dt == time.Duration(0) { + // fmt.Printf("not firing: zero delta\n") + continue + } + + now := as.clock.Now() + y1, m1, d1 := now.Date() + + if !lastFired.IsZero() { + y2, m2, d2 := lastFired.Date() + if y2 == y1 && m2 == m1 && d2 == d1 { + // fmt.Printf("not firing: same day as last fire (%d.%d.%d == %d.%d.%d)\n", y2, m2, d2, y1, m1, d1) + continue + } + + if as.clock.Since(lastFired) < MAX_MIN_INTERVAL { + // fmt.Printf("not firing: not enough time since last fire (%v < %v)\n", as.clock.Since(lastFired), MAX_MIN_INTERVAL) + continue + } + } + + nd := since.Add(dt) + + y2, m2, d2 := nd.Date() + if y2 != y1 || m2 != m1 || d2 != d1 { + // fmt.Printf("not firing: not same day (%d.%d.%d != %d.%d.%d)\n", y2, m2, d2, y1, m1, d1) + continue + } + dNowNotif := now.Sub(nd).Abs() + + if dNowNotif > MAX_MIN_INTERVAL { + // fmt.Printf("not firing: not close enough to fire time (%v > %v)\n", dNowNotif, MAX_MIN_INTERVAL) + continue + } + + if dNowNotif < soonestTimerRealDelta { + soonestTimerDesiredDelta = dt + soonestTimerRealDelta = dNowNotif + } + } + return soonestTimerDesiredDelta +} diff --git a/timer_test.go b/timer_test.go new file mode 100644 index 0000000..465bfdb --- /dev/null +++ b/timer_test.go @@ -0,0 +1,138 @@ +package main + +import ( + "testing" + "time" +) + +type fakeClock struct { + now time.Time +} + +func (f fakeClock) Now() time.Time { return f.now } +func (f fakeClock) Since(t time.Time) time.Duration { return f.now.Sub(t) } + +// Tests the timer with negative time deltas, i.e. reminders before an event. +func TestTimerNegative(t *testing.T) { + as := NewDayTimerSet([]string{ + "1", "2", "3", "7", + }, -24*time.Hour) + + since := time.Date(2025, 8, 9, 1, 0, 0, 0, time.UTC) + nowTimes := []time.Time{ + time.Date(2025, 8, 1, 23, 59, 0, 0, time.UTC), + time.Date(2025, 8, 2, 1, 0, 0, 0, time.UTC), + time.Date(2025, 8, 3, 7, 0, 0, 0, time.UTC), + time.Date(2025, 8, 6, 7, 0, 0, 0, time.UTC), + time.Date(2025, 8, 6, 12, 0, 0, 0, time.UTC), + time.Date(2025, 8, 7, 5, 0, 0, 0, time.UTC), + time.Date(2025, 8, 8, 0, 30, 0, 0, time.UTC), + time.Date(2025, 8, 8, 4, 30, 0, 0, time.UTC), + time.Date(2025, 8, 8, 16, 30, 0, 0, time.UTC), + } + + returnValues := []time.Duration{ + 0, 7, 0, 3, 0, 2, 1, 0, 0, + } + for i := range returnValues { + returnValues[i] *= -24 * time.Hour + } + + lastFired := time.Time{} + for i, nt := range nowTimes { + target := returnValues[i] + + as.clock = fakeClock{now: nt} + + ret := as.Check(since, lastFired) + + if ret != target { + t.Fatalf("incorrect return value (%v != %v): i=%d, now=%+v, since=%+v, lastFired=%+v", ret, target, i, nt, since, lastFired) + } + + if ret != 0 { + lastFired = nt + } + } +} + +func TestTimerSmallInterval(t *testing.T) { + as := NewDayTimerSet([]string{ + "1", "1.1", "2", "3", "7", + }, -24*time.Hour) + + since := time.Date(2025, 8, 9, 1, 0, 0, 0, time.UTC) + nowTimes := []time.Time{ + time.Date(2025, 8, 1, 23, 59, 0, 0, time.UTC), + time.Date(2025, 8, 2, 1, 0, 0, 0, time.UTC), + time.Date(2025, 8, 3, 7, 0, 0, 0, time.UTC), + time.Date(2025, 8, 6, 7, 0, 0, 0, time.UTC), + time.Date(2025, 8, 6, 7, 30, 0, 0, time.UTC), + time.Date(2025, 8, 6, 12, 0, 0, 0, time.UTC), + time.Date(2025, 8, 7, 5, 0, 0, 0, time.UTC), + time.Date(2025, 8, 8, 0, 30, 0, 0, time.UTC), + time.Date(2025, 8, 8, 4, 30, 0, 0, time.UTC), + time.Date(2025, 8, 8, 16, 30, 0, 0, time.UTC), + } + + returnValues := []time.Duration{ + 0, 7, 0, 3, 0, 0, 2, 1, 0, 0, + } + for i := range returnValues { + returnValues[i] *= -24 * time.Hour + } + + lastFired := time.Time{} + for i, nt := range nowTimes { + target := returnValues[i] + + as.clock = fakeClock{now: nt} + + ret := as.Check(since, lastFired) + + if ret != target { + t.Fatalf("incorrect return value (%v != %v): i=%d, now=%+v, since=%+v, lastFired=%+v", ret, target, i, nt, since, lastFired) + } + + if ret != 0 { + lastFired = nt + } + } +} + +func TestTimerBruteForce(t *testing.T) { + as := NewDayTimerSet([]string{ + "1", "2", "3", "7", + }, -24*time.Hour) + + since := time.Date(2025, 8, 9, 1, 0, 0, 0, time.UTC) + + returnedValues := map[time.Duration]time.Time{} + + lastFired := time.Time{} + for dd := range 12 { + for hh := range 24 { + for mm := range 60 { + nt := time.Date(2025, 8, dd, hh, mm, 0, 0, time.UTC) + + as.clock = fakeClock{now: nt} + + ret := as.Check(since, lastFired) + + if dupe, ok := returnedValues[ret]; ok { + + t.Fatalf("duplicate return value (%v): now=%+v, dupe=%+v, since=%+v, lastFired=%+v", ret, nt, dupe, since, lastFired) + } + + if ret != 0 { + returnedValues[ret] = nt + lastFired = nt + } + } + } + } + + if len(returnedValues) != len(as.deltas) { + t.Fatalf("not all timers fired (%d/%d)", len(returnedValues), len(as.deltas)) + } +} From 94efe9f746ad0eb6e2a26d8a63067d7d870bab03 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 4 Aug 2025 20:30:46 +0100 Subject: [PATCH 05/90] expiry: add "remind N days before" new setting to send an email/message N days before a user is due to expire. Multiple can be set. --- api-messages.go | 8 +++- api-users.go | 2 + config.go | 5 ++- config/config-base.yaml | 21 +++++++++++ email.go | 73 ++++++++++++++++++++++++++++++++++++ lang.go | 1 + lang/email/en-us.json | 5 +++ logmessages/logmessages.go | 4 ++ mail/expiry-reminder.mjml | 77 ++++++++++++++++++++++++++++++++++++++ mail/expiry-reminder.txt | 5 +++ migrations.go | 3 ++ storage.go | 6 ++- timer.go | 4 +- user-d.go | 35 ++++++++++++++++- users.go | 2 +- 15 files changed, 243 insertions(+), 8 deletions(-) create mode 100644 mail/expiry-reminder.mjml create mode 100644 mail/expiry-reminder.txt diff --git a/api-messages.go b/api-messages.go index e3297b5..b06cc0f 100644 --- a/api-messages.go +++ b/api-messages.go @@ -39,6 +39,7 @@ func (app *appContext) GetCustomContent(gc *gin.Context) { "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"]}, @@ -196,7 +197,12 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) { if noContent { msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", app, true) } - values = app.email.expiryAdjustedValues(username, time.Now(), app.storage.lang.Email[lang].Strings.get("reason"), app, false, true) + 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) diff --git a/api-users.go b/api-users.go index 26da7fc..c71f8f9 100644 --- a/api-users.go +++ b/api-users.go @@ -552,6 +552,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) { }(id, expiry.Expiry) } } + app.InvalidateWebUserCache() respondBool(204, true, gc) } @@ -563,6 +564,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) { // @tags Users func (app *appContext) RemoveExpiry(gc *gin.Context) { app.storage.DeleteUserExpiryKey(gc.Param("id")) + app.InvalidateWebUserCache() respondBool(200, true, gc) } diff --git a/config.go b/config.go index 444b23f..c997705 100644 --- a/config.go +++ b/config.go @@ -24,7 +24,7 @@ var telegramEnabled = false var discordEnabled = false var matrixEnabled = false -// URL subpaths. Ignore the "Current" field. +// URL subpaths. Ignore the "Current" field, it's populated when in copies of the struct used for page templating. // IMPORTANT: When linking straight to a page, rather than appending further to the URL (like accessing an API route), append a /. var PAGES = PagePaths{} @@ -240,6 +240,9 @@ func (app *appContext) loadConfig() error { app.MustSetValue("user_expiry", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html") app.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") + app.MustSetValue("email", "collect", "true") app.MustSetValue("matrix", "topic", "Jellyfin notifications") diff --git a/config/config-base.yaml b/config/config-base.yaml index 7587a24..aef4bb5 100644 --- a/config/config-base.yaml +++ b/config/config-base.yaml @@ -1474,6 +1474,10 @@ sections: value: true depends_true: messages|enabled description: Send an email when a user's account expires. + - setting: send_reminder_n_days_before + name: Send message N days before expiry + type: list + description: Send users a message N days before their account is due to expire. Multiple can be set. - setting: subject name: Email subject depends_true: messages|enabled @@ -1509,6 +1513,23 @@ sections: depends_true: messages|enabled type: text description: Path to custom email in plain text + - setting: reminder_subject + name: 'Reminder: email subject' + depends_true: messages|enabled + type: text + description: Subject of expiry reminder emails. + - setting: reminder_email_html + name: 'Reminder: Custom email (HTML)' + advanced: true + depends_true: messages|enabled + type: text + description: Path to custom email html + - setting: reminder_email_text + name: 'Reminder: Custom email (plaintext)' + advanced: true + depends_true: messages|enabled + type: text + description: Path to custom email in plain text - section: disable_enable meta: name: Account Disabling/Enabling diff --git a/email.go b/email.go index 0b244b2..9fda36e 100644 --- a/email.go +++ b/email.go @@ -849,6 +849,79 @@ func (emailer *Emailer) constructExpiryAdjusted(username string, expiry time.Tim 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{}{ + "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, + }) + } + } + } + return template +} + +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")), + } + 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"), diff --git a/lang.go b/lang.go index abf20e0..a131296 100644 --- a/lang.go +++ b/lang.go @@ -108,6 +108,7 @@ type emailLang struct { WelcomeEmail langSection `json:"welcomeEmail"` EmailConfirmation langSection `json:"emailConfirmation"` UserExpired langSection `json:"userExpired"` + ExpiryReminder langSection `json:"expiryReminder"` } type setupLangs map[string]setupLang diff --git a/lang/email/en-us.json b/lang/email/en-us.json index bf832ef..8539778 100644 --- a/lang/email/en-us.json +++ b/lang/email/en-us.json @@ -80,5 +80,10 @@ "title": "Your account has expired - Jellyfin", "yourAccountHasExpired": "Your account has expired.", "contactTheAdmin": "Contact the administrator for more info." + }, + "expiryReminder": { + "name": "Expiry reminder", + "title": "Reminder: your account will expire soon - Jellyfin", + "yourAccountIsDueToExpire": "Your account is due to expire in {expiresIn}, or on {date} at {time}." } } diff --git a/logmessages/logmessages.go b/logmessages/logmessages.go index aa10579..3a25b16 100644 --- a/logmessages/logmessages.go +++ b/logmessages/logmessages.go @@ -384,6 +384,10 @@ const ( FailedSendExpiryAdjustmentMessage = "Failed to send expiry adjustment message for \"%s\" to \"%s\": %v" SentExpiryAdjustmentMessage = "Sent expiry adjustment message for \"%s\" to \"%s\"" + FailedConstructExpiryReminderMessage = "Failed to construct expiry reminder message for \"%s\": %v" + FailedSendExpiryReminderMessage = "Failed to send expiry reminder message for \"%s\" to \"%s\": %v" + SentExpiryReminderMessage = "Sent expiry reminder message for \"%s\" to \"%s\"" + FailedConstructExpiryMessage = "Failed to construct expiry message for \"%s\": %v" FailedSendExpiryMessage = "Failed to send expiry message for \"%s\" to \"%s\": %v" SentExpiryMessage = "Sent expiry message for \"%s\" to \"%s\"" diff --git a/mail/expiry-reminder.mjml b/mail/expiry-reminder.mjml new file mode 100644 index 0000000..3c69ecb --- /dev/null +++ b/mail/expiry-reminder.mjml @@ -0,0 +1,77 @@ + + + + + + + + :root { + Color-scheme: light dark; + supported-color-schemes: light dark; + } + @media (prefers-color-scheme: light) { + Color-scheme: dark; + .body { + background: #242424 !important; + background-color: #242424 !important; + } + [data-ogsc] .body { + background: #242424 !important; + background-color: #242424 !important; + } + [data-ogsb] .body { + background: #242424 !important; + background-color: #242424 !important; + } + } + @media (prefers-color-scheme: dark) { + Color-scheme: dark; + .body { + background: #242424 !important; + background-color: #242424 !important; + } + [data-ogsc] .body { + background: #242424 !important; + background-color: #242424 !important; + } + [data-ogsb] .body { + background: #242424 !important; + background-color: #242424 !important; + } + } + + + + + + + + + + + + + + + + {{ .jellyfin }} + + + + + +

{{ .helloUser }}

+ +

{{ .yourAccountIsDueToExpire }}

+
+
+
+ + + + {{ .message }} + + + + +
diff --git a/mail/expiry-reminder.txt b/mail/expiry-reminder.txt new file mode 100644 index 0000000..0bbae39 --- /dev/null +++ b/mail/expiry-reminder.txt @@ -0,0 +1,5 @@ +{{ .helloUser }} + +{{ .yourAccountIsDueToExpire }} + +{{ .message }} diff --git a/migrations.go b/migrations.go index a9f03aa..1286537 100644 --- a/migrations.go +++ b/migrations.go @@ -387,6 +387,9 @@ func intialiseCustomContent(app *appContext) { if _, ok := app.storage.GetCustomContentKey("UserExpiryAdjusted"); !ok { app.storage.SetCustomContentKey("UserExpiryAdjusted", emptyCC) } + if _, ok := app.storage.GetCustomContentKey("ExpiryReminder"); !ok { + app.storage.SetCustomContentKey("ExpiryReminder", emptyCC) + } if _, ok := app.storage.GetCustomContentKey("PostSignupCard"); !ok { app.storage.SetCustomContentKey("PostSignupCard", emptyCC) diff --git a/storage.go b/storage.go index 9a5126b..02eb6be 100644 --- a/storage.go +++ b/storage.go @@ -66,7 +66,8 @@ type Activity struct { type UserExpiry struct { JellyfinID string `badgerhold:"key"` Expiry time.Time - DeleteAfterPeriod bool // Whether or not to further disable the user later on + DeleteAfterPeriod bool // Whether or not to further disable the user later on + LastNotified time.Time // Last time an expiry notification/reminder was sent to the user. } type DebugLogAction int @@ -679,6 +680,7 @@ type customEmails struct { WelcomeEmail CustomContent `json:"welcomeEmail"` EmailConfirmation CustomContent `json:"emailConfirmation"` UserExpired CustomContent `json:"userExpired"` + ExpiryReminder CustomContent `json:"expiryReminder"` } // CustomContent stores customized versions of jfa-go content, including emails and user messages. @@ -1311,6 +1313,7 @@ func (st *Storage) loadLangEmail(filesystems ...fs.FS) error { patchLang(&lang.WelcomeEmail, &fallback.WelcomeEmail, &english.WelcomeEmail) patchLang(&lang.EmailConfirmation, &fallback.EmailConfirmation, &english.EmailConfirmation) patchLang(&lang.UserExpired, &fallback.UserExpired, &english.UserExpired) + patchLang(&lang.ExpiryReminder, &fallback.ExpiryReminder, &english.ExpiryReminder) patchLang(&lang.Strings, &fallback.Strings, &english.Strings) } } @@ -1326,6 +1329,7 @@ func (st *Storage) loadLangEmail(filesystems ...fs.FS) error { patchLang(&lang.WelcomeEmail, &english.WelcomeEmail) patchLang(&lang.EmailConfirmation, &english.EmailConfirmation) patchLang(&lang.UserExpired, &english.UserExpired) + patchLang(&lang.ExpiryReminder, &english.ExpiryReminder) patchLang(&lang.Strings, &english.Strings) } } diff --git a/timer.go b/timer.go index cbcddbd..e11fc8c 100644 --- a/timer.go +++ b/timer.go @@ -26,7 +26,7 @@ type DayTimerSet struct { clock Clock } -func NewDayTimerSet(deltaStrings []string, unit time.Duration) DayTimerSet { +func NewDayTimerSet(deltaStrings []string, unit time.Duration) *DayTimerSet { as := DayTimerSet{ deltas: make([]time.Duration, 0, len(deltaStrings)), clock: realClock{}, @@ -39,7 +39,7 @@ func NewDayTimerSet(deltaStrings []string, unit time.Duration) DayTimerSet { } } - return as + return &as } // Returns one or no time.Duration values, Giving the delta for the timer which went off. Pass a non-zero lastFired to stop too many going off at once, and store the returned time.Time value to pass as this later. diff --git a/user-d.go b/user-d.go index 9ed0a90..97a72d3 100644 --- a/user-d.go +++ b/user-d.go @@ -9,9 +9,14 @@ import ( ) func newUserDaemon(interval time.Duration, app *appContext) *GenericDaemon { + preExpiryCutoffDays := app.config.Section("user_expiry").Key("send_reminder_n_days_before").StringsWithShadows("|") + var as *DayTimerSet + if len(preExpiryCutoffDays) > 0 { + as = NewDayTimerSet(preExpiryCutoffDays, -24*time.Hour) + } d := NewGenericDaemon(interval, app, func(app *appContext) { - app.checkUsers() + app.checkUsers(as) }, ) d.Name("User daemon") @@ -23,7 +28,7 @@ const ( ExpiryModeDelete ) -func (app *appContext) checkUsers() { +func (app *appContext) checkUsers(remindBeforeExpiry *DayTimerSet) { if len(app.storage.GetUserExpiries()) == 0 { return } @@ -63,7 +68,29 @@ func (app *appContext) checkUsers() { app.storage.DeleteUserExpiryKey(expiry.JellyfinID) continue } + if !time.Now().After(expiry.Expiry) { + if shouldContact && remindBeforeExpiry != nil { + app.debug.Printf("Checking for expiry reminder timers") + duration := remindBeforeExpiry.Check(expiry.Expiry, expiry.LastNotified) + if duration != 0 { + expiry.LastNotified = time.Now() + app.storage.SetUserExpiryKey(user.ID, expiry) + name := app.getAddressOrName(user.ID) + // Skip blank contact info + if name == "" { + continue + } + msg, err := app.email.constructExpiryReminder(user.Name, expiry.Expiry, app, false) + if err != nil { + app.err.Printf(lm.FailedConstructExpiryReminderMessage, user.ID, err) + } else if err := app.sendByID(msg, user.ID); err != nil { + app.err.Printf(lm.FailedSendExpiryReminderMessage, user.ID, name, err) + } else { + app.info.Printf(lm.SentExpiryReminderMessage, user.ID, name) + } + } + } continue } @@ -131,6 +158,10 @@ func (app *appContext) checkUsers() { } else if deleteAfterPeriod > 0 && !alreadyExpired { // Otherwise, mark the expiry as done pending a delete after N days. expiry.DeleteAfterPeriod = true + // Sure, we haven't contacted them yet, but we're about to + if shouldContact { + expiry.LastNotified = time.Now() + } app.storage.SetUserExpiryKey(user.ID, expiry) } diff --git a/users.go b/users.go index 242c355..f981651 100644 --- a/users.go +++ b/users.go @@ -143,8 +143,8 @@ func (app *appContext) NewUserPostVerification(p NewUserParams) (out NewUserData if len(webhookURIs) != 0 { summary := app.userSummary(out.User) for _, uri := range webhookURIs { + pendingTasks.Add(1) go func() { - pendingTasks.Add(1) app.webhooks.Send(uri, summary) pendingTasks.Done() }() From 0b43ad4ed5477a0c608c868ea703c476fe88c308 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sat, 23 Aug 2025 14:59:04 +0100 Subject: [PATCH 06/90] template: passed var and conditional names don't include braces pass []string{"username"}, rather than []string{"{username}"}. Tests have been updated. --- template.go | 9 ++++----- template_test.go | 12 ++++++------ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/template.go b/template.go index d0fadb8..76ce7df 100644 --- a/template.go +++ b/template.go @@ -18,6 +18,7 @@ func truthy(val interface{}) bool { } // Templater for custom emails. +// Slices "variables", "conditionals", and map "values" should NOT wrap names in { and }. // Variables should be written as {varName}. // If statements should be written as {if (!)varName}...{endif}. // Strings are true if != "", ints are true if != 0. @@ -95,8 +96,7 @@ func templateEmail(content string, variables []string, conditionals []string, va positive = false varName = varName[1:] } - wrappedVarName := "{" + varName + "}" - validVar := slices.Contains(conditionals, wrappedVarName) + validVar := slices.Contains(conditionals, varName) if validVar { ifTrue = positive == truthy(values[varName]) } else { @@ -123,10 +123,9 @@ func templateEmail(content string, variables []string, conditionals []string, va if ifStart != -1 { continue } - wrappedVarName := "{" + varName + "}" - validVar := slices.Contains(variables, wrappedVarName) + validVar := slices.Contains(variables, varName) if !validVar { - out += wrappedVarName + out += "{" + varName + "}" continue } out += fmt.Sprint(values[varName]) diff --git a/template_test.go b/template_test.go index 465bc2b..b6bf1d0 100644 --- a/template_test.go +++ b/template_test.go @@ -23,7 +23,7 @@ func TestBlankTemplate(t *testing.T) { func testConditional(isTrue bool, t *testing.T) { in := `Success, {username}! Your account has been created. {if myCondition}Log in at {myAccountURL} with username {username} to get started.{endif}` - vars := []string{"{username}", "{myAccountURL}", "{myCondition}"} + vars := []string{"username", "myAccountURL", "myCondition"} conds := vars vals := map[string]any{ "username": "TemplateUsername", @@ -64,7 +64,7 @@ func TestConditionalFalse(t *testing.T) { func TestTemplateDoubleBraceGracefulHandling(t *testing.T) { in := `Success, {{username}}! Your account has been created. Log in at {myAccountURL} with username {username} to get started.` - vars := []string{"{username}", "{myAccountURL}"} + vars := []string{"username", "myAccountURL"} vals := map[string]any{ "username": "TemplateUsername", "myAccountURL": "TemplateURL", @@ -87,16 +87,16 @@ func TestTemplateDoubleBraceGracefulHandling(t *testing.T) { func TestVarAtAnyPosition(t *testing.T) { in := `Success, user! Your account has been created. Log in at myAccountURL with your username to get started.` - vars := []string{"{username}", "{myAccountURL}"} + vars := []string{"username", "myAccountURL"} vals := map[string]any{ "username": "TemplateUsername", "myAccountURL": "TemplateURL", } for i := range in { - newIn := in[0:i] + vars[0] + in[i:] + newIn := in[0:i] + "{" + vars[0] + "}" + in[i:] - target := strings.ReplaceAll(newIn, vars[0], vals["username"].(string)) + target := strings.ReplaceAll(newIn, "{"+vars[0]+"}", vals["username"].(string)) out, err := templateEmail(newIn, vars, []string{}, vals) @@ -105,7 +105,7 @@ func TestVarAtAnyPosition(t *testing.T) { } if out != target { - t.Fatalf(`returned string doesn't match desired output: "%+v" != "%+v"`, out, target) + t.Fatalf(`returned string doesn't match desired output: "%+v" != "%+v, from "%+v""`, out, target, newIn) } } } From 60dbfa2d1e1f409e819fd5ec963fdcb78c7c8ea6 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sat, 30 Aug 2025 14:21:26 +0100 Subject: [PATCH 07/90] messages: custom content described in customcontent.go, message tests customcontent.go constains a structure with all the custom content, methods for getting display names, subjects, etc., and a list of variables, conditionals, and placeholder values. Tests for constructX methods included in email_test.go, and all jfa-go tests can be run with make INTERNAL=off test. --- Makefile | 9 +- api-invites.go | 6 +- api-messages.go | 235 +++---- api-userpage.go | 4 +- api-users.go | 30 +- api.go | 21 +- backups.go | 9 +- backups_test.go | 4 +- config.go | 370 +++++----- customcontent.go | 372 ++++++++++ discord.go | 2 +- email.go | 898 +++++++----------------- email_test.go | 490 +++++++++++++ external.go | 18 - fs.go | 29 + internal.go | 3 - lang.go | 10 +- logger/logger.go | 18 +- main.go | 121 ++-- migrations.go | 4 +- pwreset.go | 6 +- scripts/scrape-custom-content-schema.py | 16 + storage.go | 93 ++- updater.go | 2 +- user-d.go | 4 +- users.go | 2 +- views.go | 11 +- 27 files changed, 1646 insertions(+), 1141 deletions(-) create mode 100644 customcontent.go create mode 100644 email_test.go create mode 100644 fs.go create mode 100644 scripts/scrape-custom-content-schema.py 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, }, ) From e67f1bf1a988f58b48eb28e0db3a8b79fc795488 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sun, 31 Aug 2025 17:12:50 +0100 Subject: [PATCH 08/90] emails: fix and confirm function of all emails both custom and standard emails tested, quite a few fixes made, including to an old bug with admin notifs. --- api-invites.go | 4 +- api-messages.go | 15 +++---- api-users.go | 53 +++++++++++----------- args.go | 4 ++ auth.go | 13 +++++- customcontent.go | 2 +- email.go | 99 +++++++++++++++++++----------------------- generic-d.go | 8 ++++ main.go | 37 ++++++++-------- router.go | 9 ++-- ts/modules/settings.ts | 14 +++--- user-d.go | 2 +- 12 files changed, 135 insertions(+), 125 deletions(-) diff --git a/api-invites.go b/api-invites.go index 1ecfd47..4a47d28 100644 --- a/api-invites.go +++ b/api-invites.go @@ -124,7 +124,7 @@ func (app *appContext) deleteExpiredInvite(data Invite) { func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup { notify := data.Notify - if !emailEnabled || !app.config.Section("notifications").Key("enabled").MustBool(false) || len(notify) != 0 { + if !emailEnabled || !app.config.Section("notifications").Key("enabled").MustBool(false) || len(notify) == 0 { return nil } var wait sync.WaitGroup @@ -283,7 +283,7 @@ func (app *appContext) GetInviteCount(gc *gin.Context) { // @Summary Get the number of invites stored in the database that have been used (but are still valid). // @Produce json // @Success 200 {object} PageCountDTO -// @Router /invites/count [get] +// @Router /invites/count/used [get] // @Security Bearer // @tags Invites func (app *appContext) GetInviteUsedCount(gc *gin.Context) { diff --git a/api-messages.go b/api-messages.go index 82dbd8d..c3378d1 100644 --- a/api-messages.go +++ b/api-messages.go @@ -148,11 +148,11 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) { case "PasswordReset": msg, err = app.email.constructReset(PasswordReset{}, true) case "UserDeleted": - msg, err = app.email.constructDeleted("", true) + msg, err = app.email.constructDeleted("", "", true) case "UserDisabled": - msg, err = app.email.constructDisabled("", true) + msg, err = app.email.constructDisabled("", "", true) case "UserEnabled": - msg, err = app.email.constructEnabled("", true) + msg, err = app.email.constructEnabled("", "", true) case "UserExpiryAdjusted": msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", true) case "ExpiryReminder": @@ -164,7 +164,7 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) { case "EmailConfirmation": msg, err = app.email.constructConfirmation("", "", "", true) case "UserExpired": - msg, err = app.email.constructUserExpired(true) + msg, err = app.email.constructUserExpired("", true) case "Announcement": case "UserPage": case "UserLogin": @@ -181,14 +181,13 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) { } } - var mail *Message + var mail *Message = nil if contentInfo.ContentType == CustomMessage { - mail = &Message{} - err = app.email.construct(EmptyCustomContent, CustomContent{ + mail, err = app.email.construct(EmptyCustomContent, CustomContent{ Name: EmptyCustomContent.Name, Enabled: true, Content: "
", - }, map[string]any{}, mail) + }, map[string]any{}) if err != nil { respondBool(500, false, gc) return diff --git a/api-users.go b/api-users.go index 3b272c4..dc89f23 100644 --- a/api-users.go +++ b/api-users.go @@ -380,19 +380,6 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) { "SetPolicy": map[string]string{}, } sendMail := messagesEnabled - var msg *Message - var err error - if sendMail { - if req.Enabled { - msg, err = app.email.constructEnabled(req.Reason, false) - } else { - msg, err = app.email.constructDisabled(req.Reason, false) - } - if err != nil { - app.err.Printf(lm.FailedConstructEnableDisableMessage, "?", err) - sendMail = false - } - } activityType := ActivityDisabled if req.Enabled { activityType = ActivityEnabled @@ -404,6 +391,18 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) { app.err.Printf(lm.FailedGetUser, user.ID, lm.Jellyfin, err) continue } + var msg *Message + if sendMail { + if req.Enabled { + msg, err = app.email.constructEnabled(user.Name, req.Reason, false) + } else { + msg, err = app.email.constructDisabled(user.Name, req.Reason, false) + } + if err != nil { + app.err.Printf(lm.FailedConstructEnableDisableMessage, "?", err) + sendMail = false + } + } err, _, _ = app.SetUserDisabled(user, !req.Enabled) if err != nil { errors["SetPolicy"][user.ID] = err.Error() @@ -449,15 +448,6 @@ func (app *appContext) DeleteUsers(gc *gin.Context) { gc.BindJSON(&req) errors := map[string]string{} sendMail := messagesEnabled - var msg *Message - var err error - if sendMail { - msg, err = app.email.constructDeleted(req.Reason, false) - if err != nil { - app.err.Printf(lm.FailedConstructDeletionMessage, "?", err) - sendMail = false - } - } for _, userID := range req.Users { user, err := app.jf.UserByID(userID, false) if err != nil { @@ -465,6 +455,15 @@ func (app *appContext) DeleteUsers(gc *gin.Context) { errors[userID] = err.Error() } + var msg *Message = nil + if sendMail { + msg, err = app.email.constructDeleted(user.Name, req.Reason, false) + if err != nil { + app.err.Printf(lm.FailedConstructDeletionMessage, "?", err) + sendMail = false + } + } + deleted := false err, deleted = app.DeleteUser(user) if err != nil { @@ -677,11 +676,10 @@ func (app *appContext) Announce(gc *gin.Context) { app.err.Printf(lm.FailedGetUser, userID, lm.Jellyfin, err) continue } - msg := &Message{} - err = app.email.construct(AnnouncementCustomContent(req.Subject), CustomContent{ + msg, err := app.email.construct(AnnouncementCustomContent(req.Subject), CustomContent{ Enabled: true, Content: req.Message, - }, map[string]any{"username": user.Name}, msg) + }, map[string]any{"username": user.Name}) if err != nil { app.err.Printf(lm.FailedConstructAnnouncementMessage, userID, err) respondBool(500, false, gc) @@ -694,11 +692,10 @@ func (app *appContext) Announce(gc *gin.Context) { } // app.info.Printf(lm.SentAnnouncementMessage, "*", "?") } else { - msg := &Message{} - err := app.email.construct(AnnouncementCustomContent(req.Subject), CustomContent{ + msg, err := app.email.construct(AnnouncementCustomContent(req.Subject), CustomContent{ Enabled: true, Content: req.Message, - }, map[string]any{"username": ""}, msg) + }, map[string]any{"username": ""}) if err != nil { app.err.Printf(lm.FailedConstructAnnouncementMessage, "*", err) respondBool(500, false, gc) diff --git a/args.go b/args.go index 3f2db7d..bf58876 100644 --- a/args.go +++ b/args.go @@ -31,6 +31,7 @@ func (app *appContext) loadArgs(firstCall bool) { SWAGGER = flag.Bool("swagger", false, "Enable swagger at /swagger/index.html") flag.BoolVar(&NO_API_AUTH_DO_NOT_USE, "disable-api-auth-do-not-use", false, "Disables API authentication. DO NOT USE!") + flag.StringVar(&NO_API_AUTH_FORCE_JFID, "disable-api-auth-force-jf-id", "", "Assume given JFID when API auth is disabled.") flag.Parse() if *help { @@ -52,11 +53,14 @@ func (app *appContext) loadArgs(firstCall bool) { if NO_API_AUTH_DO_NOT_USE && *DEBUG { NO_API_AUTH_DO_NOT_USE = false + forceJfID := NO_API_AUTH_FORCE_JFID + NO_API_AUTH_FORCE_JFID = "" buf := bufio.NewReader(os.Stdin) app.err.Print(lm.NoAPIAuthPrompt) sentence, err := buf.ReadBytes('\n') if err == nil && strings.ContainsRune(string(sentence), 'y') { NO_API_AUTH_DO_NOT_USE = true + NO_API_AUTH_FORCE_JFID = forceJfID } } } diff --git a/auth.go b/auth.go index 7a0a939..b1e8e68 100644 --- a/auth.go +++ b/auth.go @@ -40,7 +40,11 @@ func (app *appContext) logIpErr(gc *gin.Context, user bool, out string) { } func (app *appContext) webAuth() gin.HandlerFunc { - return app.authenticate + if NO_API_AUTH_DO_NOT_USE { + return app.bogusAuthenticate + } else { + return app.authenticate + } } func (app *appContext) authLog(v any) { app.debug.PrintfCustomLevel(4, lm.FailedAuthRequest, v) } @@ -138,6 +142,13 @@ func (app *appContext) authenticate(gc *gin.Context) { gc.Next() } +// bogusAuthenticate is for use with NO_API_AUTH_DO_NOT_USE, it sets the jfId/userId value from NO_API_AUTH_FORCE_JF_ID. +func (app *appContext) bogusAuthenticate(gc *gin.Context) { + gc.Set("jfId", NO_API_AUTH_FORCE_JFID) + gc.Set("userId", NO_API_AUTH_FORCE_JFID) + gc.Next() +} + func checkToken(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("Unexpected signing method %v", token.Header["alg"]) diff --git a/customcontent.go b/customcontent.go index 09f37fd..21c623c 100644 --- a/customcontent.go +++ b/customcontent.go @@ -245,7 +245,7 @@ var customContent = map[string]CustomContentInfo{ "reason", ), Placeholders: defaultVals(map[string]any{ - "newExpiry": "", + "newExpiry": "01/01/01 00:00", "reason": "Reason", }), SourceFile: ContentSourceFileInfo{ diff --git a/email.go b/email.go index d5a8fdc..78e94a1 100644 --- a/email.go +++ b/email.go @@ -246,14 +246,21 @@ type templ interface { Execute(wr io.Writer, data interface{}) error } -func (emailer *Emailer) construct(contentInfo CustomContentInfo, cc CustomContent, data map[string]any, msg *Message) error { +func (emailer *Emailer) construct(contentInfo CustomContentInfo, cc CustomContent, data map[string]any) (*Message, error) { + msg := &Message{ + Subject: contentInfo.Subject(emailer.config, &emailer.lang), + } + // Template the subject for bonus points + if subject, err := templateEmail(msg.Subject, contentInfo.Variables, contentInfo.Conditionals, data); err == nil { + msg.Subject = subject + } if cc.Enabled { // Use template email, rather than the built-in's email file. contentInfo.SourceFile = customContent["TemplateEmail"].SourceFile content, err := templateEmail(cc.Content, contentInfo.Variables, contentInfo.Conditionals, data) if err != nil { emailer.err.Printf(lm.FailedConstructCustomContent, msg.Subject, err) - return err + return msg, err } html := markdown.ToHTML([]byte(content), nil, markdownRenderer) text := stripMarkdown(content) @@ -268,10 +275,6 @@ func (emailer *Emailer) construct(contentInfo CustomContentInfo, cc CustomConten 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 = "" @@ -313,7 +316,7 @@ func (emailer *Emailer) construct(contentInfo CustomContentInfo, cc CustomConten tpl, err = textTemplate.ParseFS(filesystem, fpath) } if err != nil { - return fmt.Errorf("error reading from fs path \"%s\": %v", fpath, err) + return msg, fmt.Errorf("error reading from fs path \"%s\": %v", fpath, err) } // For constructTemplate, if "md" is found in data it's used in stead of "text". foundMarkdown := false @@ -326,7 +329,7 @@ func (emailer *Emailer) construct(contentInfo CustomContentInfo, cc CustomConten var tplData bytes.Buffer err = tpl.Execute(&tplData, data) if err != nil { - return err + return msg, err } if foundMarkdown { data["plaintext"], data["md"] = data["md"], data["plaintext"] @@ -339,10 +342,10 @@ func (emailer *Emailer) construct(contentInfo CustomContentInfo, cc CustomConten msg.Markdown = tplData.String() } } - return nil + return msg, nil } -func (emailer *Emailer) baseValues(name string, username string, placeholders bool, values map[string]any) (CustomContentInfo, map[string]any, *Message) { +func (emailer *Emailer) baseValues(name string, username string, placeholders bool, values map[string]any) (CustomContentInfo, map[string]any) { contentInfo := customContent[name] template := map[string]any{ "username": username, @@ -355,17 +358,14 @@ func (emailer *Emailer) baseValues(name string, username string, placeholders bo template[v] = "{" + v + "}" } } - email := &Message{ - Subject: contentInfo.Subject(emailer.config, &emailer.lang), - } - return contentInfo, template, email + return contentInfo, template } 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{ + contentInfo, template := emailer.baseValues("EmailConfirmation", username, placeholders, map[string]any{ "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}), "clickBelow": emailer.lang.EmailConfirmation.get("clickBelow"), "ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"), @@ -381,15 +381,14 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, placeh template["confirmationURL"] = inviteLink } cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name) - err := emailer.construct(contentInfo, cc, template, msg) - return msg, err + return emailer.construct(contentInfo, cc, template) } func (emailer *Emailer) constructInvite(invite Invite, placeholders bool) (*Message, error) { expiry := invite.ValidTill 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{ + contentInfo, template := emailer.baseValues("InviteEmail", "", placeholders, map[string]any{ "hello": emailer.lang.InviteEmail.get("hello"), "youHaveBeenInvited": emailer.lang.InviteEmail.get("youHaveBeenInvited"), "toJoin": emailer.lang.InviteEmail.get("toJoin"), @@ -404,13 +403,12 @@ func (emailer *Emailer) constructInvite(invite Invite, placeholders bool) (*Mess template["inviteExpiry"] = emailer.lang.InviteEmail.template("inviteExpiry", template) } cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name) - err := emailer.construct(contentInfo, cc, template, msg) - return msg, err + return emailer.construct(contentInfo, cc, template) } func (emailer *Emailer) constructExpiry(invite Invite, placeholders bool) (*Message, error) { expiry := formatDatetime(invite.ValidTill) - contentInfo, template, msg := emailer.baseValues("InviteExpiry", "", placeholders, map[string]any{ + contentInfo, template := emailer.baseValues("InviteExpiry", "", placeholders, map[string]any{ "inviteExpired": emailer.lang.InviteExpiry.get("inviteExpired"), "notificationNotice": emailer.lang.InviteExpiry.get("notificationNotice"), "expiredAt": emailer.lang.InviteExpiry.get("expiredAt"), @@ -421,14 +419,13 @@ func (emailer *Emailer) constructExpiry(invite Invite, placeholders bool) (*Mess template["expiredAt"] = emailer.lang.InviteExpiry.template("expiredAt", template) } cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name) - err := emailer.construct(contentInfo, cc, template, msg) - return msg, err + return emailer.construct(contentInfo, cc, template) } func (emailer *Emailer) constructCreated(username, address string, when time.Time, invite Invite, placeholders bool) (*Message, error) { // NOTE: This was previously invite.Created, not sure why. created := formatDatetime(when) - contentInfo, template, msg := emailer.baseValues("UserCreated", username, placeholders, map[string]any{ + contentInfo, template := emailer.baseValues("UserCreated", username, placeholders, map[string]any{ "aUserWasCreated": emailer.lang.UserCreated.get("aUserWasCreated"), "nameString": emailer.lang.Strings.get("name"), "addressString": emailer.lang.Strings.get("emailAddress"), @@ -446,8 +443,7 @@ func (emailer *Emailer) constructCreated(username, address string, when time.Tim } } cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name) - err := emailer.construct(contentInfo, cc, template, msg) - return msg, err + return emailer.construct(contentInfo, cc, template) } func (emailer *Emailer) constructReset(pwr PasswordReset, placeholders bool) (*Message, error) { @@ -456,7 +452,7 @@ func (emailer *Emailer) constructReset(pwr PasswordReset, placeholders bool) (*M } 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{ + contentInfo, template := emailer.baseValues("PasswordReset", pwr.Username, placeholders, map[string]any{ "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": pwr.Username}), "someoneHasRequestedReset": emailer.lang.PasswordReset.get("someoneHasRequestedReset"), "ifItWasYou": emailer.lang.PasswordReset.get("ifItWasYou"), @@ -487,50 +483,49 @@ func (emailer *Emailer) constructReset(pwr PasswordReset, placeholders bool) (*M } } cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name) - err := emailer.construct(contentInfo, cc, template, msg) - return msg, err + return emailer.construct(contentInfo, cc, template) } -func (emailer *Emailer) constructDeleted(reason string, placeholders bool) (*Message, error) { +func (emailer *Emailer) constructDeleted(username, reason string, placeholders bool) (*Message, error) { if placeholders { + username = "{username}" reason = "{reason}" } - contentInfo, template, msg := emailer.baseValues("UserDeleted", "", placeholders, map[string]any{ + contentInfo, template := emailer.baseValues("UserDeleted", username, placeholders, map[string]any{ "yourAccountWas": emailer.lang.UserDeleted.get("yourAccountWasDeleted"), "reasonString": emailer.lang.Strings.get("reason"), "reason": reason, }) cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name) - err := emailer.construct(contentInfo, cc, template, msg) - return msg, err + return emailer.construct(contentInfo, cc, template) } -func (emailer *Emailer) constructDisabled(reason string, placeholders bool) (*Message, error) { +func (emailer *Emailer) constructDisabled(username, reason string, placeholders bool) (*Message, error) { if placeholders { + username = "{username}" reason = "{reason}" } - contentInfo, template, msg := emailer.baseValues("UserDeleted", "", placeholders, map[string]any{ + contentInfo, template := emailer.baseValues("UserDisabled", username, placeholders, map[string]any{ "yourAccountWas": emailer.lang.UserDisabled.get("yourAccountWasDisabled"), "reasonString": emailer.lang.Strings.get("reason"), "reason": reason, }) cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name) - err := emailer.construct(contentInfo, cc, template, msg) - return msg, err + return emailer.construct(contentInfo, cc, template) } -func (emailer *Emailer) constructEnabled(reason string, placeholders bool) (*Message, error) { +func (emailer *Emailer) constructEnabled(username, reason string, placeholders bool) (*Message, error) { if placeholders { + username = "{username}" reason = "{reason}" } - contentInfo, template, msg := emailer.baseValues("UserDeleted", "", placeholders, map[string]any{ + contentInfo, template := emailer.baseValues("UserEnabled", username, placeholders, map[string]any{ "yourAccountWas": emailer.lang.UserEnabled.get("yourAccountWasEnabled"), "reasonString": emailer.lang.Strings.get("reason"), "reason": reason, }) cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name) - err := emailer.construct(contentInfo, cc, template, msg) - return msg, err + return emailer.construct(contentInfo, cc, template) } func (emailer *Emailer) constructExpiryAdjusted(username string, expiry time.Time, reason string, placeholders bool) (*Message, error) { @@ -538,7 +533,7 @@ func (emailer *Emailer) constructExpiryAdjusted(username string, expiry time.Tim username = "{username}" } exp := formatDatetime(expiry) - contentInfo, template, msg := emailer.baseValues("UserExpiryAdjusted", username, placeholders, map[string]any{ + contentInfo, template := emailer.baseValues("UserExpiryAdjusted", username, placeholders, map[string]any{ "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}), "yourExpiryWasAdjusted": emailer.lang.UserExpiryAdjusted.get("yourExpiryWasAdjusted"), "ifPreviouslyDisabled": emailer.lang.UserExpiryAdjusted.get("ifPreviouslyDisabled"), @@ -554,8 +549,7 @@ func (emailer *Emailer) constructExpiryAdjusted(username string, expiry time.Tim }) } } - err := emailer.construct(contentInfo, cc, template, msg) - return msg, err + return emailer.construct(contentInfo, cc, template) } func (emailer *Emailer) constructExpiryReminder(username string, expiry time.Time, placeholders bool) (*Message, error) { @@ -563,7 +557,7 @@ func (emailer *Emailer) constructExpiryReminder(username string, expiry time.Tim username = "{username}" } d, t, expiresIn := emailer.formatExpiry(expiry, false) - contentInfo, template, msg := emailer.baseValues("ExpiryReminder", username, placeholders, map[string]any{ + contentInfo, template := emailer.baseValues("ExpiryReminder", username, placeholders, map[string]any{ "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}), "yourAccountIsDueToExpire": emailer.lang.ExpiryReminder.get("yourAccountIsDueToExpire"), "expiresIn": expiresIn, @@ -576,8 +570,7 @@ func (emailer *Emailer) constructExpiryReminder(username string, expiry time.Tim template["yourAccountIsDueToExpire"] = emailer.lang.ExpiryReminder.template("yourAccountIsDueToExpire", template) } } - err := emailer.construct(contentInfo, cc, template, msg) - return msg, err + return emailer.construct(contentInfo, cc, template) } func (emailer *Emailer) constructWelcome(username string, expiry time.Time, placeholders bool) (*Message, error) { @@ -586,7 +579,7 @@ func (emailer *Emailer) constructWelcome(username string, expiry time.Time, plac username = "{username}" exp = "{yourAccountWillExpire}" } - contentInfo, template, msg := emailer.baseValues("WelcomeEmail", username, placeholders, map[string]any{ + contentInfo, template := emailer.baseValues("WelcomeEmail", username, placeholders, map[string]any{ "welcome": emailer.lang.WelcomeEmail.get("welcome"), "youCanLoginWith": emailer.lang.WelcomeEmail.get("youCanLoginWith"), "jellyfinURLString": emailer.lang.WelcomeEmail.get("jellyfinURL"), @@ -604,18 +597,16 @@ func (emailer *Emailer) constructWelcome(username string, expiry time.Time, plac template["yourAccountWillExpire"] = exp } } - err := emailer.construct(contentInfo, cc, template, msg) - return msg, err + return emailer.construct(contentInfo, cc, template) } -func (emailer *Emailer) constructUserExpired(placeholders bool) (*Message, error) { - contentInfo, template, msg := emailer.baseValues("UserExpired", "", placeholders, map[string]any{ +func (emailer *Emailer) constructUserExpired(username string, placeholders bool) (*Message, error) { + contentInfo, template := emailer.baseValues("UserExpired", username, placeholders, map[string]any{ "yourAccountHasExpired": emailer.lang.UserExpired.get("yourAccountHasExpired"), "contactTheAdmin": emailer.lang.UserExpired.get("contactTheAdmin"), }) cc := emailer.storage.MustGetCustomContentKey(contentInfo.Name) - err := emailer.construct(contentInfo, cc, template, msg) - return msg, err + return emailer.construct(contentInfo, cc, template) } // calls the send method in the underlying emailClient. diff --git a/generic-d.go b/generic-d.go index 775ac0c..e10e9d1 100644 --- a/generic-d.go +++ b/generic-d.go @@ -11,6 +11,7 @@ import ( type GenericDaemon struct { Stopped bool ShutdownChannel chan string + TriggerChannel chan bool Interval time.Duration period time.Duration jobs []func(app *appContext) @@ -27,6 +28,7 @@ func NewGenericDaemon(interval time.Duration, app *appContext, jobs ...func(app d := GenericDaemon{ Stopped: false, ShutdownChannel: make(chan string), + TriggerChannel: make(chan bool), Interval: interval, period: interval, app: app, @@ -46,6 +48,8 @@ func (d *GenericDaemon) run() { case <-d.ShutdownChannel: d.ShutdownChannel <- "Down" return + case <-d.TriggerChannel: + break case <-time.After(d.period): break } @@ -61,6 +65,10 @@ func (d *GenericDaemon) run() { } } +func (d *GenericDaemon) Trigger() { + d.TriggerChannel <- true +} + func (d *GenericDaemon) Shutdown() { d.Stopped = true d.ShutdownChannel <- "Down" diff --git a/main.go b/main.go index 8c6c712..24d392f 100644 --- a/main.go +++ b/main.go @@ -112,18 +112,19 @@ 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 - storage *Storage - validator Validator - email *Emailer - telegram *TelegramDaemon - discord *DiscordDaemon - matrix *MatrixDaemon - contactMethods []ContactMethodLinker + jf *mediabrowser.MediaBrowser + authJf *mediabrowser.MediaBrowser + ombi *OmbiWrapper + js *JellyseerrWrapper + thirdPartyServices []ThirdPartyService + storage *Storage + validator Validator + email *Emailer + telegram *TelegramDaemon + discord *DiscordDaemon + matrix *MatrixDaemon + housekeepingDaemon, userDaemon *GenericDaemon + contactMethods []ContactMethodLinker LoggerSet host string port int @@ -505,13 +506,13 @@ func start(asDaemon, firstCall bool) { os.Exit(0) } - invDaemon := newHousekeepingDaemon(time.Duration(60*time.Second), app) - go invDaemon.run() - defer invDaemon.Shutdown() + app.housekeepingDaemon = newHousekeepingDaemon(time.Duration(60*time.Second), app) + go app.housekeepingDaemon.run() + defer app.housekeepingDaemon.Shutdown() - userDaemon := newUserDaemon(time.Duration(60*time.Second), app) - go userDaemon.run() - defer userDaemon.Shutdown() + app.userDaemon = newUserDaemon(time.Duration(60*time.Second), app) + go app.userDaemon.run() + defer app.userDaemon.Shutdown() var jellyseerrDaemon *GenericDaemon if app.config.Section("jellyseerr").Key("enabled").MustBool(false) && app.config.Section("jellyseerr").Key("import_existing").MustBool(false) { diff --git a/router.go b/router.go index d69fb79..e086ae6 100644 --- a/router.go +++ b/router.go @@ -18,6 +18,7 @@ import ( var ( // Disables authentication for the API. Do not use! NO_API_AUTH_DO_NOT_USE = false + NO_API_AUTH_FORCE_JFID = "" ) // loads HTML templates. If [files]/html_templates is set, alternative files inside the directory are loaded in place of the internal templates. @@ -188,11 +189,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) { } var api *gin.RouterGroup - if NO_API_AUTH_DO_NOT_USE && *DEBUG { - api = router.Group("/") - } else { - api = router.Group("/", app.webAuth()) - } + api = router.Group("/", app.webAuth()) for _, p := range routePrefixes { var user *gin.RouterGroup @@ -244,6 +241,8 @@ func (app *appContext) loadRoutes(router *gin.Engine) { api.POST(p+"/config", app.ModifyConfig) api.POST(p+"/restart", app.restart) api.GET(p+"/logs", app.GetLog) + api.POST(p+"/tasks/housekeeping", func(gc *gin.Context) { app.housekeepingDaemon.Trigger(); gc.Status(http.StatusNoContent) }) + api.POST(p+"/tasks/users", func(gc *gin.Context) { app.userDaemon.Trigger(); gc.Status(http.StatusNoContent) }) api.POST(p+"/backups", app.CreateBackup) api.GET(p+"/backups/:fname", app.GetBackup) api.GET(p+"/backups", app.GetBackups) diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts index e927252..19a74a6 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -1126,9 +1126,9 @@ class MessageEditor { this._variables.innerHTML = innerHTML let buttons = this._variables.querySelectorAll("span.button") as NodeListOf; for (let i = 0; i < this._templ.variables.length; i++) { - buttons[i].innerHTML = `` + this._templ.variables[i] + ``; + buttons[i].innerHTML = `` + "{" + this._templ.variables[i] + "}" + ``; buttons[i].onclick = () => { - insertText(this._textArea, this._templ.variables[i]); + insertText(this._textArea, "{" + this._templ.variables[i] + "}"); this.loadPreview(); // this._timeout = setTimeout(this.loadPreview, this._finishInterval); } @@ -1146,9 +1146,9 @@ class MessageEditor { this._conditionals.innerHTML = innerHTML buttons = this._conditionals.querySelectorAll("span.button") as NodeListOf; for (let i = 0; i < this._templ.conditionals.length; i++) { - buttons[i].innerHTML = `{if ` + this._templ.conditionals[i].slice(1) + ``; + buttons[i].innerHTML = `{if ` + this._templ.conditionals[i] + "}" + ``; buttons[i].onclick = () => { - insertText(this._textArea, "{if " + this._templ.conditionals[i].slice(1) + "{endif}"); + insertText(this._textArea, "{if " + this._templ.conditionals[i] + "}" + "{endif}"); this.loadPreview(); // this._timeout = setTimeout(this.loadPreview, this._finishInterval); } @@ -1162,9 +1162,9 @@ class MessageEditor { let content = this._textArea.value; if (this._templ.variables) { for (let variable of this._templ.variables) { - let value = this._templ.values[variable.slice(1, -1)]; - if (value === undefined) { value = variable; } - content = content.replace(new RegExp(variable, "g"), value); + let value = this._templ.values[variable]; + if (value === undefined) { value = "{" + variable + "}"; } + content = content.replace(new RegExp("{" + variable + "}", "g"), value); } } if (this._templ.html == "") { diff --git a/user-d.go b/user-d.go index b605d97..d4aca9c 100644 --- a/user-d.go +++ b/user-d.go @@ -173,7 +173,7 @@ func (app *appContext) checkUsers(remindBeforeExpiry *DayTimerSet) { if name == "" { continue } - msg, err := app.email.constructUserExpired(false) + msg, err := app.email.constructUserExpired(user.Name, false) if err != nil { app.err.Printf(lm.FailedConstructExpiryMessage, user.ID, err) } else if err := app.sendByID(msg, user.ID); err != nil { From febbe27a0de74e61ca588c3155fc6654ce0ca1aa Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sun, 31 Aug 2025 17:31:48 +0100 Subject: [PATCH 09/90] emails: fix conditionals not being cleared on editor load --- customcontent.go | 2 +- ts/modules/settings.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/customcontent.go b/customcontent.go index 21c623c..76c936e 100644 --- a/customcontent.go +++ b/customcontent.go @@ -52,7 +52,7 @@ var customContent = map[string]CustomContentInfo{ "time", ), Placeholders: defaultVals(map[string]any{ - "expiresIn": "3d", + "expiresIn": "3d 4h 32m", "date": "20/08/25", "time": "14:19", }), diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts index 19a74a6..2a992b3 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -1137,6 +1137,7 @@ class MessageEditor { innerHTML = ''; if (this._templ.conditionals == null || this._templ.conditionals.length == 0) { this._conditionalsLabel.classList.add("unfocused"); + this._conditionals.textContent = ``; } else { for (let i = this._templ.conditionals.length-1; i >= 0; i--) { let ci = i % colors.length; From 87c0f54a8d04d6cce81c34e5fff4dbd0c66642ef Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sun, 31 Aug 2025 17:39:08 +0100 Subject: [PATCH 10/90] email_test: allow running with INTERNAL=on --- backups.go | 1 - email.go | 3 +++ email_test.go | 39 ++++++++++++++++++++------------------- external.go | 16 ++++++++++------ internal.go | 7 ++++--- mail/deleted.mjml | 2 ++ mail/deleted.txt | 2 ++ main.go | 3 ++- 8 files changed, 43 insertions(+), 30 deletions(-) diff --git a/backups.go b/backups.go index 33b16b2..eadad73 100644 --- a/backups.go +++ b/backups.go @@ -214,7 +214,6 @@ func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) { count += 1 backupsByCommit[b.Commit] = count } - fmt.Printf("remaining:%+v\n", backupsByCommit) } // fmt.Printf("toDelete: %d, backCount: %d, keep: %d, length: %d\n", toDelete, backups.count, toKeep, len(backups.files)) if toDelete > 0 && toDelete <= backups.count { diff --git a/email.go b/email.go index 78e94a1..1660dc6 100644 --- a/email.go +++ b/email.go @@ -492,6 +492,7 @@ func (emailer *Emailer) constructDeleted(username, reason string, placeholders b reason = "{reason}" } contentInfo, template := emailer.baseValues("UserDeleted", username, placeholders, map[string]any{ + "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}), "yourAccountWas": emailer.lang.UserDeleted.get("yourAccountWasDeleted"), "reasonString": emailer.lang.Strings.get("reason"), "reason": reason, @@ -506,6 +507,7 @@ func (emailer *Emailer) constructDisabled(username, reason string, placeholders reason = "{reason}" } contentInfo, template := emailer.baseValues("UserDisabled", username, placeholders, map[string]any{ + "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}), "yourAccountWas": emailer.lang.UserDisabled.get("yourAccountWasDisabled"), "reasonString": emailer.lang.Strings.get("reason"), "reason": reason, @@ -520,6 +522,7 @@ func (emailer *Emailer) constructEnabled(username, reason string, placeholders b reason = "{reason}" } contentInfo, template := emailer.baseValues("UserEnabled", username, placeholders, map[string]any{ + "helloUser": emailer.lang.Strings.template("helloUser", tmpl{"username": username}), "yourAccountWas": emailer.lang.UserEnabled.get("yourAccountWasEnabled"), "reasonString": emailer.lang.Strings.get("reason"), "reason": reason, diff --git a/email_test.go b/email_test.go index 0e33225..25e15fb 100644 --- a/email_test.go +++ b/email_test.go @@ -1,8 +1,6 @@ package main import ( - "embed" - "errors" "fmt" "io/fs" "log" @@ -18,8 +16,6 @@ import ( "github.com/timshannon/badgerhold/v4" ) -//go:embed build/data/config-default.ini -var configFS embed.FS var db *badgerhold.Store func dbClose(e *Emailer) { @@ -34,9 +30,6 @@ func Fatal(err any) { // 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", @@ -47,20 +40,16 @@ func NewTestEmailer() (*Emailer, error) { }, sender: &DummyClient{}, } - dConfig, err := fs.ReadFile(configFS, "build/data/config-default.ini") - if err != nil { - return emailer, err - } - wd, err := os.Getwd() + // Assume our working directory is the root of the repo + wd, _ := os.Getwd() + loadFilesystems(filepath.Join(wd, "build"), logger.NewEmptyLogger()) + dConfig, err := fs.ReadFile(localFS, "config-default.ini") if err != nil { return emailer, err } // Force emailer to construct markdown discordEnabled = true - // 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) @@ -327,13 +316,17 @@ func TestDeleted(t *testing.T) { } testContent(e, customContent["UserDeleted"], t, func(t *testing.T) { reason := shortuuid.New() - msg, err := e.constructDeleted(reason, false) + username := shortuuid.New() + msg, err := e.constructDeleted(username, reason, false) if err != nil { t.Fatalf("failed construct: %+v", err) } for _, content := range []string{msg.Text, msg.HTML} { if !strings.Contains(content, reason) { - t.Fatalf("reason n)ot found in output: %s", content) + t.Fatalf("reason not found in output: %s", content) + } + if !strings.Contains(content, username) { + t.Fatalf("username not found in output: %s", content) } } }) @@ -348,7 +341,8 @@ func TestDisabled(t *testing.T) { } testContent(e, customContent["UserDeleted"], t, func(t *testing.T) { reason := shortuuid.New() - msg, err := e.constructDisabled(reason, false) + username := shortuuid.New() + msg, err := e.constructDisabled(username, reason, false) if err != nil { t.Fatalf("failed construct: %+v", err) } @@ -356,6 +350,9 @@ func TestDisabled(t *testing.T) { if !strings.Contains(content, reason) { t.Fatalf("reason not found in output: %s", content) } + if !strings.Contains(content, username) { + t.Fatalf("username not found in output: %s", content) + } } }) } @@ -369,7 +366,8 @@ func TestEnabled(t *testing.T) { } testContent(e, customContent["UserDeleted"], t, func(t *testing.T) { reason := shortuuid.New() - msg, err := e.constructEnabled(reason, false) + username := shortuuid.New() + msg, err := e.constructEnabled(username, reason, false) if err != nil { t.Fatalf("failed construct: %+v", err) } @@ -377,6 +375,9 @@ func TestEnabled(t *testing.T) { if !strings.Contains(content, reason) { t.Fatalf("reason not found in output: %s", content) } + if !strings.Contains(content, username) { + t.Fatalf("username not found in output: %s", content) + } } }) } diff --git a/external.go b/external.go index df69e94..3036a89 100644 --- a/external.go +++ b/external.go @@ -4,10 +4,11 @@ package main import ( - "log" "os" "path/filepath" "strings" + + "github.com/hrfee/jfa-go/logger" ) const binaryType = "external" @@ -28,9 +29,12 @@ func FSJoin(elem ...string) string { return strings.TrimSuffix(path, sep) } -func loadFilesystems() { - log.Println("Using external storage") - executable, _ := os.Executable() - localFS = dirFS(filepath.Join(filepath.Dir(executable), "data")) - langFS = dirFS(filepath.Join(filepath.Dir(executable), "data", "lang")) +func loadFilesystems(rootDir string, logger *logger.Logger) { + logger.Println("Using external storage") + if rootDir == "" { + executable, _ := os.Executable() + rootDir = filepath.Dir(executable) + } + localFS = dirFS(filepath.Join(rootDir, "data")) + langFS = dirFS(filepath.Join(rootDir, "data", "lang")) } diff --git a/internal.go b/internal.go index 2f6046e..0e521c5 100644 --- a/internal.go +++ b/internal.go @@ -6,7 +6,8 @@ package main import ( "embed" "io/fs" - "log" + + "github.com/hrfee/jfa-go/logger" ) const binaryType = "internal" @@ -35,8 +36,8 @@ func FSJoin(elem ...string) string { return out[:len(out)-1] } -func loadFilesystems() { +func loadFilesystems(rootDir string, logger *logger.Logger) { langFS = rewriteFS{laFS, "lang/"} localFS = rewriteFS{loFS, "data/"} - log.Println("Using internal storage") + logger.Println("Using internal storage") } diff --git a/mail/deleted.mjml b/mail/deleted.mjml index 28f3e72..066f59b 100644 --- a/mail/deleted.mjml +++ b/mail/deleted.mjml @@ -60,6 +60,8 @@ +

{{ .helloUser }}

+

{{ .yourAccountWas }}

{{ .reasonString }}: {{ .reason }}

diff --git a/mail/deleted.txt b/mail/deleted.txt index a6e90c4..e003e52 100644 --- a/mail/deleted.txt +++ b/mail/deleted.txt @@ -1,3 +1,5 @@ +{{ .helloUser }} + {{ .yourAccountWas }} {{ .reasonString }}: {{ .reason }} diff --git a/main.go b/main.go index 24d392f..e94f8a6 100644 --- a/main.go +++ b/main.go @@ -784,7 +784,8 @@ func main() { if flagPassed("test") { TEST = true } - loadFilesystems() + executable, _ := os.Executable() + loadFilesystems(filepath.Dir(executable), logger.NewLogger(os.Stdout, "[INFO] ", log.Ltime, color.FgHiWhite)) quit := make(chan os.Signal, 0) signal.Notify(quit, os.Interrupt, syscall.SIGTERM) From 0783749e6ef92060f23cc239bba21ddc822100e1 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sun, 31 Aug 2025 17:58:13 +0100 Subject: [PATCH 11/90] matrix: add log for matrix crypto store init deleting the crypto DB resulted in InitMatrixCrypto taking ages, added a Initing/Inited log pair around the function so it's obvious this is the culprit if any one else faces the same thing. --- logmessages/logmessages.go | 14 ++++++++------ matrix.go | 2 +- matrix_crypto.go | 6 +++++- matrix_nocrypto.go | 7 +++++-- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/logmessages/logmessages.go b/logmessages/logmessages.go index 3a25b16..8d2c730 100644 --- a/logmessages/logmessages.go +++ b/logmessages/logmessages.go @@ -64,12 +64,14 @@ const ( TimedOut = "timed out" FailedGenericWithCode = "failed (code %d)" - InitDiscord = "Initialized Discord daemon" - FailedInitDiscord = "Failed to initialize Discord daemon: %v" - InitTelegram = "Initialized Telegram daemon" - FailedInitTelegram = "Failed to initialize Telegram daemon: %v" - InitMatrix = "Initialized Matrix daemon" - FailedInitMatrix = "Failed to initialize Matrix daemon: %v" + InitDiscord = "Initialized Discord daemon" + FailedInitDiscord = "Failed to initialize Discord daemon: %v" + InitTelegram = "Initialized Telegram daemon" + FailedInitTelegram = "Failed to initialize Telegram daemon: %v" + InitMatrix = "Initialized Matrix daemon" + FailedInitMatrix = "Failed to initialize Matrix daemon: %v" + InitingMatrixCrypto = "Initializing Matrix encryption store" + InitMatrixCrypto = "Initialized Matrix encryption store" InitRouter = "Initializing router" LoadRoutes = "Loading Routes" diff --git a/matrix.go b/matrix.go index 3147a70..ec6d16a 100644 --- a/matrix.go +++ b/matrix.go @@ -101,7 +101,7 @@ func newMatrixDaemon(app *appContext) (d *MatrixDaemon, err error) { d.languages[id.RoomID(user.RoomID)] = user.Lang } } - err = InitMatrixCrypto(d) + err = InitMatrixCrypto(d, app.info) return } diff --git a/matrix_crypto.go b/matrix_crypto.go index 30416ea..83bd014 100644 --- a/matrix_crypto.go +++ b/matrix_crypto.go @@ -6,6 +6,8 @@ package main import ( "context" + "github.com/hrfee/jfa-go/logger" + lm "github.com/hrfee/jfa-go/logmessages" _ "github.com/mattn/go-sqlite3" "maunium.net/go/mautrix/crypto/cryptohelper" "maunium.net/go/mautrix/event" @@ -22,7 +24,8 @@ func BuildTagsE2EE() { func MatrixE2EE() bool { return true } -func InitMatrixCrypto(d *MatrixDaemon) error { +func InitMatrixCrypto(d *MatrixDaemon, logger *logger.Logger) error { + logger.Printf(lm.InitingMatrixCrypto) d.Encryption = d.app.config.Section("matrix").Key("encryption").MustBool(false) if !d.Encryption { // return fmt.Errorf("encryption disabled") @@ -45,6 +48,7 @@ func InitMatrixCrypto(d *MatrixDaemon) error { d.bot.Crypto = d.crypto.helper d.Encryption = true + logger.Printf(lm.InitMatrixCrypto) return nil } diff --git a/matrix_nocrypto.go b/matrix_nocrypto.go index 8a2f84e..8e0f87d 100644 --- a/matrix_nocrypto.go +++ b/matrix_nocrypto.go @@ -3,7 +3,10 @@ package main -import "maunium.net/go/mautrix/id" +import ( + "github.com/hrfee/jfa-go/logger" + "maunium.net/go/mautrix/id" +) type Crypto struct{} @@ -11,7 +14,7 @@ func BuildTagsE2EE() {} func MatrixE2EE() bool { return false } -func InitMatrixCrypto(d *MatrixDaemon) (err error) { +func InitMatrixCrypto(d *MatrixDaemon, logger *logger.Logger) (err error) { d.Encryption = false return } From eb941794a873afc2a4028c301585a8b6fd247598 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sun, 31 Aug 2025 18:05:49 +0100 Subject: [PATCH 12/90] ci: run tests added precompile and test "steps". --- .woodpecker/git-binary.yaml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.woodpecker/git-binary.yaml b/.woodpecker/git-binary.yaml index c9534c5..c74179e 100644 --- a/.woodpecker/git-binary.yaml +++ b/.woodpecker/git-binary.yaml @@ -12,6 +12,23 @@ clone: depth: 0 steps: + - name: precompile + image: docker.io/hrfee/jfa-go-build-docker:latest + environment: + JFA_GO_SNAPSHOT: y + JFA_GO_BUILT_BY: + from_secret: BUILT_BY + commands: + - npm i + - make precompile + - name: test + image: docker.io/hrfee/jfa-go-build-docker:latest + environment: + JFA_GO_SNAPSHOT: y + JFA_GO_BUILT_BY: + from_secret: BUILT_BY + commands: + - make test - name: build image: docker.io/hrfee/jfa-go-build-docker:latest environment: From 8781e4860197a9f46f728dd8dcf6eea49004892f Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 1 Sep 2025 15:27:57 +0100 Subject: [PATCH 13/90] email: change font, template common parts Using the newer Jellyfin logo font for the header and hanken grotesk for the body. Tried to redo emails with maizzle because using tailwind sounded nice, but getting it to look like a17t would be more trouble than it's worth, since you can't use CSS vars in emails and a17t uses them heavily. Instead, cleaned up the mj-header a little and stored it in a separate file, and also the header & footer, and changed the template vars with {{ .header }} and {{ .footer }} for all emails. Values are determined by CustomContentInfo.Header/FooterText funcs. nil values are replaced at program start by _runtimeValidator. also, i beg of you don't try to do light/dark mode with mjml, you'll want to die. --- .woodpecker/git-binary.yaml | 1 + css/colors.js | 18 +++++ customcontent.go | 33 +++++++- email.go | 37 ++++----- mail/confirmation.mjml | 91 ++++----------------- mail/created.mjml | 107 ++++++------------------- mail/deleted.mjml | 91 ++++----------------- mail/email.mjml | 84 ------------------- mail/expired.mjml | 87 +++----------------- mail/expiry-adjusted.mjml | 97 ++++------------------ mail/expiry-reminder.mjml | 88 +++----------------- mail/invite-email.mjml | 93 ++++----------------- mail/layout/body-end.mjml | 5 ++ mail/layout/body-start.mjml | 5 ++ mail/layout/header.mjml | 64 +++++++++++++++ mail/password-reset.mjml | 23 ++++++ mail/{email.txt => password-reset.txt} | 0 mail/template.mjml | 85 +++----------------- mail/user-expired.mjml | 87 +++----------------- mail/welcome.mjml | 93 ++++----------------- storage.go | 3 +- tailwind.config.js | 19 +---- 22 files changed, 318 insertions(+), 893 deletions(-) create mode 100644 css/colors.js delete mode 100644 mail/email.mjml create mode 100644 mail/layout/body-end.mjml create mode 100644 mail/layout/body-start.mjml create mode 100644 mail/layout/header.mjml create mode 100644 mail/password-reset.mjml rename mail/{email.txt => password-reset.txt} (100%) diff --git a/.woodpecker/git-binary.yaml b/.woodpecker/git-binary.yaml index c74179e..6559ce5 100644 --- a/.woodpecker/git-binary.yaml +++ b/.woodpecker/git-binary.yaml @@ -21,6 +21,7 @@ steps: commands: - npm i - make precompile + - go mod download - name: test image: docker.io/hrfee/jfa-go-build-docker:latest environment: diff --git a/css/colors.js b/css/colors.js new file mode 100644 index 0000000..f631140 --- /dev/null +++ b/css/colors.js @@ -0,0 +1,18 @@ +const colors = require("tailwindcss/colors"); +const dark = require("../css/dark"); + +export const colorSet = { + neutral: colors.slate, + positive: colors.green, + urge: colors.violet, + warning: colors.yellow, + info: colors.blue, + critical: colors.red, + d_neutral: dark.d_neutral, + d_positive: dark.d_positive, + d_urge: dark.d_urge, + d_warning: dark.d_warning, + d_info: dark.d_info, + d_critical: dark.d_critical, + discord: "#5865F2" +}; diff --git a/customcontent.go b/customcontent.go index 76c936e..0b199e3 100644 --- a/customcontent.go +++ b/customcontent.go @@ -19,6 +19,18 @@ func defaultVals(vals map[string]any) map[string]any { return vals } +func vendorHeader(config *Config, lang *emailLang) string { return "jfa-go" } +func serverHeader(config *Config, lang *emailLang) string { + if substituteStrings == "" { + return "Jellyfin" + } else { + return substituteStrings + } +} +func messageFooter(config *Config, lang *emailLang) string { + return config.Section("messages").Key("message").String() +} + var customContent = map[string]CustomContentInfo{ "EmailConfirmation": { Name: "EmailConfirmation", @@ -94,6 +106,10 @@ var customContent = map[string]CustomContentInfo{ Subject: func(config *Config, lang *emailLang) string { return lang.InviteExpiry.get("title") }, + HeaderText: vendorHeader, + FooterText: func(config *Config, lang *emailLang) string { + return lang.InviteExpiry.get("notificationNotice") + }, Variables: []string{ "code", "time", @@ -131,7 +147,7 @@ var customContent = map[string]CustomContentInfo{ Section: "password_resets", SettingPrefix: "email_", // This was the first email type added, hence the undescriptive filename. - DefaultValue: "email", + DefaultValue: "password-reset", }, }, "UserCreated": { @@ -141,6 +157,10 @@ var customContent = map[string]CustomContentInfo{ Subject: func(config *Config, lang *emailLang) string { return lang.UserCreated.get("title") }, + HeaderText: vendorHeader, + FooterText: func(config *Config, lang *emailLang) string { + return lang.UserCreated.get("notificationNotice") + }, Variables: []string{ "code", "name", @@ -346,6 +366,8 @@ var EmptyCustomContent = CustomContentInfo{ Subject: func(config *Config, lang *emailLang) string { return "EmptyCustomContent" }, + HeaderText: serverHeader, + FooterText: messageFooter, Description: nil, Variables: []string{}, Placeholders: map[string]any{}, @@ -359,6 +381,7 @@ var AnnouncementCustomContent = func(subject string) CustomContentInfo { return cci } +// Validates customContent and sets default fields if needed. var _runtimeValidation = func() bool { for name, cc := range customContent { if name != cc.Name { @@ -367,6 +390,14 @@ var _runtimeValidation = func() bool { if cc.DisplayName == nil { panic(fmt.Errorf("no customContent[%s] DisplayName set", name)) } + if cc.HeaderText == nil { + cc.HeaderText = serverHeader + customContent[name] = cc + } + if cc.FooterText == nil { + cc.FooterText = messageFooter + customContent[name] = cc + } } return true }() diff --git a/email.go b/email.go index 1660dc6..10aaf46 100644 --- a/email.go +++ b/email.go @@ -269,9 +269,6 @@ func (emailer *Emailer) construct(contentInfo CustomContentInfo, cc CustomConten "plaintext": text, "md": content, } - if message, ok := data["message"]; ok { - templateData["message"] = message - } data = templateData } var err error = nil @@ -280,11 +277,8 @@ func (emailer *Emailer) construct(contentInfo CustomContentInfo, cc CustomConten msg.Text = "" msg.Markdown = "" msg.HTML = "" - if substituteStrings == "" { - data["jellyfin"] = "Jellyfin" - } else { - data["jellyfin"] = substituteStrings - } + data["header"] = contentInfo.HeaderText(emailer.config, &emailer.lang) + data["footer"] = contentInfo.FooterText(emailer.config, &emailer.lang) var keys []string plaintext := emailer.config.Section("email").Key("plaintext").MustBool(false) if plaintext { @@ -349,7 +343,6 @@ func (emailer *Emailer) baseValues(name string, username string, placeholders bo 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. @@ -409,11 +402,10 @@ func (emailer *Emailer) constructInvite(invite Invite, placeholders bool) (*Mess func (emailer *Emailer) constructExpiry(invite Invite, placeholders bool) (*Message, error) { expiry := formatDatetime(invite.ValidTill) contentInfo, template := emailer.baseValues("InviteExpiry", "", placeholders, map[string]any{ - "inviteExpired": emailer.lang.InviteExpiry.get("inviteExpired"), - "notificationNotice": emailer.lang.InviteExpiry.get("notificationNotice"), - "expiredAt": emailer.lang.InviteExpiry.get("expiredAt"), - "code": "\"" + invite.Code + "\"", - "time": expiry, + "inviteExpired": emailer.lang.InviteExpiry.get("inviteExpired"), + "expiredAt": emailer.lang.InviteExpiry.get("expiredAt"), + "code": "\"" + invite.Code + "\"", + "time": expiry, }) if !placeholders { template["expiredAt"] = emailer.lang.InviteExpiry.template("expiredAt", template) @@ -426,15 +418,14 @@ func (emailer *Emailer) constructCreated(username, address string, when time.Tim // NOTE: This was previously invite.Created, not sure why. created := formatDatetime(when) contentInfo, template := emailer.baseValues("UserCreated", username, placeholders, map[string]any{ - "aUserWasCreated": emailer.lang.UserCreated.get("aUserWasCreated"), - "nameString": emailer.lang.Strings.get("name"), - "addressString": emailer.lang.Strings.get("emailAddress"), - "timeString": emailer.lang.UserCreated.get("time"), - "notificationNotice": emailer.lang.UserCreated.get("notificationNotice"), - "code": "\"" + invite.Code + "\"", - "name": username, - "time": created, - "address": address, + "aUserWasCreated": emailer.lang.UserCreated.get("aUserWasCreated"), + "nameString": emailer.lang.Strings.get("name"), + "addressString": emailer.lang.Strings.get("emailAddress"), + "timeString": emailer.lang.UserCreated.get("time"), + "code": "\"" + invite.Code + "\"", + "name": username, + "time": created, + "address": address, }) if !placeholders { template["aUserWasCreated"] = emailer.lang.UserCreated.template("aUserWasCreated", template) diff --git a/mail/confirmation.mjml b/mail/confirmation.mjml index a3f0023..0584101 100644 --- a/mail/confirmation.mjml +++ b/mail/confirmation.mjml @@ -1,78 +1,17 @@ - - - - - - - :root { - Color-scheme: light dark; - supported-color-schemes: light dark; - } - @media (prefers-color-scheme: light) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - @media (prefers-color-scheme: dark) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - - - - - - - - - - - - - - - - {{ .jellyfin }} - - - - - -

{{ .helloUser }}

-

{{ .clickBelow }}

-

{{ .ifItWasNotYou }}

-
- {{ .confirmEmail }} -
-
- - - - {{ .message }} - - - - + + + + + + +

{{ .helloUser }}

+

{{ .clickBelow }}

+

{{ .ifItWasNotYou }}

+
+ {{ .confirmEmail }} +
+
+ +
diff --git a/mail/created.mjml b/mail/created.mjml index 40a1417..824cada 100644 --- a/mail/created.mjml +++ b/mail/created.mjml @@ -1,86 +1,25 @@ - - - - - - - :root { - Color-scheme: light dark; - supported-color-schemes: light dark; - } - @media (prefers-color-scheme: light) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - @media (prefers-color-scheme: dark) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - - - - - - - - - - - - - - - - jellyfin-accounts - - - - - -

{{ .aUserWasCreated }}

-
- - - {{ .nameString }} - {{ .addressString }} - {{ .timeString }} - - - {{ .name }} - {{ .address }} - {{ .time }} - -
-
- - - - {{ .notificationNotice }} - - - - + + + + + + +

{{ .aUserWasCreated }}

+
+ + + {{ .nameString }} + {{ .addressString }} + {{ .timeString }} + + + {{ .name }} + {{ .address }} + {{ .time }} + +
+
+ +
diff --git a/mail/deleted.mjml b/mail/deleted.mjml index 066f59b..5cd0793 100644 --- a/mail/deleted.mjml +++ b/mail/deleted.mjml @@ -1,78 +1,17 @@ - - - - - - - :root { - Color-scheme: light dark; - supported-color-schemes: light dark; - } - @media (prefers-color-scheme: light) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - @media (prefers-color-scheme: dark) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - - - - - - - - - - - - - - - - {{ .jellyfin }} - - - - - -

{{ .helloUser }}

- -

{{ .yourAccountWas }}

-

{{ .reasonString }}: {{ .reason }}

-
-
-
- - - - {{ .message }} - - - - + + + + + + +

{{ .helloUser }}

+ +

{{ .yourAccountWas }}

+

{{ .reasonString }}: {{ .reason }}

+
+
+
+ +
diff --git a/mail/email.mjml b/mail/email.mjml deleted file mode 100644 index b919bf8..0000000 --- a/mail/email.mjml +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - :root { - Color-scheme: light dark; - supported-color-schemes: light dark; - } - @media (prefers-color-scheme: light) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - @media (prefers-color-scheme: dark) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - - - - - - - - - - - - - - - - {{ .jellyfin }} - - - - - -

{{ .helloUser }}

-

{{ .someoneHasRequestedReset }}

-

{{ .ifItWasYou }}

-

{{ .codeExpiry }}

-

{{ .ifItWasNotYou }}

-
- {{ if .link_reset }} - {{ .pin_code }} - {{ else }} - {{ .pin }} - {{ end }} -
-
- - - - {{ .message }} - - - - -
diff --git a/mail/expired.mjml b/mail/expired.mjml index 633ac72..ecede82 100644 --- a/mail/expired.mjml +++ b/mail/expired.mjml @@ -1,76 +1,15 @@ - - - - - - - :root { - Color-scheme: light dark; - supported-color-schemes: light dark; - } - @media (prefers-color-scheme: light) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - @media (prefers-color-scheme: dark) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - - - - - - - - - - - - - - - - jellyfin-accounts - - - - - -

{{ .inviteExpired }}

-

{{ .expiredAt }}

-
-
-
- - - - {{ .notificationNotice }} - - - - + + + + + + +

{{ .inviteExpired }}

+

{{ .expiredAt }}

+
+
+
+ +
diff --git a/mail/expiry-adjusted.mjml b/mail/expiry-adjusted.mjml index 55ddf7f..a20a1d0 100644 --- a/mail/expiry-adjusted.mjml +++ b/mail/expiry-adjusted.mjml @@ -1,83 +1,18 @@ - - - - - - - :root { - Color-scheme: light dark; - supported-color-schemes: light dark; - } - @media (prefers-color-scheme: light) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - @media (prefers-color-scheme: dark) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - - - - - - - - - - - - - - - - {{ .jellyfin }} - - - - - -

{{ .helloUser }}

- -

{{ .yourExpiryWasAdjusted }}

- -

{{ .ifPreviouslyDisabled }}

- -

{{ .newExpiry }}

- -

{{ .reasonString }}: {{ .reason }}

-
-
-
- - - - {{ .message }} - - - - + + + + + + +

{{ .helloUser }}

+

{{ .yourExpiryWasAdjusted }}

+

{{ .ifPreviouslyDisabled }}

+

{{ .newExpiry }}

+

{{ .reasonString }}: {{ .reason }}

+
+
+
+ +
diff --git a/mail/expiry-reminder.mjml b/mail/expiry-reminder.mjml index 3c69ecb..a30ae63 100644 --- a/mail/expiry-reminder.mjml +++ b/mail/expiry-reminder.mjml @@ -1,77 +1,15 @@ - - - - - - - :root { - Color-scheme: light dark; - supported-color-schemes: light dark; - } - @media (prefers-color-scheme: light) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - @media (prefers-color-scheme: dark) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - - - - - - - - - - - - - - - - {{ .jellyfin }} - - - - - -

{{ .helloUser }}

- -

{{ .yourAccountIsDueToExpire }}

-
-
-
- - - - {{ .message }} - - - - + + + + + + +

{{ .helloUser }}

+

{{ .yourAccountIsDueToExpire }}

+
+
+
+ +
diff --git a/mail/invite-email.mjml b/mail/invite-email.mjml index cd8f298..7221a63 100644 --- a/mail/invite-email.mjml +++ b/mail/invite-email.mjml @@ -1,79 +1,18 @@ - - - - - - - :root { - Color-scheme: light dark; - supported-color-schemes: light dark; - } - @media (prefers-color-scheme: light) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - @media (prefers-color-scheme: dark) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - - - - - - - - - - - - - - - - {{ .jellyfin }} - - - - - -

{{ .hello }},

-

{{ .youHaveBeenInvited }}

-

{{ .toJoin }}

-

{{ .inviteExpiry }}

-
- {{ .linkButton }} -
-
- - - - {{ .message }} - - - - + + + + + + +

{{ .hello }},

+

{{ .youHaveBeenInvited }}

+

{{ .toJoin }}

+

{{ .inviteExpiry }}

+
+ {{ .linkButton }} +
+
+ +
diff --git a/mail/layout/body-end.mjml b/mail/layout/body-end.mjml new file mode 100644 index 0000000..d18a334 --- /dev/null +++ b/mail/layout/body-end.mjml @@ -0,0 +1,5 @@ + + + {{ .footer }} + + diff --git a/mail/layout/body-start.mjml b/mail/layout/body-start.mjml new file mode 100644 index 0000000..a5165dc --- /dev/null +++ b/mail/layout/body-start.mjml @@ -0,0 +1,5 @@ + + + {{ .header }} + + diff --git a/mail/layout/header.mjml b/mail/layout/header.mjml new file mode 100644 index 0000000..4ce6737 --- /dev/null +++ b/mail/layout/header.mjml @@ -0,0 +1,64 @@ + + + + + + + :root { + Color-scheme: light dark; + supported-color-schemes: light dark; + } + body, .body { + background: #101010 !important; + background-color: #101010 !important; + } + .text-gray { + color: rgb(153,153,153) !important; + } + .bg-gray { + background: #292929 !important; + background-color: #292929 !important; + } + @media (prefers-color-scheme: light) { + Color-scheme: dark; + body, .body { + background: #101010 !important; + background-color: #101010 !important; + } + [data-ogsc] .body { + background: #101010 !important; + background-color: #101010 !important; + } + [data-ogsb] .body { + background: #101010 !important; + background-color: #101010 !important; + } + } + @media (prefers-color-scheme: dark) { + Color-scheme: dark; + body, .body { + background: #101010 !important; + background-color: #101010 !important; + } + [data-ogsc] .body { + background: #101010 !important; + background-color: #101010 !important; + } + [data-ogsb] .body { + background: #101010 !important; + background-color: #101010 !important; + } + } + + + + + + + + + + + + + diff --git a/mail/password-reset.mjml b/mail/password-reset.mjml new file mode 100644 index 0000000..a95f183 --- /dev/null +++ b/mail/password-reset.mjml @@ -0,0 +1,23 @@ + + + + + + + +

{{ .helloUser }}

+

{{ .someoneHasRequestedReset }}

+

{{ .ifItWasYou }}

+

{{ .codeExpiry }}

+

{{ .ifItWasNotYou }}

+
+ {{ if .link_reset }} + {{ .pin_code }} + {{ else }} + {{ .pin }} + {{ end }} +
+
+ +
+
diff --git a/mail/email.txt b/mail/password-reset.txt similarity index 100% rename from mail/email.txt rename to mail/password-reset.txt diff --git a/mail/template.mjml b/mail/template.mjml index 8e6b9d4..9283beb 100644 --- a/mail/template.mjml +++ b/mail/template.mjml @@ -1,75 +1,14 @@ - - - - - - - :root { - Color-scheme: light dark; - supported-color-schemes: light dark; - } - @media (prefers-color-scheme: light) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - @media (prefers-color-scheme: dark) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - - - - - - - - - - - - - - - - {{ .jellyfin }} - - - - - - {{ .text }} - - - - - - - {{ .message }} - - - - + + + + + + + {{ .text }} + + + + + diff --git a/mail/user-expired.mjml b/mail/user-expired.mjml index 0296423..13bd053 100644 --- a/mail/user-expired.mjml +++ b/mail/user-expired.mjml @@ -1,76 +1,15 @@ - - - - - - - :root { - Color-scheme: light dark; - supported-color-schemes: light dark; - } - @media (prefers-color-scheme: light) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - @media (prefers-color-scheme: dark) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - - - - - - - - - - - - - - - - {{ .jellyfin }} - - - - - -

{{ .yourAccountHasExpired }}

-

{{ .contactTheAdmin }}

-
-
-
- - - - {{ .message }} - - - - + + + + + + +

{{ .yourAccountHasExpired }}

+

{{ .contactTheAdmin }}

+
+
+
+ +
diff --git a/mail/welcome.mjml b/mail/welcome.mjml index 45f8d28..a14900b 100644 --- a/mail/welcome.mjml +++ b/mail/welcome.mjml @@ -1,79 +1,18 @@ - - - - - - - :root { - Color-scheme: light dark; - supported-color-schemes: light dark; - } - @media (prefers-color-scheme: light) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - @media (prefers-color-scheme: dark) { - Color-scheme: dark; - .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsc] .body { - background: #242424 !important; - background-color: #242424 !important; - } - [data-ogsb] .body { - background: #242424 !important; - background-color: #242424 !important; - } - } - - - - - - - - - - - - - - - - {{ .jellyfin }} - - - - - -

{{ .welcome }}

-

{{ .youCanLoginWith }}:

- {{ .jellyfinURLString }}: {{ .jellyfinURL }} -

{{ .usernameString }}: {{ .username }}

-

{{ .yourAccountWillExpire }}

-
-
-
- - - - {{ .message }} - - - - + + + + + + +

{{ .welcome }}

+

{{ .youCanLoginWith }}:

+ {{ .jellyfinURLString }}: {{ .jellyfinURL }} +

{{ .usernameString }}: {{ .username }}

+

{{ .yourAccountWillExpire }}

+
+
+
+ +
diff --git a/storage.go b/storage.go index 119ca89..5bc46f2 100644 --- a/storage.go +++ b/storage.go @@ -719,7 +719,8 @@ type ContentSourceFileInfo struct{ Section, SettingPrefix, DefaultValue string } type CustomContentInfo struct { Name string `json:"name" badgerhold:"key"` DisplayName, Description func(dict *Lang, lang string) string - Subject func(config *Config, lang *emailLang) string + // Subject returns the subject of the email. Header/FooterText returns what should show in the header, a nil-value implies "Jellyfin" (or user-supplied text). + Subject, HeaderText, FooterText 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"` diff --git a/tailwind.config.js b/tailwind.config.js index c51ba96..73d8ffe 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,5 +1,4 @@ -let colors = require("tailwindcss/colors") -let dark = require("./css/dark"); +import { colorSet } from "./css/colors"; module.exports = { content: ["./data/html/*.html", "./build/data/html/*.html", "./ts/*.ts", "./ts/modules/*.ts"], @@ -62,21 +61,7 @@ module.exports = { 'slide-out': 'slide-out 0.2s cubic-bezier(.08,.52,.01,.98)', 'pulse': 'pulse 0.2s cubic-bezier(0.25, 0.45, 0.45, 0.94)' }, - colors: { - neutral: colors.slate, - positive: colors.green, - urge: colors.violet, - warning: colors.yellow, - info: colors.blue, - critical: colors.red, - d_neutral: dark.d_neutral, - d_positive: dark.d_positive, - d_urge: dark.d_urge, - d_warning: dark.d_warning, - d_info: dark.d_info, - d_critical: dark.d_critical, - discord: "#5865F2" - } + colors: colorSet, } }, plugins: [require("a17t")], From c7ba9944f062c3ca36dfd19296ab645dbf228096 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 1 Sep 2025 18:30:16 +0100 Subject: [PATCH 14/90] images: change banner font use plus jakarta sans, the font used on the newer Jellyfin logo for the banner and social images. --- images/banner.svg | 56 +-- images/jfa-go-social.png | Bin 61343 -> 53465 bytes images/jfa-go-social.svg | 3 +- images/src/banner-jakarta.svg | 348 ++++++++++++++ images/src/jfa-go-social-jakarta.svg | 668 +++++++++++++++++++++++++++ 5 files changed, 1028 insertions(+), 47 deletions(-) create mode 100644 images/src/banner-jakarta.svg create mode 100644 images/src/jfa-go-social-jakarta.svg diff --git a/images/banner.svg b/images/banner.svg index 345e222..fcc40f6 100644 --- a/images/banner.svg +++ b/images/banner.svg @@ -1,59 +1,23 @@ -image/svg+xml - - + + + - + - + - + - + - - - jellyfin-accounts - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + @@ -87,7 +51,7 @@ -)p{+#mc())I&e?lH~&;3+00shpOzJy(}ZLTu=5CEH-bhXr67>H9<_^ zLSEs^LJrm$k_5zP1>=7z@uQ>uA@7>q>E+Ppzh-X3YleBeJa((^dncnGFs=iVjkAtqqZ69OU^px%$9%+o4 zIxVT~IkjFF&dM`q%(O^;tm?55|JdU<4h|0|?`8%2A&b?(#GYV@wPy^K_Pww~Mx56D znWMw?Rec&Y2K}g#{HU#6# z%YWAS!@zYiBvpZ84`P)dmJ5p8VVf8HWDv$ZK_zJa=Vz781ZX9fwWXGS-MKUwqVVcx zKoLTU2wG;ypDKDg81w)0MqEeD=cMofx zutV594$es{Vz7dZ(vK&p40`4rR)!hRw7~%x8(Hx#vf4H zF5jxMc?a@P1Nk(Lce+}B@2MTx`K}1D)EgQ^`U!5M<@+z1Z;4{Oj9}lvX1PA{Xp73s4O&ky9a-H^`zK$!4 zZC}Z@cDhrm(!usyDVlNNoI@dZ1>KV48=RiNDE&NT2* zMI!py3w312({IV3+`1kTU~gZCgnKXUf)#m2(GjA&85V`Ch75<1wG_e&D&N`rr>_;d}12v8*fXXdM~2 zNj5&-m%j6;qs+{Gr%^@Jrn=sb)RA}t{og(&90v}&MoV4Uqm?1ZBrCD@cEI0YIOHB^ zSpY40i+6z*5KY3zJmSWaj1$e@-2N1ySeJBXpcMM#v3p%u*7tWK)&1KWy}kmaiZ{Q{ z{`Hfh0sun=Qv$wYSu|Mn`h}eSOL5a#T=dQXwvP`C z=w;>RCh-(3F>>bARF#37fp_eG1afJAQk-Z*%?5S4Zbd<`OYaH45G`$ChR~wYiHD7f zoxN%-W3rQu7sbRZslP=Rfx>78p(%sl*2SC_f|LL?4-%5H%X1K|FkW#>6BWcT+ls7>Ma zRli)@cE3K7`sxY4oWUSr`po|~*QzTKdXXvrS?-OSOuFiKlOM4)f@j1A`~kjnmu)`i zXEkF(S*KkH_}NRn!7=YhrWsB;YBKZSrMQcq1(_4L9=_4P7DcQ) z(5@?OU%e1SZHMjS2vPt=Q87bnBshbH8L;II;iZmr}{LuPM8b!iI26K&Q|hb5%jm0NN+BUQ3q2 zzJng4c+3oGt<5YoWW+$2M%#yp88Ik*Ltu0*G&e$p;$1*iH@;tp1@#vRfN7NCp<0Er zXsS-hDT4aQSG5V7Pxrw;>G{c7_hs*D?tdeVxg&;4G)f5?9Uv&88^}THN=nrTESnT? zfS_D!Z;R46b3yv0La*eFo0jxn7!Rg2{cXYRVuAUnjuz4b%x47pBq0BXACP2ts3Lz4 zpKq%#Pd~+cm!8B;5chq(KNE+OWQ&c`PlQO=70D39_rX++EzmZ>3r-G^=SK*JB|$F= zXOl@i{)Boe^I>GPlV06l+lhal5#vLFbRU3$A&bBJyR_FcdHi{G74GVn`}A z42#d++iec}+`$p&e?S~x+cbYNJL-ylQ%ANi3wza}uX#q`(*tda!_V`8`lt zH+W8o{+eP+no_5CoZEg1C%&)k0IPIaf>rEZy$bR;3X_`psA&jaAY8srY3<>H4_rna z^RSK8Bq&@nSeIW}@&=5_zl)lmBkke&(iK8ly0FT-V#@Q$gD3eHM;~2;yQN1IK-+X@ z)=WNPj7(bw8`yIJV5sRIlj~I|{)jLJaVp_K=z5IzZbqqSAGNb83csZ#)g_ZZp9-#UzWeLv+2&!P^(UQJX=TW1=|LTggQ{Lcd>5M1T{EvEm519A@YW4>Bai603-jDB`hTed%C6)GY?j z6%H#$hWz7FJ2?M&R|O`tP*zdNZPF${#O=MdW7FYBq&92{!k6SlOC@joLrT(WLsCPB z%DzWsCkB1St{4wBp-R z0H2jIO~By;(=A|Y@?3POn`TwK^6&XxG?`od(t5D@IxquaT>b1|dX010aneu#e|Zg3 zWX*B#!HY;H|Jt;5Um`oRL<_kzEay1i6Ni=~3KD?OjviIQg*e-2uz8?;E2MljMBv?3G8!pPE6ySS5=2H)U z-?eM^5RQd3ZV%a@X5EMz)uAu=Ii(-vlq)$E0jI>icCL99ZgAJ(YEnfyQ1NK9thmh^ikhnxW4KK8EN77q|tm_M**0tyODT-TTqt26tj5vwu_A zlL4u!J3yE0uTvPqjdnosj9Pq4Gz)cT#)!laiefZf&qq?Momu#78N=+g&pi=H3?f~<~CZ8OkD<&QEz;DA5+b&F*)!#ly0K`i7q82(;QIc>*h#*+F6^?O2QFTeN3^k_tFY zOIIq#=B=3c{Ke*lzh0*D%74cH%)VLTBq%&GnKvpncEG?_(#$MM7XhWs9u;Do9b|oP znh}I`GDfL^Dn1x1Y+}#GLq+N0+$egRD!4QvHHB%;fu1|+VH`nngeJT(BzjB37i>Vq zH)^0ohTLbt$8arXH;KHyF*NZ!Tb?)d2R9{Fe}{=mR#p$$MH>mS(wx!7GAm(q^;L(l zd69*h8bVYiGsF9E{lf?MMaTV+3^ldXPaP@TBJtTP%N4Kx>oFV#wJuG+tVjSY~m3H#28Kfts0_`C6(i-Fbg3 z<$OE6)WY&JoC6O0fLMusLPJ9^Wg9!aN+~vY7ZOa#3<`VRsJA)A&H_Dn&SG;=$s1Rg zIPa^^!j<^*^z2Nq+>ZOV6f9KhrB;yM?m&eWf4<%qd3V~oaf+E~OsUb)mL(<6vMU91 zGgqslW3u)q*2$j@g{Hd_tZw0iJ=-rTzyac9E0`(xXF4}n1UMY7lrg*>80x(e{;{u$G5f4 z$yBsz`eJkaY(vxc2CLsg&UX)Pn_RnIngVF)Rp2R$ixj;px_%q+nz^0tapCFJQmfUB zfXXZWDEtbF-ERr$dfRU6cqP0Vl2c?*C@@~ZxaNPnRflF%m%;B zl{=Dg69u*3_{o&cEnjDOn=*5dnmF@EkZ9|+GY>50W zcv#G`jgt0#8fuJaN zm9RTo+A^Rfdk}MiMNL~h%UCZtMlPY|Olg{3@vK092ulpwRnq@D{KZNu+1BrqwMP>Z zxLV(M{FP)N2<5%Jq(s*+vt)@ftmfwH>>slNx3V6S`8==Aqqb2?`#PSAh`v&yCjEF( z{9adDOyvIv-*0nO5hT^}wfZMH&vUu#Bh_{7x=rC`Ex@CH#DCgl;Bv8ukF^PiN?SZl zA3NRpVBvYk@_8Q?1{WhYB{>S>Ygb?l}u#wxCYwy4QQ!SL(`B4jvcRQGer zZr$&ZZjlJ?@GNL>Jy$U_#8ox;+&5s^8OR-o5`NxPhDrk&G0yymvlcd;VnV zOw6uD^yf<_@v_>ZL`r@3VZ>_k?5si-E`Cc1&|*I{B$d%lS}SPS3@O+XAu|lzgu}M- zN1hs}5@Q`({?5|cVy-820CQhyg%qBN9yeV3w~trfL)s+zyxoyt9E;3p6?pW!q9SaX zW5mv+-{-{Hq{=mOAJWSyV}-V>tCiZD4-j^JZ1#FD`$SFcQ<(cz5l8O&ky}>*IDKvw z!lBm_Trcuy$@-iT1AC)GSM^Hcv(EXv&4WpId2=wbOsMushc?t$K1lsj*lwOuI5u`V z+*+Z4Ij?~`eUI`C3q2B2FL?LKc15{1&+`cNv6ZR@gOV+Xt_IRD@!a_F=ntp;{Ie|2 z4Y%G!ynC@^CjVH~=!&o)(5x&wu&vvdYNdYs(w)*kB}pINXn={CopsOi6?Y1q6S3+tQdg}bq#go+m1iJT!%7m{r)H{O2geX#n` zL(p<);-i|Ug496?CC`w_$L=(4!T7=EXL|Fh?gxiYEid}7ggk9so-iA@+r6EktUaH9 zei&U$W%(fMdkXi1_oE9=dXMJ?Xg6Ypd}^;KD7K})(=b)h8dFXlImw~2)}Nd-dW zmx@ovQck4PlWp0=BO9TTrkbm}Ab;89-hqP|( z!tm(r^-Z3WR{VIU0|R)y73dg}75OPNkgfFEU&+cx#c5{<`M-+ndv)}=1lz#q1^jz<8SBk2oHH&a61x>sgb?jO{<@<#_MoV9-Frr*1IgY%&ve`*{~+kX2YgYY*`8JtiBH|s}E6ERs% zTUlngE184ewgg$XveeYls_Sa#S9j`DcsJ?#)20|b7QPtLu6-paYk;lUW{zOpk0~~? zjr;66YN2c%^3604aOjTIRkyA6tyy^x3r_{5psI?5XM}Htp#Yd<5TG?D0Vs`@E{u z@$2GjZ}F>})WdO;e)}`i*A{=B8ztnjCHt)vM`v?c=J&4c=V`^2CFm`)y};dgg2gjl zOwKDRD#|=O_n}VY6Z^`HJ%{hMB&%BG3&S@(`h5w6!p_mE`d(jNY|nh8+ol~&e%IK3MfZyn4o!v0m-y95n6H znH4y3H1+UsRa%r^>DO4K&P1-&{c5wXCqul#AX=iMzj<7o_GYb6M11T;?6-OUl3SGP z`tpWCU(VCcMpxZ?&*z}<)vcCZI)SvOo{N-E24Z`tJXfZy?hE#Kii#%ndK$ZSc#B%u zWP6bc#f50$Hk+3*6(@vJ~j}JuN4?d;a9CY0hNJ%zT zobJ?35o&Atk-HFsCrsNE;~2EtKsSGU<3&#A3h?VHojj{P9}cQ)X8|# z&*(|@WxrILF=}1trI)2u&Oj><>qL2)WIDSwF88QVjomX*ucW80c59=3nrz|gEbce- z<<5HYKHsY`QOIU{K9!^f(>oG>0ze5$0VNx&G?UsrmWiOk?g5 z6Q|uQvl2?ma0DO(DQzasVW;8(dh>X`WsXuMa~H`BSF1o2Zv?(13#92*+qz0 zUDT&|ZHkti*a5v+<_Ww0VAj=Y>)O$@ClMk}%U)+kY3C7jdpzrt2l1Ut9wJ*mFMcP~ z?zb1tw+XC9e61>YlrNgC8_8l5@l%-NY+hihHuLFM&y|dmR_IM$GQDds~bDB59xK41}W!N@w zAtPZV7JS0bqlfk2U{16ih)2y14Zgo+Q*T#P1l)4s-Bx*Co}#Q#{i^n}?>@~3Yu;S0 z9?MZm^OEeNo${^Y2AJ@#pycnT)m-ZliHR1pg4}sqTN8m^B5{fq^S-*3N3~5{=iTcH zck=ZdhJ{*h1{HIwt)^u!ul^~Tz^Gy?;i1&^vQ1|j?@K(T6UoZ|>1;4U8`7~_F>968 zxpESs>oHytmvv?JdF9?itNqgK<#ocyqm`a_VXN6kG(&xN$(xy+N}jE!h3HlqdB$wt zbh(?oJZg3D$D?L5kZ$VW#n;;1BE8LReDimKk#j#^hf+;d7s^G(PbWFyUg7-|F#@c| z6G=jkw#B{uIO(v}Lwy?Me5@+?0Em%E9E8?O@7i#Q1@2yv@7uQxPdOJ1Hc%Jl&c+SlxQ_WRtzX2Z&LrLxt zi-8XTD6g$$4yui^_GJxfhn)2Y=$2IO9>_V*%2gh0=5LSm#652Havj>LTt`js_@!RI z_#;Uj1U{Q{B}?C$zq6<6=M$%sK&Gja%2dmpU5C|i6Rx|uCrOGnnuA&FKMFNOJ#%^o zdp`tqYG{1uFF`o1PV7BYTb@l0^%{DX>bzMISLvi1Q|r;oFtxkKSSxx{BOo;GUd`bg zn{HvPaB<_$j;tb!npcnC!$p!_F#w8bF20-Rp3m8E~_(T&1xf?ZdIN_3AR2>S$U0=w?{X;=Ae;n>Tpoc zb2&hyC5MRr!(+ZpJr}JYk(~ke#F{5%dG~V5Vh0nm)p^(7hzccq-Jl{`JSsPMu3d6I z``um8SkaU(xhA7C?O>8xaQ~zv_asuErhl35K>ERSYl(E(gWbp*qv`#)@)2*eW%cZq&8>Yg;{=Y)9n+8E3wR*F_!XT(NW6x z!`?zn%-mk?>EEt%cYgu?kcY>_d0j!ysWT+#&E39R$w85ulIbNYU%kysY(1Wo70&$( zpt9ofSXbNIPvm3QFh_;GU@BA#(s&kp9 zG2Z*^bT5^f*z>#c2m@6huT)4j*{&*NU`seTeU{4RO&CaBWW zyLwELFVA9_&sNJQ<+p26=|u4PpCnt)O}`ecely$ZM-HH)3=bQpt|7MT3fLH(&`T~m z@3GYNJQFu1lrJ~?Ap0azXFB=kxoCQJ_UTZsNZ@1MS91Y#y@Pj0c{FS5~WQSM8NJ;HSZgh#$^#t#kK%pJYt(%yh7Fal-dxew0;B8}~-dFUy11*k|*T z8fzPi(;_Y!PuR9BGe>Z2_0oQN0=rpSX!?KF{+3U2{zIhz+;P3lTlV@iJM`9oMk87sZotLzn6s?nI4L1d0H99T*@$g(4 z)mRBx?p9_B_(U&P?f&?kt$EufP!0m|Q zylnNK>VtQ7dWq5D z@x!0USP7q>wP>raq*4ne*?!089yUF=ldqe#r)ah7T|mNFY5OGk{CM-e^T;=;i#kSw z^+@iuo!{5sUcaGV$We+lL%Q5jcb24h)^M!* zzGPA2K+WQ;Q*G~ndcC{Hbhr8oSB2XlEPc`K1rtp~N~B=9*)c%8&g~ra$H}y=Gf&mE zWBRp0)DBh;9_a1%jt-j@-+Wt@-r})!yzCzc4oe%8Iz zGOPnmog1Hf@<(&|SUGDkIN1cFv-caya)+T#f-bvnhGO82P|1y6ipnAi;BSNa?NM+lO)1^Z$l4td(Cx+CS zOOO8cY7N3=b);yslG3aMdMG!Z{9ZxsBt>{Ih!$o4q-QoPr|r0*z-pvqm9Q*FrDV~y zB0FoqV}4S7WuI&QY#`vq*-Ci+*SeE?TsJUkH;f)`QQR4CMGA6{?(BKU3cPd<(sO=Z zjgY&8ZzN%r*SqFvre;QPyY9&0&;xMU(Czz~|FTxm)?7`|*JDI${^t`I_IC*b*#ZRr_r;}`i z>=N_gbZ8^ekqnyk7q3kKpX+(7)-fJeR`7_g)}exneFB)B9^KuY+^+F)4^3b1%)F_o z)x-Yg^dxzJA#BFE-(I^Q?x#@{4{(`dC-f|G--ED{>d(3e*zj}2W@W;i=VCEu+t$~om8<|UaNNFjbyPH8W-L@9%{oYJZ?4-6d$iq+$62}jEX9+#LqCi$7_P=b zT7X)Nj!X9|=OfHL_+`o z&EQD!O0cty$6TSv&$e$0`ae@b>0*?meQ)rstz;-$S(TiCN7{ZVHt?#UGOj))IoG|2 zs5luX^}JX9V4jEN_NooLKJl1h$82FN9U$J_FQrS?`=b2X9HXPYXA8!tKh~An6SQ zWwZk{`D(syM$YEGuM6KGLgFQX|@&`_lbOpu}U@RQRN7dmA_q z94jLr<;!X-9?lJI9`TU&b=GW97SPh{wU2y3NjW zp_;`F7th@euY*vsFx8vg3UMwFnZ2k~Qx$(0?e_4MhS1vy`eT~W+zd@tg@_nf_UEhMlAG|Kt zpPou}E=_ZR-sg+#G5PFX?&c^~!L94;$d@>B5%P+QB^Hz6bUy_%XkSWk^9$w9_FI@f zxLd-lo>`MBo6c@GP&ueo%v?W3h+k<{VryDNH}!yGjB|eBbxE!kRsHewJZ~NAekrw|EFF3`=847pgnG^4K*gHn!IAnPBG*z~8u=-dK=IQVmtDDa-V)Xy zf|A|LYOhjLrKg*;{h!Z_1qHcNXLn;AD_&ErJ+RE_e$nznFpjrQZ#mCey{xzHR3g8k zK+aWUB0ae&T?A*l+_827<9ubMT|!ZG{6U7osZ`10OjIc0QwtNJe4WIErHSC^wQpL( zs_8s?>&^{_L*YEAOIs@=tsO@nK;D;?u2;pfE-&+-z!Zcz*vgGgHJi#oh2 zZ|NqK{=TC+8fb4|%u@Mfi2Bjc>W|wYTGTs5PxAFg)4p2Y7^<0k2N$+Y)zPXt;SqX2 z`qbs>`nkE^!NM%(LCDe&U%aXfZ)%TU&JvH>`kxlUN)wCZ&!t$Ac5FIB32JdX6Th;l zr}8{H=9tv9f_fM6Pd?nB8{*@;8tce9BKm$%1B_0hzp(b!52iz=v{Yw%b}Oy5%HpRI zuTE`m-*)FOX(or{3Nu%BtdqV{0P}=z;U4%{)$7QP6sNQG$a|Z{2yr%zzN;6mU$-u1 zA1&T7c%TU5>4 zUK#=Ek}l~6X;_e!4y9Wfq&uY>0V$D|lJ4$qkZzXljs=$8Z{Oc_eSg52Gc(UTb>H`# zQ{82klQw~4mG6J?&XEwHnyH?Ye`z1g@~ zk|f92Cqbb^^M5^*=hg(J^I(PXOy22qw$}Bf=t@#AOTg2nblc--V7$sf?8C6>G1y$!KH!UB?p6`3 z5K%j@aa7b6h;l&Yt@w|;I=jn3BAd^IR~-OU7e&5jj+$j_fM2M6}WYb+FUyI?iM zjY~)Hs>FTcxv9s~tn2XPY4tACQDn#pi+-s1=6Q#H_%73gYGLNh5>$^q1tnft)lO&Y z57n}GMNrq{4^D&G3NK?Jrkao23v5}9`wEwCFjzh{^?YQYm>>0zgp$MIt=8g#ttU7f z*Lfe79a(h1BHL}uFu&xb1J#W04$9NnS{Hla`y1Q1`#qr0cVQ`KwjJIVtxaE)+MGas zec*U5$$hlT7|snJJAy(&`lk`2@lhmO%v-t%GNv1@c650Zkp(XISLWY}OTZ>%?mzct^SJpQvK~sjqJZCu8WEm z8Wu%0$9EN_;K-YM9M$7~>a7AUfBS0Gh_S8g`@IL+fFp}6kKxYVbu%EmaH~rT+~XQE zG}KACzE8U-4At_5l{6n`JyKU4p~f-9tM1(;RiFQR4D@NZiBM>BR`ChX{`tqiL<0CD zQS9&rB>;3G-FCkvc>0n^F_#fJXqrzW-e92+^c+t4e0ARLJ}(Vp-jCs1<1cBwyJ-5W z{HZ(OGk1G2Q}OL{bNj<-8eBz0{q2NH55mTn&UFOk2=!e$Y&xaBTrThBfw|R@PaUiG zvs*Z+eAe%F^GUqiqNj9%Il>iAZ*$iR)~H3ELv~DrJ*l&1fE$e_PSAOQSM#MZV553M z({n{2Q?Q!W_Ppo4%87@`dVbLBD5zkt+Z-b~uqHrVFu)}edRPXPTyy zYBWmSa0iOZ2aiKh_wY&?usu6BDh>{!e%THY1h ziXz#ksC3#e-v}4iMn8A2%V?E z=9B1+&8ptfe`P<7167aF=1m&A0u~X?T0N<=XGDRWMq7|U4V9Ocr!PP5HinP=;XHr-A zquCy*M6(wg;+h(ui?3UoBBj9B+3$6$hCl8?ts^|<1U-)%x7I5+sHlX$kLhXv2Fg@w z?kTV{VoCuDJ`kVsrN%M~)qMll<@1#BIXZ|wU>A;9K2RHc0MW|{81pVI4gYdnfT!WE zt}&CnT5pZex|Pg+pZBi?#f+7$M4Q5Cf8gPU^5njGTgH|k#V6nnQ*>T%J17LwkCu4k zCu{s|d!dhjcxZ2j#P{o39`sDh;GQIRqnODv$5;N}k&J4P&zk2wHu9r+Zpfd+AnWR4 z){dm#sznWFgwwprioWOXH;5=>};^KK+PORBpCATO67URKHPh+Z`uR)I|>1e}< zFB_auu3a(+GkE@3q?}h;>u*BKk(#N=1N;dOGtZ@cw=b@!x$S<&TD7Bumwf8I zc@px$4uPYul`b`8UEp=4r0E1ySwvEj`*aa_jtK(Yb|R%bhbPKj_f4(9=StFZ(cJ}N zc|CR%73*~VV>tH|0=@2jJWAq?sbLx;!DxdWBfMTauH9R|H*RuY5P`MB9;mWr{JI+W zek7jdIGD3memw{^Yy3D{tr zO~V#c^AiWQSM!j27q?S;+#&HOlgkO)!R~xPLu=U2XeFs4q{xWNV8+q}xDUuEAkpJ8 z^&U^L@pd!)Bs=Ong9kCiGG)&d#4<$B&ao*(#gi| zs(ypOtZ||{i_J~qU-0zY2FuT!KDc%(q|fC#A_od>7k<`MQzjl{{6^}y$yb0AXO<3ky zLnQoPI4Rk7y|5Aw?;3L-P8?xPdO;p9oZ>v=!!6bNmrlxLSrezBCR>#tT&KJQ)q{Iz z_2oZ>cWw<`NwxtO(00<-NO-=1YvCE1v?tChU`X9B{jE zYO;I3Z%`1ImG4(rX`t)BTe4SG>EYs=E+9rn14oNsbXFQy>6tZi&RIvWG44YJoNbl3 zF83Mlkx`|7-PbU>c{+cN^~13>iF(>B|KkWR0FOdPE3*jbAKdZtevV%6I30(+D_{ED zh3jMW^5fxY{f%}4T;C)gAoeqGa&_^Y===_@+fzq_M_RU2$-l)fl@Fdl1dBf69(H%v z>?Mhx6H`U5l{W?qsXO?>w*lI-Vyuz$U#%@y!zd z{JiSy9hLGx%7JBr&l0j`L1jp7E}FYoo*j zmR@pKMol#X(()&4Z!gkk^Mi0?0NoyYC4mnHpDt&9Npj3n6;(gm2u>z=8*7)Bd6`E0K6*P`hfe$!_w#b-Qi>=D)Tz&S&h>*xjsd z>=oag3(FN{g?S6Ao`aY|efrIKaKt}9UDQ0Uj+G?4FmJxp9$vLTx6a$|+hLEk;*D(U zh|772)}jla(~Yrm{q*ar3r7AcV;8mykpkO1)DyF7c1zWN1l9&91U>9$x7`!KDy0>+2Ej}~AOuFBKD)g31GG25(fg9Bmk7<>vx?q1aHQeQhd zlmfZ}ptYU!&s)PU{*)F1OqWNaBpA;lMlbt^RG-`&<;aPz{}txNsU6-+N;=s~=y96N zY>2W2J!xpguf4|ywx2?8>Zm&XEuCe1{&V#g?_4?6v2sOfSmB=#Nb@Rbw3+r0tpFD2`4Er%wRZuZ`(864_m6L>;SMU zmivpD)CQSqz7RYBthpdlM%YyEmLw&q(^&+L1MU3wF^fcoy*@2(D+F`BGQY1F7B$?i z$A!Bn1!wy<@z1rO)$-cWj;`17!sp6Fr^Oez{HJ@JXU|8zUUyQ(PO}{2=LWq5t-CFIBW#Z%S+qH2b(6?AKwvBF zm)xyYH-Xz*$Ch4AkHs9xUlvXVWe1ZjTfd8P_JxOi+eQl;U|{%8NHg$VGAbGgLC3Ji zsL66%rRnPjrMh253wC~&?Q<{dThweT$(~l$2M>IYuVRVRGiMXoe2+PP7p<4#haGQs z5)%H^6}|?B4JZg_^+4TE?}h6ojw2Q331?Qf#$n9{SR#;{F$^(J>9+^G9mEC1SCwuo zlWXc);i_Tbyci&YmJAkJS9J9zyFqPj>+7i>Yjek`AHHJ_BS<45Xx|!E*RZGlL5h1e zeZ;M5ZHrm5=CBeco*3_Up`suB!p;_#r6}}S^bzY8%C1;dDBp3Gaqa%_f*0%3v5ISi zYIQJ$ERu~~_L=G9GRMcJf9{?4*T|dV$1>`YGYe)P^5lB%;QbXa0Rw#s{dzurG!pc5w1fAUGx_}P#H8wXr_-+)=&*uwp3&WkT3 zFWi7v{HU0b5Wv8GrBO#V_!Ir;r--X^%lT$RSWtRF<@D%&?uS}_=@I}^PrE3p^TNTW z&E9o3fuGkHfN#1wW2Y~{qaKu7Y2L5A61-)JJ859hC-$M<)5Jbn)3*u*6YNAOQjsxq zn5>wUrL0YPbt5rj1*5Q3-_AP3$TzKsClgbNOvoq&;2nTeFAz}5QrE{!1Abl1Y^2^N zj&_Ygr|^J_Fo^i?eb!u_Lc6RfPjB-*7=lrA=(A2RIJn=rqtE&_hr}yUDF3>$f6Unf z|DYDH;{JSRF!<}) z*y4i*P(h_UiLJ09OO|kn)sOys_#1y(?2Vi-xQ}O9WunrXQTHbjm4sMKkgfsas=!+j zQ=eAK@$qwTR&4C-ImT`_HqPexovBGRj@g@ZK2wmfvWn$ivRFYrfQH@%na1q9iPET@ zwt#4!cEPcc-PdeJdP~OTXFD9|$=a=Vz?Tvv0b}C`!Ob(JCq5m=zZuDT!jxQ2FZ@rD zzeFT68AyLh^pWM!a{L9=kn^;UCFrGkPE<4$whhD}M%E-zXqFP-qQ|&UBq;s_NZS3z zF69Ej);6Hb`lQBu9>u|1n|xG#!C3ajs@6_f(lJi zyAkc*Hm9g;l-5?M0ML9mI(FO3NaYN1n6N^+^QAPWlW^45+WyUN+BTclhN{w8xMA%4(Wr~`T8C~ zlQR#t)F;FO(%296@`Kq*B}nYVND$YG%YvD3zsF58hfVF?)B4s{D?$=I9{yyATzSC+ zs=NTKs;8>+CnOYC!X0q1WNESc?_eX@@(tP`Wp$6U=(vee~h%_G68V;ms{S@1Qxk@r}jgTCm#n{jS$OXEuXiZ&z1S z%!mo=s3M&HAbdR|Z*er;V_Bg4el0^+Rd&T)*O5J-0h>mnHkbu+J$W@Geo5sj;LpzMi6^yRdO1dyBo4MAOEX$jN)iOSTL}3>v_;NBsnmlT1Wuo=tKth{%GQb@S&qE7?A- zIRurJsJdgd|E1olo+_63Kjmy2E7hD}Lp(-X{|I zn)(2*l;8*Y0dPN3eXvGW1Cr<#48?;}$ z)+^1r?isuVPab>{tI4MzmPS1{9a6^}!tKsN43_B(ajh60VlJb0599UcUk`_ny%F&8 z^Z{L8!76nOjRnrP1S_(jmvb(DbVo-wH&8KJeKX#Z>kR{Ym*F8-QeO6?EUppQs)dV zuCa9hNb|bBjVtV&`VnkEF4!5SqL2BpOp%+A=mUM#s)~(`_RlNcsVSzTBT%A_TsgqMuUR4~C-25t8Gz)b%Cg zVr0aB1Z&8%{g(PO;i-%uSzwhP?1`vuM|dS~+Ia0(5OA{lrX~Bhk;{Dj&(&K}Y6*T; z$oMNhwa8C>uGnj4ClbR`8r)v-GJ z5!~OR&G1o39;ySx>80X+E7Is_*!2D-OhGF8q(D>JHo|JIs7Y_-Jjq6buC4VIubKt} z*LB?-W?vcd{M0|1kgw`zd_fiXWm6XNVqm*EtE#sBf4rY6AJXkl9!01)>b0?qx2}p zy6HKvj5@CQ`dZz&8T-<;I&v!NO2POuRhNgG5WLrTqe)JICeOy{v!xehdv@{f1q78rfp}N6kj(d)_G5o*XK! zhdb}b< zlbrfDVUS%N#cBi?=9#6J=#%P6acaevq&Y5(^Yh>5Ult!dzN2hdKthK{8SJOOEWOD} z+hX5-G#R0j#t(Byt!P*HnR+4_$&5yE^0>PlOx?W>4Dl~VNdIE3f{I*TuRc-FB|=#* z6v};EBOR?s#;9> zz`p$+gdJGJq|TMQi7_$leMf0=V?g#m@#oQQf&@-g_mt%A{ed+_qXLV8l4^rrPQ7+` z#n2`k@O6n7n)6WuIZuL8In^MWmvI_c(9uX5VL%AmH)njSA|tim8KACF z@ZAK^($Y(73*b(wKOFp(390@Lb;inekqa8~u3E^l=aWRUy)1Wge8;mH$^2CecaW{b z$a}v5Qv?t%`=vsZ0L#$WEK=%^#MB|&;{_w-X4MJ-@8uJbN%7+caIL_{!n86Xp@@jV zjeL|f3N4#x;@tQb_kzB?w@^gJLMkN^5&$5LgC#inaD*WN$=I(x(S#O#HtJpp!y9cN zc=YxxlM<-~);uXRbGAdN+oJw04Xh}L2qJ}l0WG}qt14&RS3~;~_E^P4qPN&meDsmo zv{G~F)w(}$3U$4vY)9(JYfBlGW(4#jkwQ-PKVaSxWu>-NFk&t|L6%IF@76-`0$=(| zmuT1M&V|((q^@#p2++3o6aYAsWP@qxZ8Zn8#1L>O?QaS^su{s!kL3ZkPWziOZ8={} zzz-h&w;8xZ>Y%8cUJ0PTabRJX3ZwV|*BD8wjEr)=I@SiFT&#m3Fw@d8G}ysDd&4;F zqeI2LqLejVb3OeXXAYCY-By3~st+23pp5w^%w`paEjC3@l z#KF!G5b3Yyr$odGjyluKkAlx$70mc|PlcV=)J+lIr~m|0)v|_DI!^YbMP4p;W0(rB z1lEYasTqOo$_aWO3!&c?Q*hnQEzPdaya!Jtk78Zbk2`?dr?q#ltNC{pQ)O!m{BXRjRXWBq;#J9lsy4A^+L9Te20+?OZcH5rA9!u!o05A90zEQRfw zSsgIaOm=ntd2rdq3#qF{cRdoygY8{48a=7j1PY~s;*{&?tS$%*g#c5bq!@s|Gb_dY zIZkr@C*~Dxh1fcQv_9{92hJ;tI~8=AbT$Ey$x~%ejGDE$HA~Msoq~N~={- zZ1Fpl;US+OPq|K{c)ALM)P$Q)qOP~vhCppk&^L+mklaIFToKBvbEgTEtrw15V2lx&n`Su>H$*WZ#B|*QRtHe3sPa=&NiW$73gxJc%&Q)oEclK^J2RsaOvgDbt!sxi zS&{Ua9hdm%v!kxxlhpq>=LT&<%{m@Efs8{>)$fal&~V~+>Xekc1Da^yG_40r%2w`u z()^!aor%hte>pgbYML?xab?{EVU30*ZVNGVo4v5F<`j{gDyoL94L=?Qstk<7LOH%s z*zE(eg@}kyh#fK+Gm{7Z41Z9PO{U<$OP>OLY|yscp-I-bSYShd`g9+ zL^5XXT*9gpP;YJ~xyd|qJrv(SPcAw8RGJs^B?~t?SStV~EQD$qs8dAe1B}mYd2(q6f3W9X&W_;V!PG#Z3;f>YqBHKGF!rt3e zsF1@>zFMnq-};XK8lYIo3BGd&N0QsC|KQljh%=9T1MDbcEMjSv>%=BM+y~|RS?`c| z&0E~_k7^oAu%9&>#Q8$NLu7=OvAKB%hD+=t!yJf+^F4d>Q|Tp{ma`)4>}+==TzD=b z;LOSOwiduXOTrPu4k;lwzPog1&GE7IBx}t!P~h|SJ;z({dPfIKZ+E?fAQuk#zu$Tp zcA`PLlEqj4S^RVP$5JOHgl_?MZS|!{e@XUt|8{34PD5?*tg6|T8n}!GgYYG9RZOcrP2#J%TpZskyft;nu#C$>@=$^R(m&BF`9@XqL=^Ng@hBKF%@lAf9)nqOo z?K_0oNh}(fyzSI3-?kg6YJNdcBk0^|V_knuT^S*^eO6GddgB#jE3jyoqkkw@g2btB z80_?VkLd8!r>xD;!t!s&t&f0-2^W22PU_~RP>7a$%|fKXTP(V6;LHH^BSe0T_#i`_ z#k^^XC|-EEF5j@>{QcbeVZS_G1t{HKa7W&29u%9!x{b?Cq+C4*Iw=YV@0(2YVSPua zf$s--7ZU{mvhg6~hOEDBxpa=|&4Gw{r$q;$2cL+&y>_?}tC!Z)YiuIKGI#`O#Q zxpXcr0zw(F(^e$Z17 za}GQ!^>A}@*(I?CtwgGh=A6-_ zbTl@SyAkj8(q0{Ri;qXKRaN%=VjFYW>tWVcYn|CVh;aH<#wk}wwa}=S5u_nO%*i5| zhOSm%!Gq1DcIDTW8Ew3=7`sp&^S-n9P-r`KiRB3m&!kx3JfN^oE~vslJGju1>g+e; zN{_n(G15g_0fi1{iomfv-K&L^*tIIKtEE-*=lKc;>bj@in{VwE$c~|b{P*A)k?rA9 zQ7ko@7=<|LriEaNEW8hcU--W)b2p1%e!)YcfukU59l2tF`Sr`;+(+zP&m=1C{ndDI zUz_H7dlg~Hv%k&BZK20VqJ|gpg(M+`;M1Dhx!ioZ?yT;&VF+t`>72xHC!Sd6Wk^}I zi$GAvQe??Pyy^G}7~#xaPsMk=s{p;Zn)&0Q4KAFHw4u7Q=y?|*NGQua(QEgw-|T~_ zTs}&@Ql+kDcDPs$4l6;lOHcQ=)9<2#c#qT#tvsManrX%k3Ryil|Hm6%|MNP0ij#*O z$!3y(DQ-px9x^?}%{l7cN?eA?xBioNE2YUcZ)KBz)h{*h1R)Qjy|yTr0-g&r$*DS; zF#0#q;f#`tzfdrb338P>RxN2HT{|9%ClZE(9ONuiDy)D4F^Lq;PDET9O;o=fgIBR@ zr+;fI2jg?ksXlTg-eaBKPIw=kux5Bq9pER6$=HY875-5YmX+WMa(mCp43<|IBRe=W zdwS;eXO{AP_U7f`)26{4w4e3@HE~T4TKW;kBCv6B^o}Vh^|x{?9o9sc11Or_scDRa0Ly&gus=nuAi35 z(d3-3*Tp7Ce9E$(HOWtu_Du*L%8Wuum9FK$iR}?JzF)BIP*jB_(y@DfV~8GTM>PwW z{taxm0CW+SGy?1F(b??kc?|YE{HzJ}xeI4P>`4w{+cvbt+? zZmm}ykg2-NyOxH{?@F?UBq<8^YqsgzfNi}&Jvj$EXHzZdon08UUqmq9QVdf<0_frC zsT9A)e|mwBa#=k+-jRb-%&jjP>2)-PVg-JRgn>AZ99$vNJtMnIYhh}IOrshAb#v5u zD$Hu4Lq%pcCvc9qLfozfTIRx3kFND#zHNdjyYuwGv zs`wP6C}SMsU`n$$(`S|o)GS_qRTr+%ZyZ;A%qRyS26;xbn?Adgnvh% zFq=xu3xwWc8&*gW_-rm17^ov&SNYG%IBnHWMYY34>^`Y9i8L~3b2T2esmb-MAoiZ( z^cEE4!_egOyv_X0*bsm@{Q;tIGVgot+d3Kz^KyAp!J@iFtVPoYGV#LR&bKc|aU3?E z0YwZMuFqt|;+Y^*zkPOb=MFYALEin&TtM-|aj{TjI1G-;RaB0KsF-F`U&++e8*Cs* z5>x#t4tXWk-)AM+>n*sN)BULXby^_*+c!>qZ`E*oX-59LwVX>Cvm}(aaaI(GGUni2 zdJgolpLZc}{3o8CG^UrXPehFb1G!L0POAz<>+~|iA|B_$OBAwaC0dpOKsF>NQU zu?WBz8aqo`@=J2dJtCVH0?zb=fG;v@KFvssn)Q+QPB(j&bP_5aDga>5{v^=*CVkR( zlYOf|ygshYBb67o&Rtr6*orD-=Rsuoo%jV{hKFq6b=s>~4Dr#BJ_p@ch&N%4=vM?+ zqw*1nk|Q1Npc{3f#dratY!_?;8z<+<8?kwk1kPtBZA7VfU_1Ox@gzLyA0F!2{YfuY zvnIkvr?Z!T6=tJoR^~lt)4)o+dsQNDz&&x)MrM$E`O(SiM9aD1pi~CoCJ~#B#G5J1 z5=~W@=LdrYGqqf3LiF~QUL{hX*p+_?b2QK10nXr6Kd{kad627Y9Hfd9*jkxBYC0Om z6>|Nu=&t4o5Hax{-sS7 z=Zr$Zx~6jG!i{QSS0l6FS5*N6d=L1aCus3o*^+W1f(iPvdL<7NNfDvUR4rR0EzXi=Q}oz)n~iJqqRLD)6=Fq`Jhr158~+L0v_z?k%qv^RZ0gJ5DAy(Etz7` zPxY{VwVcZV+wV)`&G3G(SMXV8G=Yu8ggc@;sER-w5osAsL<|g4GAj5QmNpk`&kGjh zR~b6g_72eyyJ~7nYdIZ24!rwHE{26n)A5}d3OyE} zaBz#Q>(s%H-!aKK{rKa%xf$fejyOiI8g2}Eu1f>^NY{;Q~*_gDPSebIryF39i~ z_qZ57UoY!eay3c{B1?mhl&6noFD&UEId z7-(P@!KDquaU^L|H^;X+Y@X)f0Wxp)pGn+@`RQ#lq6IlDx|fuc!zmc?%3_zz%0seCT zH_2YA!q=15t2_sI7_N9ay`O+4_?| zlj`3iefKK3cke#R5B=nfIG90~d>7|l!?P2C`{CWuS2pLVPg|@M383RhgPMCaN_3VM z2}QA83n|D&FXP$9hd=Hb8O{%&)%<=7^jQ8r2GST1JrP>e8$~$|$67($LA4y4{_Pg< zDMGRR>82TEY1}A^Rfw|Dk0BWL*goeiAAUYQm7?l@`&cqN>Nd&FIjR*_Y)swt5L8mz zt`|^GW-Kli@j_)k7_9Yc`z(qn?gc)cMSCQ!h92P#0__|Q=S`R2^#LLIC>$6Y5R{KR5Xg$ESY0RAxkHoY z6-ps^VSEF;1oSX9L{Y?N?7)MV+Y1Ihe`M;AvMpgw^rHtK9*SI3SKpPIi`?b0#ZgOa zMZ^3Hb&|K2TKhDxgO)#pRg100SfR*EXWM&ak$VCEg`_7YD6IyD7p6${99mNC@8Jwz zh3d3l(@uWW`prfE?i7*$3t1Rp#YLr!|49JVsg zXybotJF`S+93$HFmi8nmQ=m0myc_y{C|Y|phVgTHlBn!!SXBA_?i-3HIBoZ>bf{xr zHl%p*Eyf6BY@k7!h?M-z4o>&CzMWLrWp9u76s?Ra<1<^Q{Y{k{HX5K2^t6sUJDfC5 z7Tk{P%C!MC){p#gtLDnyec<6pwcUjH{T5FTo;zNS*!u3#=&kPANW5q{&o{N*E8!^{ zCC;yK`VJ1Xdd5mEUgF3LclbIJs$Eco$sc5a{tI8~?c~i>oI@XCF~3fqQJ@cBokh}n z+g);pfHEos3oH8({LiBW4B_R3R~0Lq0FoVY%5}G!NgRH_-W7i*FCAV;LFHXeJ^TUz zKkQqM)P6G9dNDGC+>aaCNLb61lZ9aG6DPQWulqJWJ$trkiwif;7%+_nS z-2RixWQSM=){ejqh&i$ZNH%!+h}}Odx$}-=b?$udty&|94^F5<3J@mtL z3Px>FlJ%#j5pG8aw~i!(r3NmjxB_c~kg3c3#nmhG05mwqp201ok(98xIQyU)+z3Lh ztI(1Sp{7a_qsVEjcR;uJnOOf?8X4swH%^*YN-D9ZSOWO2n=Ygh*<|FV|Fij_AQVxV z*!0)DEjcYrn&)imLl_Q_^yB#i1-r3E)XMG6k5rCLOY^+x$X)p%7swT9gm+b=dlhE6 z0{>VqY&2fQ*U^$e(OQ_|VEgfwoCQC{cB8P_3v1`W!1@Xb$-;P#E<`fy5;Z7v*N3y~%q!We7caD{fhyXHQm^egVPRcyKKTYyQ|3NU zzks0UZ+COGa!drG>~bFLWtXvN%97tU%uVKw7T9nbOBp2zA>d(4YIRdzW_yBO=Q!P2 zYOswvZk_w62i3OD`l;bg)>IVUQpZCgi}<3q+l6i{%=4WjA)%0|H)_k20aZuD12!Q{ znV=eE^j6l~`I)q{< zh&F42A!(ic&JQNIw7~k~4<;GWdg-ZujCDVLz3X$za(PyOd>FtOcg$#p|4BEUgqEup zy*?=rX#8S9{;a9|n||m?tTmf|tyIWKt)#NUT}xOXuvjEKS>=8}x@0E#E62=$qveTc z1XA7{Lh6EEY3bBQpCU@jRL`GjYo?z-2LqR}dEL{I-z9*k&;Ca?x2AXN$IRvptC_qV4Wen5%tFmrsH_XApHyzfMI zQaFmoN@;aXVbeTDWWcj20L^rrQtnY5P~zJmw6Z@Kt;$t!C}tDYZbB$L1*xipGk&*! z>u1!hq>ajm8s7`RnLkrySBo*1o+#0{u3DO3z5zlj=Y!Ey)c(Dt1H;0vSr0O60^}aL zy4m5GMFMc3LBqsc_Vag?2!`+9;W4F#EiUJpM)mI5xnejnrL#*dG|)CT_RSxcil?)+ z6MszNFDtX^4ucc;Y-BN&&VRTqJ=aO3PSpV%{V6cYlLvF~Z;hPe=GN%33}YZGR6ybt z=1W;q`Y*w9VMSNlcDTZv?Iszyd!v_GWGF;v{=C176DQ&BS>kPRwC8Wx6Zt{E>aaue zRZGQrx#&f&X!_nJxa84_M4y{}I_@7k!HpxT(1h=iyGWP?g;+21dOqn8L<7sa|Hvb9 z_7a<(FuUsu2zZhLR<9%$LJ;zINy6`Z^wA8CZUh06 zg$O-C&o8k*Qu>`JheN63LA52TT+4xEmWp?6%up9p~=NDeM5! z%hxaURN=ymU)SPlGVXF6mUa_q2oJc0@MMHWZFClqg0DB7=1)zV$$nVQ(vRB(Z5qVeW@|kxZgO{YS&J)V3wYt>j8)m5b?^R6a4 zQ;#6V^(xYW^hSm|(CU@nHWVAaB?08NP?bJ64I98!%45#$mqKH-NIjfLS)9m~;B~&j z#n4$&L_F+5D04&yh^1HJczV#iDM=4OGJiPpiKXZG3tf>51^4{Uka}#E&WbI@e|esz zkwvts7WcQs8^mz!!O!u#jPj39wg@Jl%gRP~C?QC}=_aQ{wfwyKO(6<0qg|FXgwmG^ zxILMXeRLanHJtdn+c&b?A}Q*^T0z!gNz7;^T!@D#qkk6aaQ;ly9KVs9X0X@zwjn3{ z)X&q9;4fmS$uXQ+qckE+7VSccBf~=#6*4y>Jn~mN@wVZ%rLE`3dH0xe@N9(v84THb}dvdXehtS3{aS#$S^1KAtoPJi*m*wPF?j5 z5*XlHBhvtO9S)LQ{5^cl203HBXzDhnd}T~S(or^%N2g_wRyR=o^U*_z?AL$X1^zV$ zCJG?>R}mRNIcz^`R$I>jReQcak^PB?HQkzc&1)%q%BEJMcqrpeK1o*l3*Q2OodN&w0ch)9j zj>X@&Zz*8tBbL0)tFQH#4AFEpr9Rx7#yAYd8KyWKAzhEYz??LzKYVN z>-&2H=hL3dXc^P^&p5BJLdZxOx(=wz^>NIc_4#$2(_eO~$b% zblRP~P0A$l1z|ct0Xbh*BgBFauFZq|h(A8?6k$p#nCp`x2-!*zgr3qH+u)alCtcH&Fts=PO}lf{-g2~WAS!^@C`h|<0Wbwl=-H87^R&-b>oWWrgKq&`ttmeyg zD@psz+M(9L_#0Xo%Tjq2xYNXr#GgKDf>(u3v_28B!`9uo%GgTlmOK4hP)qtAU z^3V;8qNh$^Hj0bO+qc*EDyYm@JD{TL(lv)?#QEo3SR}$UxP4N+eE;hm@4*! zVgLcr_|Ff31?M!%O~H_*+)M2?(l8geh8}X#%UBdEg$^W?QHdq1GLkW{aZeH`Eps#j zfqwmZAeoJDUo|fb+o!?Ld)Fhgz}p!(Hgmi!Wcn!j>g{JQczYy z?^Qtvdl~8pY;wHRYGV$wA--Sajr~ViCRuK5l`XHqZxT zPLbd*6e4JH5;0A#(IzA#E9iSnmWvMZN^Hohp5|Kf`lna@7VhA7te8pIl!tdSP&#$evjUpi-Al=dp($bBR z(svL3{`Y?0x~_%Gk(qt=+53&>dER%Q;lU`Xk064~T7=(+f!;#?_zH#?jI<~R){tn> z-xBogf=7g?J8U80N4vQAuQCtpx*5Wj&of@-NpYQc*f|0T-H{W`z3gKlc+$LSYsdl0 zvay3L!{`sP&9%hh@BYoPe6%XttHh#8O)bNX_pDA)md?ImE;@FYwxm9rvZLeq#%NzQ z6;|gEfN2!=Cs|+S7&O=(k|Iy~-{r)s9e$PC9a-hglQ%33qPZ()g{3&uO%7twn6eE? zSTt@_>vj}v=@624H9o)-08EHnt<-LvDl8t#F1m$q3s5#oM&p{c@r`g%eP}Gjp`mX3 z)fR6fp7>&l+g_FPe$Q_A;J0RL{$|UOKsaoe6)jpYqWlk@$RjZ!FCQd3TosZe>HJCI z^5Oqht8?yChtaR7`5e768{81tbv0|fB=;MLZG^k!a7&Db`y@N!o7^51+{d;Uq z!OlxF$}pJo!mCSCWwC)V@V*Bd=@MlfM9`&E+U!mM<0Ceti^w|vfOOB5lOU^25!_}} zw~C6OS9oDj1mcY;Q57jY(z!BS!YcRcIPnBFiwMet;ZGkbjXjWO(DKX?Q|Q*R)4bv(P@Bh@_yG~&?~?iB@XF;F#u}L3-gBuY_%9I1M+fu9^+~_x0RgL) zPsu|c5t-@1>8Ja|k!8;$#`)^PERit^9s zq<{|-VFbqr+v{wNqz0XRAA+Yr(Ej9XW%5_*J{==}A1b6{Hx!&8MgTsg!8HM#^|k4l zh%_S2DF$!W$?>2`D~1@=aXiPLjvnF3KQtG?>w$x=%0lh8`A|le(O8R_Kq&b4G2v_u_K0jVp!imRq`uNAo0e0ZTqvsxlT73p z=FUYlt@G#L2W0T39Mzo8IBOWBxz2BwN-Tt5?PEVaNMoPf_@N{Jc|4%0&L6g;lVPwW zgyXRSv_+Q@HbbefY~Iy#wJ9mrC_HI&8||8C+EyhI6zzc(Qe7!TuQ^%T9mcdZWi%hR zCszrJhW_73X!a-xzUi?IZP7Oy*x#|QpZA`(0X^{-iC#p+x=^aTI*Kv=xUl1pUmrJ?-r5I!WcAOj*I^@aw2WCXs0GhEzdLX>6%!pM5i5Hxe;#xR zfDh*xs9v06?#_yX;ece|};s%@7g|Y2>s|Rl{R*r2O4kJjPet z$Bb0`uAGYIS#@&gbUmiE9M4CCq?q5F%5sYg`~gO@%USr14gn>UOQ6h@N0O6sI%pNI zte2wQ4RYq_g*~D%xzB@ z5g5~3S2Q=tT7JBa@-OKU%g;S@sHJ^d_|`lw!0av}O0ZLExkWzC%it8?>{FQANd=St zYl#I~;!z$aq?)((2WQ%NJNyM!+(L$DSK4Edb1ZEf!%wb${$w5-dzJG^ z1k>}(d}Z}xY`jg#&te5uqM$CS&Ta>2F3liSln1sh%euSoanbKLCMY!E-ETSeD`gvW zpVH`hB&FHOhtDS1tv_v*CIjo*ra==7Qh@loPDD4X)a^>ZZJ_kL(QSt_SIvTJq~=B{ zWZooZ;Rb_HIA{%ubIoJ@Tv>2Lrci>>`~05q&rkG6ZrpS+RAw6?A&cag&v@9md-3CDaizt^?7HWn1aQJ}xEf0|Q2>o&iAxVD~E z%}GJEOKs2;WJX-4LvMCpgez=TyTqA2DXowaWikw%6-q|s=i3-o#~N0|LS^WmXWq(L z<%ia}I3)j|v@k&%R;QWYH1%z6#s_;^_ys33b8W3XHJ|CE1vk~(uR{qezs@|=cgZ;sqGV2ooiA4E!;DCA3WY*j8j z4bs!&YE4zC3)$l8DRaT4z0v{sT@)qmcCDBD&Eu0V)I9YOy^~eMNrpOfUWW1!nF#UP2R|fiD;G9W1p)|mWOy*7wI#@f^E9LzwS$%zL&2S95r7T*cSc+a>e86qAR6i9| zzBQvwoG}3f6y=DXC3j5_>7y4ulI7fth$-~m-|c=-*?#r%rRJ87b>6{;!SQVCkfRm} zqp$lOtFF&7j_8uU+;3&tUMh`bMe$C=pY&t)V_vJcyk_UjKaRrQWB z;s0r92m%yO{0e4{2SBnl5gs07wq(v1xOHdL+IJpZ9`(xVb+6gPatOz9w*;a3iZg8X z#D}GBS_}&4L^;#0&t_d6jKV>4aM(u|nVEI?-pWmW%=)>^xFrsWZ(uN}CFWA{nbxbU z`umum-9Mw&)4Fp`RyvDRm6AfiIi64^$nVaj2B0T}Mg5&$@5!;2Ru)vI!9j;n+P8Ba zp%{+^_eyVd{fGRlj~O5gx(~7{lg|_FH^wSy!Uf8Jh0@=%hIeFzLq_@h*)T+q>0Tc; zZzz6@$G5eAlHc&)(-o zUku{XM@OL!&#iXsr3Go%Wus3nh%#&nxQZB!cXW811(&?t-`tIqKq{+m#fjIU5!B>1 zj!!AM`45@$4P#S*{KGRepy8nMh!GQVs*IA<9ogg_0f1j#S|0jZOh2H=3y~`0nWYSP zgF_Gvlim_%?jpOb!6bXwlUjKRj_zfHpWo z8*-bDoG>Rrz3^QeVB1-`Cs+8m%y3}UG4#*WzFJO!&AAa<7Yyw*+Q^6fZPVYLom>X| z_5Q(+0?<^(;uI;CNWzP$q45uXzZ3DK!#r4H5Wp1pWw1z>G?|hY9}f~Y{&R*oK9uCb z*uk6@DAORc{>|U*n@DUlGtRyON?~U9Cv<;0Hyj{b36HytL_yy`kIv1-=lHOWnnNVN zbr7(QKla4iJs=HR!o8V^2y7ff&I27Q^%V`cA4^J7PkjI7A|4)Ui9%ssf;(LuhWmA8 zLx2$kYBy99xD8{!6S9;5KIHz+O zJc~f-y(`Dji>X%K;<`r4uJa7bG_L3?XkU^Ln@(~1y=B$%O%&a;87vUW&E1+Si!7Q z@_i1PysTYfLFlpNvfn87BNBAe|atD|V@6%{I@*K@-7?I$8bKJiTCrfQR>TyGXF! zy(>>x%Qj9tiY|#N*UEE8)L5HpbYYQzp2a9 ze0Iz_WF>O%`E?v8s~^4K#=ByC`8zmKUyt4Dcl2^Vd>6}|*KONR#9s>&e-sx@VjV60 zDeAizgBN!P+1IX)jLGSC;eiH5+@r{gsefa2$Eas)LejJI4QpzwXfHF}*F~rlHGY&7 zD;zoC$3)K)IdJa(#T*~9{PNMDb#>EN@b$y(&3i;lhW_?uLU`_X(pxgy^0s?D-t#{j z2*;a6;{D*PDBeJ#NV)jv0Rd~z(eHVTk8BI5_+=W62im??}UwR71sClyJ}&NSkS zrKsxoAlG+2oA8Ksi~Va0zvxvz2c1*Okfkpc%5rorG6a7%rR(G~v$V+fO5}54``w1? z%P6Vpwzr^paL`^wU!(k)I-Wy|+?m6QvIL5U3nRKk96uY7*|km#^G%jegM)bq0Oirt zbO;Q$A;DKx>H4t5G#yjaCA^9s@SzS_d-XAIT`-<-qzxamk;+V_rkYL`)F4jwd0&1`<1!LB*Pthj^n8{96jo>qViO;qlc z4!k4abIIq&=Z*RC1G)P@df^h|!av81rQ?7WcsDjGCFwCpAQh`jH-lzLi=_mW( z2(!S;OZ)&QG)QNU{Zr8n$Q<~vmClM@iJvhO&0WlfvXMR)+Eia2xJ`oz|5cWAxaR=imt z4+8X)Ax9?wmRZmi zB=`AUe!anStBiU))**D?N_(L^PzNkFc=;|EoK#wA1{g;a9hEuWC?Vmey5Iw}$U|pM z@*)x}`SRmh)wgXtpnW`@yLYeFeDFtyds7o$S9H|!${)vwh&%XLvaw0_@~@r|CxR1a zI-XnMN^X0k%4`8IH1H*$shMRNzMj@+K|os~scO9yL1(oKovrUPps;ns{{dnmAni&$ zLRkl~_hl(MLy*?LFck3XbClGSzTA~hNSNVCXid;APXmp7u`STM$C@8QQs2LBXWH&{ z_~CWEP`le8hAdIOO^UPm3phT6cx@rF7i1+qZ#d72P z#9GuvUC3uajj$A6TY}e_Zk%Gb0*(CKsPHFjm(=hvMjd2zmB?4j6x*t=%l)Nu`HCg; zU-FZYVHq(>J>NU;r2O#YAvp9{c05S+6~!EPkE%1!nZVxGziK_+D3h{NKFDL|S7L(v z{?CdC3M-Be>!RTG>Mp$tgtPW~neM*uWtsEu8c3it! z*+O5?AlTyAaCuFsVBa&lcYX?ROq%*puCr4gA2Uj>tJ=f*$v1(l>Aqi85;rRu+F+Lp zG6jo{p@8@7{1kSqAo{Lnd^99;PbH_9?LR`j1xgb@pLsJZYxlU?IRPcv*w~*a{SKKT zMr^MGjf$K^oj;%569d6VXgWCYmnb63UtV}SZluO~nmqC`BJ%gDzgIprMzb)#)Hxbm z{h0jD&eqYQzn?FGsa0hVK(p<>SBrrfDr&D@+}(AO&d>g+0b~exxK^PUA;zu(J;7aw z*DgW67%v$6L0RWqaKfHm1lIZRyr?UOBw!Gp7Rai16L>&V

DpXNffoupA8>d)wX4 z3jb8UgoSX>A96nY8~|yJCJC_S+YM5eCoP)4z3AEe2h1NDxuveK0>AV1&c#e1$R@*@ z&1Cg$pq-X!;RllUe|KJT6VD2D3+bZ8u(B;iT8be5t_6BC;8z}GZw8rM(ZZGk1PwP^ zDx$x&>^~9HPE=GYAi=#_$Jp_)pv^nY5E6(olyIQj{ql#saA}Q+=^Mfy-sN}GW&(CWJ% zlrSOo~cYE?5_zm zuVQ6Yv;~cVei1vTmeo{BEAmtF80nXy-+4$nm?qdtX|64?8hGz~-84@C(&`mcHa7bm zmrX>$Y+E9h0u$)*ILI@H@-usdd)q-26^!k?FFfUb6rE zwlnd)%-q?KEI(I9WsE!7LY3}+=;-uQ@-Eo5q|4SvvO0&)WGFeni{+S}8rO-L*6zH;bHV{i2LFi$9~oqpO^~P4 zIll~A+8Mu+QD8L-KmI`&7cMU(%V05~WR^AegBw&Nnj}=qqBT;Z?&HTMkv}S7tSDDs z*H(NZ#fymv%JS@5>%1f?umQZeuC7gGrmvfpPtA{Lo;m)1VPuOE5zD2QGPU{WG>2qT zHh&O9xtQOC2zC{k9!8WKWE8)mn;tJxhhp%mGHu9X@4D($NvXUJ10{zr-JwEndO@#VYVhoQO%03Mbk8^|b6Vy96yLLA>G1&TWAc+VIsX)x%>-mGpT z9c!S|5J7#kGP`y)3amWHu@gnntV8U?{Q5QHTEz5JA_E8*Fw%C=&uUDVmVM-fq2hLy%2AMnuSqq7M|$ z_7Z1sV{Mf*p#Bl7Y7tKevzOg8F%#X=Gb6s64Sa(_I;?KkRf=NFB~_=9v(QR{pj8Ce zF=+~_a&Ei(IahBQPAl^#=gq7EegumfL*=jPKxl@aq<%(We4vjnB_VnA%zz4B{JqI~Jw}I5NOJ)N+DcyxfF+}*h zrEFs+_ahOaF0_(AzAZu5m${7u%%BQXW6w9HYMa2SiG~_GX9|onEg zPF%U|6U9U`PP$cA)j-TLp110?TKA(gVVWp%Ou2Dya-Y8M(L9I=)gp+Xc^Wutz)Iop zRG`J^9!53cZxquA9__eSKge}X6d5ArQ!c)gdXm~kyPO}T&ROi=6#MV)d+%({H<*|D zU)|{5{PZ2zmoVf^H`rS@iI`Xyzt+>yF?e~z%{PgjM7PEp5nCN5oEwSK;b@u%Pe@+0bxOY!zwsVS>Rk$zgmb=AX9#7@{?T#eKoeM7c+*f8eNK$s zY}0K}lf+q)DB-*DK@KV@FvylejLg|H$FySdqQU)>E#;+Mknt$-82L&T_Bq z&hZ;dnf6&hiU2Yskv3AB;}r|Fg!LvTH}KK$o71d<-mRV`pxQ;OfK_W_RW2+tu$+$$ z@?=gS$@yL`Uz=zn4$~f8dm~(&bg+3!!)Ei@JfUCgxVz|}okW$>i!?}`$T+T_f0%dO zWE;gY;G0Au43X+=^t?yPOZW}uijj4X~L=HYbFTAj`5w# zU=|w5nFe?&efTPUt`7$>i^u~Q4q2L9D^oqrFSA3X^2Dztnsf)!^|p>viU{l4Sck&; z)JpP77;+Ol8=6TAF_+afIqjI%_#oslB6b^_?N!32gM0pW``$9(X55eVS(BaG|IC*4 z5a%oPa66@~vDsea8}qp{7t}cK$<-*FWkN=n7t>cgUaK$!y#B=CkFiP)|ds@W#^piLW6utht zJoui=?>txz7g>?>FmQl6*wQ4brMrW>qR*RT>$$Ty#T;RP4)#6SP{Z#{#a~L2-se>X z`XN`DR&%v#-&j94%%`dkV(yxXv^PChXsAe(w$Zcv8Q!J-_eflaJJ?&vwf0ubDWXE%97qtw($!ZfYkH>3olM~?H~CO?fKP2W63+ertEX(;>#g>R{#%76v=o+{pi_8K@uBCV5_C1f?6u0A1sS-b8-{t249gfyvBEy zwP6EZM&WXUTVLmMGdQbRy4mU|rMu_4gr{lk-Xi)9 z<=^`PlCm#P-wm*i9bN1GTd>5G3D#xWL6|SaGvu;W&I77bn)~)wMc>8xTQlu^#^(>d z`Jc>xb$(&HRR%*^%N971=CpgFa;P(^O{&^!QLetnfE){-5&X+$fQP-m_)BaR?WRTS z)rH%5z^^$s@LY{3_Xa!Q{8Iem@RZJ0hrWly?RtWHB*UVvYJA{}O&*bmL2DJh>(vL< z{zsi(*PBi94@PR=AGFgns;6Had~jN8U;XOq%_dlHZFqJ9_Tp;KFb^0mJM zQ!Rtb@GOTgFX8D4&9wfW-*1Q7i~i7Fj?y`<*#3=?v-bz@x$3p3FLuJ6j=~?l+P(Mv z8%dM_YqbA$-oXXUrEcvy*Sy~R!K8tvoe<>(j7sA7H-neyUPoHOha*on_EqHS+wOo( zwaO+?N#E|gA_v(_N1qh@U7PrK;UqBQZ~J*=iwo6?e6+HAlf0Vgro%4hWQ|jr9m%u< z`)Ffr+s)0;?&SNXv(m(3j{s&v*Y7FzUIPrOUH-2K)g zR|od%%6D{{<87*R4Y^i>OEb9CB;4wJcApfK@?AY|a(z+i>hHccZ7_e?_AN=UY58R6 zblv;lm*#3v)6uF*lf_ik)UgPalUA2r<*&($noj2Y_1>TM%KoQr*V`_QT$)afQNmkJ zk#;wOB?~jXl*bN{4{oYkf}fUl;7lg-c2nAvZ@Z$VtXnO7|8dh0y{+8Txx>$LD(c>+ zLE+>5%!Wtib=nEC@7xS_>8K3H)^_UTlYun1-LpZ@7bT-@{P|ashG$>fel|o4>*^UA z1>LM8O)ORJ(^OX5WtRx2TuqOb^4y53QT0~ZQ#+&=7+N%XP6R6LcwcvZ3l_MUcs|+7 zvf!TUV+Hp=@%*#nbL4k#YR3jd zhB-8RWEe#y{1W`UNcTOkt@W_;>@CGAW-om&@pm_8^^b&K`Q;RFe7nZihNa7CYbAnI z_e*OpjD*kMSI{-#S|+JY**@VYb$Lk5FSZ$*axq0zn`~)PLL)P$fZd#nGynBl{hqho z-3#9&uHuA+E4An(3z|oBy`4s{UpI)}04u)KT<^*7-O*;tOF$OfYGDu)B3p{okFAgQW_R9(WgLB1xlPW2(nVYj67R?Ow!x7+$#J zCb`#NlsF9!tqkR2<~yb-e2;3RQ02or z)?nXIWs7+}wg9(C>{XaQwDjEE2$u36tW8Sn`5DjkzM%2Ih>}DQi*%#jy+-17= zYdy?seO5@QIk?0g+8)}cRE-}y9n`{CSDZ-%FHeu3_C%%4^vr|A-Uxo?kstMzY`Y~O zavDev31R>XP<{WFr2oD2vgjyEe}-kAVSnpwZDX|ofYN@4?p*r19KA^|dV5B6bsFoR zBk*RBP8kx&5h9IW!5n22UM6kSrnVaijC+o+D zUx+_H*j~LX%5%CN%#hq(#+E7d@qO2W>;2;9rs;3d@@nhP)dN$BtKA?!p>8UBp+cCD z37LQ-S*PMl0PFa2VE1WB+)bK@Y^)V%4`t1$FW66ZW`uVL^;dh)+`x)$*k%#r3JJqe*o-?5AeMQKfROrBtG zQi#n;!qc8S=Lf3{O}`a|&x+R;imkci6*7c0#edIpy~ngjM610XY3BLC82r()YQ6TU z)Bo5@z+FZ$`0)f(yxPXoX>@jAEqry!-CvuB`Cv86(8ugI?&U@hwz^)RK7_#*KfJXEQtfLdgFp;=@o!ko2=#BMtNJRr_v zV@!pUIi+Cl!Z;e|cmz^5nnvCD8*kBjhM0=$f^8{=$5y)&na%&ek>>TkVsrf|E#dLs6DQ_C?#9k-p=zm z$?59ZufI{$(V1T&EHq7C7cU*%oW8fZL|ww;!C8{K2GUG3MoygP{E;+rWcfFF1+JT$ zKpVSfj*S6Va=ZK%PbO(L$kR$20&doaCg*>PFcb-YG$;r)9D2b?y(R8eTswNo>KtuW z$2Pg=L-XL#D2{ttwe`S}r(N-yOQYY2=lqE4+M`jsi)J++6W<)ktrXdOG2Xc=5iz}- zwyN)jIT@{Gza}4X9(`_V^j?TMNSZ&ZbJ{aQ(Q=EnBQlH6}_8hpP9yL4j--Yj6P$_%kcU6@^zZml}L%>>Vo+~ zJ-^@}mCfGKw({9|=51b11$lX)S9ixN^5^6p{hH^NcD}E3SIVeDJ-bPIfqudED3fcy zMyQRgSK1GM?RMeP(rw#%;+d57{?bu81(olel?%cfx;q|`u-9HE4VgqZ_x9`Ml6baZ>r zKTV1v08<-I9x&IKUIi1n6t#iezN?1XC~J5lJ3QD=diDgl>`+uK3pQm8z?}|q*$h7) zBzWzI-E3$2a|5ol?rAZJK_7nnbfCedHr0Fc;9FyLaAX%Af``}h6uU}Qrp(uWaO{qO z!%~tKN27K%!UPg4lGR}OhRZ_m>i=Fc!lALuwo+b?R~|12!=Jut?*G_lV}_P&xB0{sy#Aiy(`$fbSr#?uVtdeub5x;tDne34_SbHy{ezhE&Q*U>VeB>)Nu!7XSy)Y~V7#`p$hqF=Rvy%7v> zbO@tQ5k*sn9XAnH!7bA{0hw@m65?#Eate3cctGNfDA^)=JYk*J(lOE+LhZ$u4=dUbbnU%aEpF;MHn*Ay4_`tHl5;p9$jG2GosN;Y5_3m zXO*?d`PC+}7r%g6O6e_XM8g3aq4`9j) z28zKNpX*?(S-e716ydf844KB4@TGb~Yf?PikeAK4MI9n(%4}< zL=8**r#$}i%Sxii&se66{MgMe)FXhZ0;19X#BSjTByLP^qVuP6$lM*M=$VdAJTm2u zR0x(W9;C$oMWd6>xetgx?_Vxb=eE`{zslfUaE9h}H;-jB8Gm`@U_d8>uwOWQMOiTSY zqU)4WY|aAEWJ5Hh)qlYm32fcgd6+m7C!ix9j_`ezK8=s&=}<22amFkY@V48HSIJ32 z#3^epM{EGISurQ4b8H|d6*3$z{>WjvSCqFRj>5fhluqd(e0Hhs@k#{?O1}N@MC0h| zNFHm5Q+Meyfo|xBk}aSoEsWd19!qHUOic+gNR0ce`#Q)2-hblJwtN}#!t!5ulZ{`8 zy5&KAfko__JTf;KkV3+RL+Gn%*}V7iyAgE+V5{Ug)hE&qy`0GdUc}0R*Z;@<<_Tq* zkupHWPsca~s(yp0Puuf59Dq@TEa>DI3y0M_em5Fk-3U|$ zP+}?hNVFb?$~XM)jxnf={(zDM*9acRp;us5@r$Ehq!Sy6YyyJ$S^vn*v=ee!zS$24 zYwK)tMRi1|;=@N6dop7Dh)f3UH!)8%xGdlKf~1yuQ%Y^RuLhadz$%$K*67Rb$G_+j{!0LnsBk$8k>0Wu1$jF2UE_&DVQ!D$wQ)k{%p??=WRadC&5Sb z?7S&Gu*l7#1S_>f$8Yix8C|9ip|Wb+l&T>A9#m3?^f;XLyZ=F#A7H0}Psa{=eOUDuMt! z?Z>%F@qt4eWKKM;BhcHYlji05<=m8qv$&`DMt@dJLb7Y+`OYE|eJfzvnwgV)#34DE z$iI0pw%$z3@m-jJs9D#28#Sl#YNrST%08T*O1NY?oC0E*k{qOzG7M~s?D3XQqUTNo zz=hqcv$9Y0hhQ6qZ|X9Ej7^4VWFmgdI~}L;2|0pE|Y2jUag|-;a9A?7*pB zg$Z-zg0xeY$zfub3f?2lr&NguwC#GPsWaZjh%n5PFjk(I4-?y}f+dU8#>KIS!MZy~ zJ=GX$c`=73L$U-zH$@l;rJBu#*#rl9Fxa zoAXjwvg{h8?&ws*{s@2{g&+AJSEiiM_o)$9vFtH$ho)HDtkvOp((prqzaouc_hV>D zEaqKeG*v#@bxJKaQn(s}DOrHOS4`Fbnl4OvhrbMBE+5ZiWSt~mXo6!Q0$CCl%D*9b zN;j>&E$52e+(#JexdR=k>082??+>rwC7U5u1(X=|>bJ-P)FbbpiGh`5o0aXwbLNlK z&?iN^bx%s@Wko~6EQZ+O)DI;@tj5U!u9R#M@>Em&Nx$!%wakH28I@)0bm&eehm-~Y zgyy3v|9Qr(jN@t+EslfTf0#%fAWoHJ2{J8jd#d0R3lJ#~h!LtunU<^6Io$xkns0n0 zB#>*$=(k20d zQw!>tKav$h+!5yS&s5>b48XLpg(9TK$4U?=yFu|M4z^^WqDpvrbr=T-;Lc_ea#Gr_W2xT#Fx>T)OY$Vr}$^wyzg{Om{m6aRC z5vq}i|G`l2kRE%Ox0Iw?9ytaSJtN}cA|HT{>;0EQD%Rg(|E)Al=3esxd!6|oj51X{ zVh;iHE4<;Bx+NIE#>@*zp7KKq?b)WRCUAhdnGxd62j}u*q%sfw)ulfE*U!lV#K>gI z)RR);R&Qwn0?8xH85aTFgyDZ4lLsKjZ-lS;CIJ}xOEqoqeqA(lSXjjA^1d!ACN9~O z+ldDw&knjfdrLgXG23_s=gh#IRm}80;dH}_Nyt9-VWIr)-^A0Bz4Jea=(~UD3M$>Nx~9qIn4lEiH94TpKxL^*`%pm zu{3}AK`slY&m!z6XG%#%HX^<);)bm$4W3y+BTBn46~VjiYO@MPIEZ_6ZOp&u7BVy7 z(&7X(hchhydeH6&TN2|Ph(IOzJdZb))t~ioctLNu2dRE^K$ry}Jn0m3%sS6iUFI1#*r;OltfxTmp}CmP&85Fyo+6(08G zQ#xekM~1_SNJT(&3$KkD3kFb9onMUvzrQxdlI&zdX3YebrrO9| zQ4=&mPDIh__Y1e=r-~p`RjJL(FISF7NU)qyN!1`yN@2KU;@@?bnDCwEX?ePzV?=A9 z$dsZtOMIxbD+2g1r8=d2IP)-JKMdu+BKBvi=%98FSA<5L{h#5oy?Qk^oWc)3+$QBLk2-XbGCk7np;~R^70}dCWIS`e{B}tyu;#a##BFhw&>6aC`I|i-Fx-^8?*A}!kzNo6RY*2jKca%3 zH(&w4)Hv|E7(08->$DuJfAQUL_QP85s z3uWjaC?S@dtrS}LDJPIu_Y~GHgxrz25B>e<7+nH_HNS_gZPI7I>UKt0@4xzI3g7V^ zBXa^|z;u)<|GNV{<^Mz#e-&>cuG|yTF>Fb21qO-8AkV#EkmUiTdfJtm(kc106!Yn) zBFFLf$E`8)bI@B$f?K}=y%+$gCiaDDIK{0)_tkZ-aP6UK2)}>1m0Jm@zF>%zha1ga z#(^06#8|`$xP5(mfQfSaR~Y&*aCwk; zG1AIS_6r$-iPk@Ua$FlfD=!YXw(P5S;f&vjoFvk{~oIknI&Jzod4E!i02H z_k929QfnPm={1r6geBsTWw|jw4`Vz_`6Zv~pYw9aF-aOV;cE`#kup6(pxpw5m_oKS zr!sDI<`tti0PB9;PvV0ZeyBA#kRmvwuy^+QY9Gu0grDc5)Ku27OrVS4Iqj3OAskS- z_^&qs(8vQyEmZ_$m_+)Xcua)$PzL22CdFjW*oiE&CWrV&n z>NCNYC^rJ7$^b%!&TOF)>oXH|y>svxNMz&J2lo(VZWd@{*Ya)iD zQ#QAmQ(X{Hg@Hhb}R%R=aaW_q2%p%e&~P)4u6X|i;%;)ugFUBA;Ee@pX=MYa0I@h_dxo6U!3fnC4t!g#;xBc0Bi=K zOo9ZJuSOmKF~oT;e}YL@_HCnt+}7cEh{`*r*y~}I!+@kCgaIIeWs;}Z+`=S8JIt>R zez3y;!ZI_jLWS1E;$~qq>5Z-%+I7jF$oeeqT|n;alnFq zH^&k_MUUV-rg|g*Oi`HPN!<^}&EX~jn&dxq3V@DV5>N8Y$6ySi%nM=gdRy=QL;_r8 zEq)zgn|v3nA@murDywW*ayDF9lgOnowT>HStrj6{A_n;vsgjo|QY@kT#TZ`1uR6kR zf0fP&bQ#3>OGOuVWbQc4=seb$>%m1xgt;s_uGso#_l)FEfZBzZy*iasK+4#XFDFOi hsl~}W&CYXr1G{%eU&_(ceG&qHl;l)pOQen7{6B~G)b{`Y literal 61343 zcmZsDcRZHw`~Lk9%2s&nk(9_|WtP2?Y}uR0-g`Zgk)54Al0C}aWF>oxM97}mgx~de zkI(1({r>*wMct44zV7Qf&*MCf<2?07QC=Dkn*tkxAUv5T63P&S0lq~;UdIGq+V;jW z!Iv9$Pc$7N2)_;W2i9eqX9E6_+(}ZyNyXOG$<@%|1?1}LiZHjabTl@!dx5ZZFiYNg zOaVc3kc@<=s$0s&jBD(j3HRA^{jP5Q?cOD18O{Szzn0r?(O{TS#5a)dpJ}q2hemHK z&~;ZmXpTSs8gKQ@W0v8YQ2e5?Oeq>8a}@D)OxQJ%kZU4n5}2fPM5M`k{Tsa-i<9RU zFhuR-YFhEMuJhTCg6(B0YMIb?Ymaml1do2?+RQxl-pZw6`>Oa&{|UyWW()!Aho@9E zSqrc2aNCTNY408TF`wEnJd*y<#dMD2lfn>V9DDMp=loUctbXX;gSd;cp7YeZU3qz| zm|Z;lQZKqRPjC!VmdhekLicp4Sm{$3MvP+xc zJTddMkVNY`8XYySRiVikks{u2syV(>3qzoC z`trBy-f%01C4*!Fj%9I5BDQuatGz6;P4MlFQg!Gy`AUspGMu^^tHuSU8lXdkefm%$ zz=x*l?Eo_FnF9xgNUTH{hwb$8rZsLUw9*ELS~Q)$ z|D5xDZQV{)c5?#5YR`x>q($c|J)#mr#90HjLiht0V|r!K@8*{doFS$yMw_EDhn?F{PjM=J>$IvTXybiML?Y_Tinfa@a^*1oGyW;udVg0URu0=;z}$ zo{&7#8Yx3c4y@^5ehJ217U(Um5JAZ*WRN^CffIFYAEzwbhhFasJu>%8$4BK*oo6t@ zw}y7GSdlKgA`xF0_^C-LqA}cwmz*_DX}T6TSuy*l&v4IOJeS6TP?tNCm;3C(@A}Iw zf6J+&6F_X1O7_B#=(jgWnV`ebEB3|tp;I0 z$pMdGZ<5AF%7~(#Xm$;C%cbG9EQhO$SkoHL1T7fEkoeplHU_3hS>N(HMld$my|+TqncE=|O0dCYKNitzbUcsy%@&O$I+i)G z|40bo+?X{kiV6i=3CC~hO1wW(9axSQ6+x*%Z*zvx;_dXktS=}L6|S@;IPIbEj+eAk z*C7u0Dnkn{619|~c4ydhLt@xx*+niWPtd`h@0iAr(i1`xA<@viHqBJh%0qjX>7T>j z&AL=L8GJ%l%LHXv=s8IRlYq$hPYC*=ZeG?I+Jy7m(OhRkS(^z$3&s<0lIX~ZpzIbA zLRY&DR+4VqpHo#oN=gUb%_HOv_B41p5AE;&yz|9u5D%IN_v0^)di(Gj(y&3u-$tTy z!KAxMwk*6ABTGz}0IFPeL>$&`?%d+Qxk)axQEel+xF(G7ectmt4ncdU}udgz$W(+I+gYMN9|=bixjaV0<8`EE>qc{+t(Iz z71?VHb~L)Lv11&U5bc3E4hxqfDo>Z_kTUGC6mL0 zAJNAUg)21s{(cbnTxUB}#ZeOscep-x-+|-yykqm6Nxl98!LNh0DI^ z0fvY`hhiDMXG6PvGgK2U&v6HY$!p7pS8IfqkV(TtHs6LW-1|g=MBpK#aNASJo7g6o z_DqOdYn{@_YtWGBkDqiP4s@SjE*X^-=e>T6rh^Zs29KN%m`9ttkC;J)v_A*7)xs18 zI7x=5ZxB$H$9Gl`u}&d~${|#qrI<}hT@pZUlmt~uncJ_dOTveW;gIeb8X;anKi3-} za=rlwS-qQ~p8+l+!iPZK{}t_{%u4{8=x-8R8Na%Dv#AR_tBuD9LFE+*_8YuNz$9}d znt_PWwYR9;D09WJ$|l1@J?HcaZyN~zI(XwzCP?iKotWxM4NPH(tsT3T58MKz6}sAd z9n!izMLPunkqS;eA>N%AfAu>vJQ%L#@tKFsrsQN$svTDptLHjDL)Z`snpEWBP!Ii} zA!UpbgtTS)(Z8`(?{&uu;CS}9Ffq9TIP33|eOm4-2` zIeWbl1RC8rmc-p6y&X8-mQU19XzoK=i+``&2p^y|GzSsf^>_x*)R=fus`uS)qkaao z0`UZ5Kv-^3p}G`K>;}&;`?;(f&1V^DYew1V$XDPffJ>W%L#xDoaZj=UeChNaph0}J z=VA@VkEQ(5ztHE!fy+HVv;NAW7XLRiF-oAUVN`t_9>F~qxaV^ z`8_1~I3?8E=FuOCUYN}Sg&bx=f4Qu+=mAp}TD28a*iE@JmN< z$bd2N@f$D9A_;^K4n|Aj4#SY+7%Z5dU|5IamnZ`fi%*ym;XR>tD$0$&AP|Sc?2A~n z2#kG;Oq=eu5P{BW*uO9cN>RWh+8jDI^0@)|eJf)-7|64X%cGOKcz@c#o1d`5jmHG7 z7~sa-#E>7~j)B$BHafiB&(QU%6GF#-Fp_==cZlr{`Z77T2IO@AP2Jzeyl2v#vrPm= zhv%z~ILN#I6p{5-JyHAmcoo2WXIxPsbXoU5|_ zGYbmVpyO!aQG=sE-Qx}y}b`sM*t=XBevQ-T1ph!nz8qOUTb z#LzWhT++FUlF1j&;1g*5JG|B7*KSb%-}TZ(6p=tOSy%ky**-GvO9yyYan*(h)=nnK z=rag3uOZqZ=o&I_=sv}+J9JH$$Ptw-hlZaPQHubpEs|FnXYeMx?86u5We_{*{!@?*5DHw*fN$=u_hj#Nwd?q_bmkRtdy zJA%V<1|6!PV8W>Mu;cIK^n8DXrW4;%GZbtW*7-F^0=Mv*2bTnmB{>)RNG`S?1wwD%ozL-5m z!`*%>VVEpi7z(P?GsUZ-sDnFA=14Jr69bv_sCgU2O2{$$5Lj(6{4ORqLN1AB2;!FB zrK#dzdu?43F?1igty+R9@JHIJ6i8yRAd8A1NrL~mt=I3Uq60k{gZ|D@O-qkExE zVgLnkSD^sz6NQ^%)?&V|3+`IahmGVA$!O!DnGsQK=r_|Lg^XS$TV6$vUSxLJP5wK? zi}SRZkQ>n*C4g=qa0Mj)D-h-=J6C+{KS3J%2DC6V>M9Nnq;=8$wWo8Rkj~dqZ*&5} zkSL3p(AjZSWuD!>hlBh@C=rbP5d#o914bJ_Z?3S`I8X=I?Tro>Pf-?@fNG!8?XZXnBUzlI_5RShA#cBUyHV zsXeR$U{Nh{m;$%Cbl_k!{jK%(aR72aM*Yjl4|ihV#>DL* zJ&qhKERgO68li&T9gi!9*#+>@{pzv*p<{@4op~DAVH{2?TE=5kHaPa4e0SlCn5Q5l zt0hArP4WbXnf-CN#)j*xrKfrI79nja>kZsk*lOkNoUzD`3a8O)OI7I&WZ z2@HsPdD!g+sH-5PjQWFk?#4NSUD3h5&BlykwBY@6nsB;BK)nA$C?#3T!XF3GHQa~L zs@#=UB#Q>^ufjc3AL)F7Ne$k>FbsdXp{aJseuSv$ISvxQ11fLCu#idFfbF2P9{ti) zt{FYFD&!gXh(;)qH3V)V$_q%AQQ(!{o0LLGseUm-`XMK9FhmCJ+XRgI3DN1GJpQ}c zW#PLw|G5XF{JHN7j21F)7&ONM6v%%Ghjw#LY~iS7q?-YTLHLd-)1*`vHGU~Ctn=xj zQHv~!OU+YQ?RkXt^I;S1p4QoEWMLz(5kW1yC=#*22+Fc(!a7>j&?P9D4jctBd1!P7 zl>QxdJA$79BQqr%2z=Ge1d_cgRJH@-H#zc7NCWf)Buc}|ARxj3DA`UCs(paGkO>01 zhl*jqrjj*a*E8-U+`S(h0T?w*@h{;f%a5u;^UBRg6S@HyV^k;+3>$epV1T+>*4j0n z^*W52caG7)9)!3drKD<$H4uhQGfKu}aAami;z zay=jb0OirUeCKN_2(-New63=UVxrB^^a^}FV2}K!0|$~BP#A9-ZK(r6W$59eBq6Aj zP%wW2rpOb_gdb&ZOm^r8YDc9?hD;1V(RaK*&}vN#-Fn>)O*b$lGN@f8Cy>rM&w7D! zCbvrwKS!Gjx%2(~ZWgq%NB4QTs?C0PKtbfC&esk=?#C9qz~%v%1RGoHxZZF%3+$ zb(tbS_LwH7#|s145YQ+U@FM=pT_Dajq;$s*jvS-~x!PR*O7@olY zz6I*&iLj9`2FX$R7!f{!qTS#z2ux_sYhM?2uBKsI_W(2#YSQB(7(Lo?z`O1Vq}w;r z-PY5G??ex9EDuk1EN>Gb(Ji6|QkWr9_c*=>*(i&mor*;ez}qc>JN^WE!YwIOvt@sbB>6W1|BtlUxtytbruq+#r0*K9azDs(1;60fT770DO5v4xue znGNcLQ$&30#Rk}tX&~-0DU}ro?>Bq$r2|E+;w%v}Pb4{yy&54UdiXtYCVF$|3b^%$FBP_JyHVnPkI z-(!fN3Fvt}$-0uTJf{ID`EsTsVYlje4O!sU5RScvBhe8w`Lq{XME`_P0%2%}2UrzK zzW@&atojoD>XxYZBXmSmzD1)eSW*_r_8?Xg|9o^k1AD*ts^EuZ|3w#dbSN-dZaz`G z5&^3pS#El8+NGgN8rhSF|N3h)`os`k4e`pIQ$YC8BOLSeAsx|KR9XKwI60Pu%&?J? zk-Rqp(w`^-a#g%90bmCtpH4kgAclPd!%<5jpqAzH+%s4i6^cE8v{>YCu?HyAf1ZKD zY@h^_33w;Ibg(OZf}Rl4Gdq2!|LKX6Q@6#0X#q(CxeH^l*KOfhstA5MdeVDBg3L=J zNXeUJ`y}HdRMPY-SC@W9r={}O{UoWq#Br)vnzh2%@$a7fK zYka6u8i9{BYm7o&awqhw6(^ixs76wuC8+8``}7(Au`m?TVwix5i39wurwVt_B~`g} zvb%}CVT)kGMQ$vr8DJ!w;-D4*&IIHrxOi22+Nmt))gmE=Z)7^a;(26K0*5||3gKY< zqbP%GqoP8MJ95^u@`y1+RJ(GJm?>A@+W*hZ6GtE-(dce}ejfDlIAxIBsf7m=_Znac zlIu+AhbD%?`|r)=Q#j)gLxK`nLDmR79hjM&Cd7iu+5alDiF@c6EqsQY*#A0sZE_R| zCI7Cza0^xwfie|@U9JnCJhuDg0VAZOS>{49hdnY3pWEpHueUkW!0G>Tf<+-QpJpsz zs%10-8{t2t955o{?4Mk@H~`%K!s>s#2BApZIiA10z#ClfQYI*R+ZC7(fDxifKH$!MLF;I{Hz$B=)cpa|Xttx{$N&Xv99a%JJE-I9R~CJ56&NXM0W1Ii9=kX|Se6AZrsCSVhk zLi^-zV1L(?|M;5c%_xFv;TE;ru)?NDiqV3@z9MDvBp&cDd<=h_e|>G#n=L2q)o$7W zz9WHrTXN&3!2fo)0V}3bY^zPYh0&7LMfs^s0T+pk^ZId(%qNR1JnwjBz|pXUQw*PI zmmkgr6fI8)98`+yu#+2hNdxF~$8W9-nQI+?Z}gSK{7r>)V1hnVsCfXK`y0Hka2=wW zYt;!mxossoB}V~DmO^w~`t7#4+j=_8h`&S?6<`myuhgZU^S}Yx9W0EN>ou_wC>wfV z*!@FIJT9Umg#bl+P}TZh%fsRx4~EFA>voj4&;e?60LiyQZ0sb_zUFJdDOBP5%c1MU+d%jYh>1uNev?D!`#2xPP>{lasv2=R4G1Pk;A@A0 zXoHdt@BG9EAUE0T(f1 zBVj0ohGH`Rq#pnn9PlJ)l%{r)mYePW=ih}4f%3~CKG@1B)_>g?IKjR2&%h_+#eueD z17-c@2`=z6bWpk))tu3IqZ9U&Pa7C`D3Ja~Q~vF(8X!zx-!lf>ZqLfHVZRD^FAuLM z8r#)FpaRh}uFq~a{$`tphGAPM-@;Ho`&(2bR!~js;6OrOPMq!6f)b8GFJmcZ;p66Y zit+->ugo!LN~V1iW^v5r^3Fb{GSL<6^+jSzS}D37>%If5g=IRU!(yVcI6X4st5$Bq}4z##kU(S=-iM0Fz>LT)#BF zM1b3QEWfr~o^{6g>ypLM;>qXlpG-U954oE%@`o_w(%wysPB!%84_NLmhMC?awH&YF z+xuB30y++VnTZHFsnvR5EZ~A_%7r+<$X|9t0oghA^6RcH7Uw1M2e#JF1!9V59>&S%J7$QcqT@~Z$fn;h|1+9` z-z-pq&KABpNqI}_qV>lsR3D+m-zEg89+Y0MkueVRfmPXu-)%ctuAK+*5$tAZHDK9; zCphKhT@ChStd76me7jKHQ@^>yOTC$NDc*S{&c5;FPu}4+`N(ghH(deU#lhE6ko;Km zrw0r<1*ADJgjEMnU7ba3{2SgF8MgY!!r|4}wWRW|+$OQzIh7Cf2Y-u+j+nu-&U&9c zU3Yc1n3ij#m>Qxfl=jSyEgg8dUa33fVV$sK7GH5xtJHY9SSG)bUg01*%4?bvtNP%{ zUb*a$FWnb#%4h_E_y4wxfe8y_&K0ZhNHadaUZ%iVTQRQl<3pvobAhFC`P9TirWoCm zs_(x#Ufm5^e&}H$)pmBzdH&!=I89o~TDc=0xvqmssY`=vc+#Ww0s*z*F>ce!g?PQq znjH0o+@*@;p%{-}yCYi6mCS>RmS(BrR@Or;ou%Ob&*a{g5)$AhQuZw;!W3_}Cp^P3|T18#Q7YCF1TzdlhpYwjW{fX_2 z=+|jr6+WKDE9T$)(-MVf%u)YV_e?T9JTy5~XS63Wbv)G9Rm^e3RzK7AqvPn*^V}ZS zqnCUA_y}!=E|) zDzLuslW&Q?`uoSeCbH^&r5)o0Pn@z{SIZNs=YW%sZ6ABH#-CPmV(p%4J$x*mPxWh* zpSkUrD(*O-tlzOVoxs@3+za=)I>D2FW&z6W?fHyH=ejfw_;N0W$_6<21MRiHo?E?M zJ}F)x3(IEa^O-Y_Jqutj$!720W43T)O*;3gtxfyJQD7_m?UGchnas=T=aD<&R(*+n z=!*#B(dziKZ`ckWyX`;c&AjFq;1ZOV+@+QBF8;jt494CvAoU!gs{16)d)~%s@v;Zw z5*fe$F31t%sbiAuP@xX2?#`I5{KHdq5GZ<#R&4vZZY7gEozkp1%UL(9oO?#PJG=MjZSl)%R{|UJdSN-;tyxJx&z~N z?wU&O=m9-F=C#a$jS>Ji(k@Njnkh;P?;G+( zppoVqRsQCs#XH+5ul{2Se$t~J5|BmnCnio(1OQ`%IE>v6G;1l&J^8`* zPu+Y0vx_^dRfcel=jx`+>bRi~;JIGYT})@Q;1nu5c8;aGn2vBF;Ly3-20Q(-Ch$C- z!0g7FgU~Ws?Z5L47*gN+;%-Ww_~IO#`$bt8Lb;Xssq$wR!_&s<RuJnp8C_X#(SdC((fMRo_C=f_rTPT5#?G&_E(t@v@7 z&-x}b^r>0`3{sS}r-rxbqA2{|rCSL&%%qUa{?N38acNThy2T8xNc3oRlT32yMv*g@ z9X$ai{2wv@Z2up#JeWV@HS_6ZXenf3M-&n3bmEC=F@rs4UDC*Kn)rSJMU9Y?J8o7o zz|RY2z0!#t=SxX3kx-W$N1XD&2hvJSpWa(FiQLKBqeI&I>!y$$;T1LfAKjVu6hY8O z(Dap4FHVMHE8Ooz!V(q^?zZgQyd`*5H;p+%^4zGkWu_lf1!qnIUs|@rU%g_Y?uLjA zr7L0blj|tU)0}61%I(Ikv?v@kmeTi^8{bVl4Z3%0NuMnAM>N3W8S$*F)b6g!GaG=} z5|obqZ{Hq0>S1R8sZ3Umv#o{U&l@RmeK})-5`GkrMYuh2mY7v5Xe+^3K=GXpsGUrG zAp0Tn_p(Hz-k>k|pjbU-K9ebK@I8 z8Ln+df4Km|eTko_UIHD2!>NhY|FA5?3Za+Zk|P=f+LEZ=PXpO|Abn8HAI~}WK?x}M zQL=HYTi>ZT>G-vrvfi>=HVjgJ#|zAnaJCSfU=q;1=K*HmU!7YO8ER=IsKUU;=p$H$ z8aG$1>H&ww5c;$ijZ=%lU))@(m+@-)#G3c(?~FIlhFuA?qAbSyykt{_VNY!umY77w zDCNg={5`i-;d|H{FdhI1uD2yn{FZF2U|wMAi(fwKUB|k`+w*QcULjundw3)G>@iY-F$>PrqxkslQ2N_O3IhIkQT)4OnP2cxR-z7}RO2(+l4VkR8g z(EpE?5zcbq3X#F9FV75X^ZW-=^PRbn(LdZS!a^`gENE+;#=UCD{pWN-Snr^NSrLg@ z?CCEZz^jokDcFFgOrx`x==sbgViRl5C4P2L8t^`sC{F%)CqWHPPyS!6M-DCCc1CQ7 z8(sn0N-w(?SNS)69csq=pdU+~3i#^}*z!3(U$s6_eHo~-tFQ9PF{JHM0+H`=xEJ)0 zt4R^WXY~8k^3cQt%`+e5_i0>;Xmfi%75!Vb6T!FHmst*zC;K6 zaF6t$QrKN3NKe=M7j1MvTv~9*L;yYlM?-p$o9z45cg>0Vkg7MsX4eM31+f zJgV+v9a(bO9X{nH9?2!@ckI5GqvU;Fj`fhMR%89c9h98f`?;h=wS9Zy7uVWqfr_@Q z{E3&A^GVqI>O<|+RQ>T`BT=(^aBhyuyzY0BRj`j?rIb~*suD~+{&&$tzJH_1R)mT^ z2COm)USN41tW7AqJZelHt<0<)mlF$15qezQwe{$ITdBfm-F-fVL5bP&4@%zRzZ}K# z7gG56^52e>lT@;FudQ*`{R!!(y|{caFhp?onQa44$!h)%_L_EeWqvIy?TVT7K<{dc zpG@|G3UotV!K|^$#$qTQk5!9z@_kjj&fKi72GdIWPEka9(%40tiih{*>knoLx2;&A zYs<>ML|v3p8gGDXX{eF81>EEaGJf`)5nFj>Na7{U$=7Q&Q6GJS6yn}gwMIF7a+1&&Tn5U!Y|d(Fq%!wAPLiw4#T zSOlDA`Vn}CylP%4UP8qO{&HHvlkaELT?-YN*5-!H1<$_b>3KLmD|hUnuc~vIj}85a z1xB7w{ebRbM@TQU05zWx?8K83&g(Q$M;`q4@P~&b!&BO=?`a52sc(^-gFQAF^DmevO>xC)Z1UbSmu0N7&*u)y7qmC-0fy4%^wtb02DvHtbT9R^LJU{%zNt1p8-YMlFACim0La z1N!BfaiC6Y8Z*^hzjJ2jApU&Tzh7&m#k;XKexUBW{w-l(#ZNkNedC&E^=Dn7qdioI z5e)Zzud>WgGFy(X^iAO~;*K6l3gy00|_D z!TBy$YubPFhAS9XjV4{n_VgMI^0UmbeyHikOW^?PH#x|>6CS})6RKsa-sm&mMp@w^ z_bc*LjUg_&r2dnvy6$*p5Y3}ZTS0Gu(JZb%OC2b{KtnDg)=MUYYd@&-;_|HC+n#Nn zZ{~7n324xRt+YW*Unt4<@W}CW#%(gWpCqxWJ1ax`s+Q8J%I4*FT&rG5W46tSBgQ2i z>~mW!dUm65kg2Jy!k+H6dF2am-#=t=({=gJk9?f6*VZ{JZ9*JsD};`>M6Ida0u)md-ljST3`WV;sIDy?CqyPI$x-$F*oDFls0-@2KL^SWU2l+%Uc3lmeB zJ=p+4A(#;78`cGsvfU}JuPYyy6M0s7G+GE48$*|=#SAf7!Fwn%_Wbhm_+;X9Z||Kk zy=?mC*V!W(kVa3JZ=;5!k|d?8&hVDbu0k}8+y3hxeC+nSwAUc%8aoD(?)eU~9PCCp z{`1iY`5ABXAcuExz8i0Eq~U+;3#!w|*P)ui7NKo|;4d7^@wVo6^(D;CUs&rAL*4YY zW}j|vrj&2ssS}L|9$VC+*@f{!(nfoMr00t>l;W%xtgIw)#SYGwX%k%sefJasM{i#q z>qrEjnGkOoqJ|$Phrd2=(ub$wX?m}HMa(#x2mNNm_Fbk*eh;l+PZ%TOBcZeRm%}{2 zaMjYF597L1AC{YZvKD=fUm10pij{5<8DJyhR@f)~OEqFUyFOE!yxS1|f+O;I;(Owl z!jH<#d83)$@6mcQk23wGbDdUJD(-$D<>sv}%K4QA;+SHM%c5wYAcsHK*^;8t$GUu+ zfTtkme)XEYLi)QAeeaW~ka+3ne>?04VnGAH-^B_w7E04L7l|L#?o0Y!&!W2qev{U{GhOnd3cD#3**OP`>S}d@5VWq zt^2#i*hs_rGynC`;RX+mlGvYIdmj7sx%=N)vbA&`P~Yn5k(85TGq>=N$sOo5pUspZ zY4&^?U13uuGod?_zh1JTy!EQ}{Lkl47sq>N+|`6L+Kmfe2K4ZS_Kd<_Y3$MufF|lh zv2EdqaLp$c0Ht!r*dh-0c5m3AaRJc-{b98QQo1b%XYvEvm*czMF_kvhXkSf~EvyG9 z&-;g!l~gt^m54xtWHR#7g~F@qPWZ>KTn{B>5^OC;B4m53eln5f*a|yp5%}!Ykiv0D zA{p>$lgED=k9ZvK#&`^{o=QA*3FAGCPUZ~O$^AJiC3!rGkbf8vm+A}@@!%HHA5*%UvO0>Zox=$SB zo3rC<-={H&6woP?EN8Bf<*negq!p1mCyHwxZ{!q}9Y#8t_%OI6cl3+p+KNA7ps-Q2!h=1NfWpuovo zC59~D=j7_S`{PjN#cy^Y$sbVsu3`KAP6lRU>LIfSJ2b;#)=yA#blj0klNR^+!`D4t z%H0z*u*H8%Np_CnzlS)CRRgttOO2q>!0@G&PkphG^m%tgof+M@NiDK_?8-e`c;gn0 z%Hhb@=a>fN35{Waf6$^GqS;qyD6XdwNO!@VjwhX77mMCBLMYwjd=sLjd^g(;t9BAk zyg1MnN^@$4o(MSxRfEBxS1&7h#hr_nb8yQF%S$r>n&X~Cg;r`IcqY-=H_%eLDUmrQ zLTFS0s!z2>Xd586m6h0#-Vmh5Zq~L~(O~!;aqx>1*0MY2mv>gbNUTaDy;1Y)WV}B} zp`KmFV1Yo5aEyAb`n6c@$vx~Rue7#WCx#4nj(2;bmzF+zf-TU}b|@(Axpr}qeb4l^ zmE7hrr=FPCaoINNw-d6-!g25_G1#Df1nEP8dJ`uvFm#e-tcsVRf~&w%_|ZUS@}Y8t6WD@;CsMmL9jS^<7Tp$0XXJ@1k> zLD2x44C?az8Lt?RK4hOPlcQq6n{2%g>}Q)DbH7(v=GMl|{Og;Cn30(B?=%o2s6vcxGEM z<3`XFbp$<+q$xGsW(BF5~%?^o6HhS_icipWelO0HDC7KDhtqQ-42Vb!8=G z364`~V>6~4?J@P6!lHT3RzCM(V_8vtEqFAWrI)FUbmvl~Vs%Cnf5SFMj_d|BQ8|-DFj8zo>=|K4GS^W{#}~Jr;Q0R;4eYF0Qt&y{9#jq9T;6 zEGx9v$3zw7#Y%CsRg# zO*mcdTsBWa>w`aTqzc(w;^hEKjIz4IVj*XfLTk1tqQoO-q_rt5C*YM-pqcaW+=E_KI4UKW9;D8J(o?=pe>Bmw7iE!G|`Vwj9lV@|=hm#-8srmTK|L_|= z$$6h_v!Ws}7u}ono-2FL<5%S_?ZtFeUoi!feayn=X5aOt_?AsA9p)8X-ov=Z@{G?S zcc(6HPz!Tw51Cn$Sqv{#_W4b5a@6LT>5=OmxXIyZTF-tj*woglw9nJrgsC$fVS+boq`f zCoe0#*+-l?%SQ?;aSSS2oJ?vH7GcryITbmjq7znDLvfbLl~v_>2e2bI1ZhsT@t()Y zm)JBxzDz;s56^cNS*2^AJxw5ZxLQytH^o0%tETEq(LO0hm5&(7}%tZKMuCDCP6P^xwN#`QQCetYtViL7U;4Vl%jGkW;c*hJn^ zMy+)go0)N$g2#3Z?R|wcol~L3#=WA< z*T>6bTM`FpD6fnenB=tCcRuB%S?>UQbcpj}&-{RZC*QYBX1gX||JDMI$Io{o9*5m3 zS~_JDKPelb#b=XjP~8iNjy~Ox3Ry+&jWkGi@_b5Bz$$RGI3mHLaQZ$koLhKn_78X93nx%8&#}Knj!fy5-p7IM|)yh-Y!-HY$ZS4c_5_Jc)RmuU=XYtb0+6f)uggcXazjRIi;ru+|Hwmc_BDUwlem{$V^TID`kHrAPnalS4 z=*ZXjWbFg_<%c1)jhpT>jr9t=R+j2j(eEypwJ1N;ty4GFIjUDTo&G6n+TXD2qe&2) zO^OgWZ$D_PGn7r{q`&Oj4A(qwf8=rS6GYyn3}@5F%_UE!yK|_4R{m{WY!jjn!ti6JbUcF z{Zryig6_tO<*0CDlftz4_xU_GTrZ;_()g}26#gr?QJUjzF`?h$~<9P1%#aVf-vqfAor`}_ZB|>Z@qXe}C9%ktw z=I2o(_6%w33QA&*kdB?BoO(q^Z9U<`i^{siul-MirK6LQ9X$qu#@C0bQ%}Fb4=p+8 zVYWf72Z}ZrdO7}LF>vIAUid>n>!z-rrt@3y%gnup+@@ngO1WI|>ZY4@pHyZrJ0^kE zIrrAs_N>}TASdF`?N_x}X5P@vfVd1WrihJXSP-roSH8bm<+{JtuZ6l^n2yuzDEMi6UFEj#dglq9gCdzXWXqzU2LqzwtY^bBJ>9n zaLM*1b33!xJEmWM|kf~5@WjS%_b{u?2Fs)DG+W!JQ{B$(@yv{0P=eTk%c zsr7imVakvh&?Ii9Eja2k$z<$I*X4TIEG>Kv#I!Xezl!?vTU1JRd-3Gh_&07vDExUz zrJ5kOzFe|CHZ%Q*MQax>A&w~abvJHy!F#wiLG!5O1RG}o6hKQ{y*0zOk8jov@fsTH z^_pFJD6GS3KD?Ee*`A&(NCr4LJ%GP{(YT;DJL)L;kl|5LL1tg^9pvJdrI7+#Y*)@! z-2}Tz^6#r@NjihduOoZ6YCdQSpNFbz+`mY*o#`Y!&-Sg;UFVIP3c8!(XCsiRtc@i3 zxEvPPFg$E>+4}KgZHU^h@UUL<&vwWr8>S>_)vw(k(KboT$9&nEyrjC=5~ExzmKoTv zCwW|4L}h#8Hl^dc-;bKJvkO~v`U9}tg zu>{ukX@#patM$LV*yI{;?^;qjkMyiwJAYJvn+?2@wOQ)Lx-{?lOoFqAn=OmU*4CG2{Z;j`+$KBlFg+{~XYx0*g9^xm^%v-0p= z!anbs?~9(7C;!X>oHw$bw#4aj&jgzS>NNXjM@rYSCO7G1IkCvAeDBy+fU%gv?b`9A zg!4pWw?*48F{Rwxgo@g7J>Q+T;ZqhnqatK32fS(~iWEPe3q0qYwFJCg{eLQ`Cfl{p;pXtB2~9GIq7f9ZOQ@_GC^Bhp15wC0-h|8 z#q@=jZbwoxO*2~sXAV9VuIWBr>aL41L(>TJ3ES_LO6L*1k!z0T=WD6! zPSaI$o-0W&3kUM<({5~5L^qyadL%i1r7UtNtkS)EcA1){HyjU+e}JGwUHZ#e9|oWZ ztaWD&dvPBvso$(SM#!mEoo=h!Y)o{qRW@@Ti&f+DtT%suJSWjS$AXQF&l$cy#;bx9 z-F!&syLBSUHa?o9K5jtLd;FvZqh+b8!Drr#?fGoR%yWanZc~-h9R(|8$1gEPsA|AU z!c9I;eGHJE#~)3OyQ2&J>|Qtx?bc?#`VRvm+tl` z7W@mak(eFHKpA@fZletIxrbd1Ec+>;`p9$LJT+~t?-ki9m&a}EafQ3a8`P})=h;Uk z&mFy&f&yCTVc}2ZXd!pwbf2FQnMqw20i3I`K0m8Ob%{iA4=GEoKeEY(Uj8wPW(6&?L5oseOMgoiC{pfSdUbPiqYCXe3OUh+o7JVZL%A-m z>$%0Ft16fUN1hGB4YYL~)$XV1O5=@9ymW>kN@jN62JRB7=Wtgp?!QT|Rn-+7+V(as z$mh=`@b-!+n6&l8h#^5PEbL`AkyjTBmWH*Osiy}kxanV0~%Qc@(-u#7~S6E%SuClFN z=#+S)1iPN6dad8}n*OSbi>ZtwebfEwa{gRj*MYm))r8a0Pi2H%yA)R<8CgCzFqQd$eE>J+0lZlb2 zLTchTFTUK`TmKl3{V%bHxC0grnZK)_T<#TmI?3SLUX(DuHx%0}egC@qQB*^e6<+&X z!DkOoea2^pmocBUVTpaY093Tc)H<*;hTifwo=nxq(DH7}V72hE(d;l%az4=H zu3j9Snayyf)UG`E?ZQn?nl)i-F;=xn?QtIWF=X0JD5jbMG4Mw#i5A8;`QFp`do!TBI=`eur0EU< zfxMIuVo@Sn*eFyeTenu3Q`l6H5ae|{>~ikQhHJ?&C*(0ZDV%8*sepC*&2s$VDyDy~ z-G$Qtxm-V6LL;ecY`n+1DZ^wyury9WgCgqM^bJS_#(NI}>|Iai@K_Pn?I`&rbXkFQP-nE&Ohtr^Lf6mYrwUAkCK2-T>2!@T`z`w@FSr7dog zpZ6#mT7Hk2Q>dutozC3`PPo9U%t zeny>+)`2rQp38*gf=G7B%0{()b^BnL{&46P~UvtA{N0sO~=h2%H*tezj|DWNs8}JckSj?lrU5PJxe>7vR%aN&#$% zAn&v$9)Vf zC~%*iDa+`1`>dn`P+?{|J7;Jns`#<8@%?^PnV|Ek6m_e(WURhf>jM?&BKEwf*of1Mxb zZPyiq?vH)}htL?U|0>FqshJ*?m!uy2ZW#Z_0x*PZi`VLHq)x^0=G2VUM_%h{uv)zo zaro`nUwzdqsp-4_9ww8RSy4@*5_T)(qo(n(a&^^1N)gwP7Q}vBm-~!LRFVs>s=p(>ux6h8<#k$IH5!>dp zR(Fe!HHF?D?W_3ptu==i)8$!pQ-p&RKb4OclCG@w2WV(={?PGd zV)}G#n2U zey%cw&$IG<;4liQBzu?B^U(LF8!YftEG75=t#0)&IMx27UN%J3)uZG6h*FmIeYm#M zOtbc~{yKhYk5du+dOPfUyv#ns4C8m+OLww!oQIk)dvZI6b{3}VezJAu8;?{}*xRqV zz0O;_5ZvX=4GrhC(bfi5<@lZ2xPcpt+`3<@AK^9o>Z)D{375i`cblDh5Y1uceI;wc zaY)}DIR5wQ>378T{;t3(erMo(_+-sQ>MunnoDT~3N9rGsZ-fP(7g+<&vL3Y9Sox01 z|3VbyAClmu1eUj0J6|1U?S!8!5E@9NTzI&Jd%b`5sLO77dcpsF&yL(+4pD4Lry&8) zJ$({!@CN;I$m^c#IO7o&|4ju_PfF297EIpXYY9ZO|3Kub79?=3XrPfS8eC!PMQ=O! zEi}jAc8Z{`8=(ha^Bu~WD}4Oy6E32FKh=}h=6sU27f!9Sj<9Ed4DYrn&$Hika%nL( zr7~%3y%k!2N}-eUmh2eg$9Y0~@1pi>U4>P~onTP2u8H@P&Cc%j^C2!GIPB%&EMBsl zblP|Z93pc>5{>dZ5&~e`gLy?*TATFdk4OcJgI?epPm8T1r$2p^Et=n55$7y526;BV z?;4D^>6s-z`&ZWJRr}7)qA-I^VV%~D7_jdnXjM*hKTefPA&(iWz`eIKS*x^0-p&gA zlhIy7@;d-BZU*d}I__;7=(C*84Y+9x{Cj>AWqQvh$TnPRe4P;y*0uT_?&`R{(a_Ei zvtlQBde?Y~$HDT7O)dO1RrP*j{dlU#nvY=k+B3iFs&3-%A|?6}5g=fuH)uO$?Y^pg zcvPDf(!S$jUJJ-dX{}f;GWzuR!FV*k?h^p+e*?GR$}((3x_kS|i+&7}6W8wu_HQ=X z;Boyz#mA0J#J@bSwyol>tcBX5%Q0X$#qD=#t=Zk?mzEEDox*mYtEnJE%u3#{mt*JYsYyD}$)zKW!gN4lNvzT)(| zzuTi_WSB?;r$cs1^grP`*}8J98BDG?+Nfv#*aMZXZG}0F9uZ=f)c61=o*%Aw390I9 zoA_MVJI*gP`+gAK83*0kulK!$@`)aHqs+c6-K8AXuH|*7v4oAgI`jd`(m3~!!1ba5 zMt*3zI%@otdTAoJ2a@`1d3C(p7R!rx+nL%ZkRW_p)om{ZpUGC-U%el53qReR48EwQ zYb4!WyA6Zu2i6;(5o76(@ngbaX^FnYfm}fk@&dg` zgvwTt5p5wv3$H`%d3}~Lgee5Ki|R-dekcRrl)heppRrSB$ zUWRnJ@+TGbhqb=t9mZ_;NF8YSjlH=>OveKv-g*O%$1ci|X{l(5GiR9t?qZe5v3jIy zTh)gQM=l=gt=-5Zk`3+ipR{x}RXpH$WonIpdJe$rbWKh$EkvF#W^l}qU;Oz+{NuUV zd~BejnX>E+X-*LeqE7_C@!}*%5fQH5al_532i{q;LUt&A#l4kW%V#{J|;-J84$tFU#*xPF$NI_B^Q?JwHxkY()kjqssnWO5hRe_EPqZ zGtH+x%~;=umqXUe6D~dSy_{vohH{O?zAJo`;%DWyD zVXbBFC#`g>YX}2a-70B7gS>!XPg&3{@5_Ce z*$2+Z#8OG%-d{?Yh`Vow`d*G#b*FCd`mQn&+k4~(yRqy(uevpXXoI%KfbUN3Dj$b- zvRod?P-^p>FH_Z@FZl6$(OKk~pW_HnV%Kpw*YY@h*T)v3kb-sb>@l2!VBDbP!52p= zb`}ryviTp*bNB)0jPQ4Lf4hbhw4j*wsL86hAp$$13r_h7p?6q+R>_hDmu}{Q@|6+c zQww55N&nM&pMWP_@8n8^v7e<@CA5e}Z}Sks1V1rcVuuO@D7)e?IYVo7@C`pT?0)6IYj9 z+kIcW@rPJ!-WG7tj+N43^N|7`EpLSd2~U5

R^gYNomWN>FhyHurSEiCx4c5iTBp z@J}_m-Ra+t2E$2w96L+4^9u}&?=kjc@z3iZur6CzhhKQ0?!$LGkQl|ME&6T+Nx9#q z>0|%!9gdq|Yi)b}SCL=?n;&p&WHTl3BKE~&^LhgrlX-mn@csZdwD3dmg)mI50!u-Zr-z9NvP<+%AzpCFIOL_B#DSReu5=NSqCXPRL*Woq8GLt@uanxzrN*ozD7F7j6D_Nu{s^lQ@uBtS4RRnUyPa)Bb|md6GLwh0Mf{ z)$69CqgW=Q4U$gZg5cl%A%2_PUq5J!?N)enJvc}AF*ciYcJj@?BPt_aVeCLS?>G2R zQH9^X{+QlXU5>VL9l3}7fN_QxNE(A~y=@9v<&EGXnv;`g^C2C8l8f~Oq5xHxUQr?+TFbU^w|G8A}@@`#;bFxs9M1A47VOz?Rmd#)Z-Vg zBl#(I@k-cRFEX-}5mEU2ky4R%Fr>u!o-eyB6ZVfj@A2Ld2wy@*#`|qjp%O|nM`@QI zN1{zfaT!pCrC4r?;KQ@SpxeIHM|x|v4|?oa@C<}DSDRpLR_wYe#~|j8+Ee~>e_H$H zKIcE5#wlqVif#Ol*itK8B!b-fOG~;6M%20I3#Y>QNpxND!rMN?hlW}t9~CDz z_y@i4y-jCCmMdg>Vw&FyUAI>d#_4UlASxAps@b%28MP+FE{>7@Dzq~~H8Ion2A8M~ zsWbDY-ig40+rz-p^u%qS)7IHWLxS+53i!=ERR52Riv>{smVv~8+d%+BjD{yHo?XneBo2m zIi-=P+=tR_rCF=)_YUr&tEcpIMBFX;y_;=jE>aF1rtQ_g>rx5rA+ zQ%iY=I4@y8$}4MuKz$~Ie|2oNgnIV}b&K{Cuf0w6)9ni0og`;0Y&`r^!|+4~&o;om zd-V^$DnujZrSv8;OhHYUBWz?r>dEAw2YjL?jxR#@?LQ1QZ(r$-+gRWI$(TMcg%F&{ z@y*LI;O~q3!amC2f3roEY1=m{BS;TX2ErRdsztz_BD|yzv>keCYODan=I{U*O_{y) z&98E1r7Fb7#Tde%I1%*Yx1_bV_crv}l zWK#=+FBYAGYPGEWwrrMXvYQ~kS?x*q<)h_s>&7K-wd;fPH&W9Y?5A`9uqjL$n>wb1 zpnA)GG=-Tyg_$whb#Fu^k8hH;@NR#M*V8rC@P(#1sM+b9?@~EqU#fk%N>0A$RO_FnEeKVVQm#L5&TEHp zDTB2@HgqfIr7rrdpyjNRsk7k9sA1vz6MF4|#x)nrNg+(kakE^qodBu5#0ht}ZC^97 zj;@PzN-Xb>Hk2j|5^QSJ!AHkhTH#bl&96c&jh%Qk58S>-64-*>EGs$01q$Xtk4#%4 zykVEgBjraFBCL;2^|S%yUP2M1(@Jv{uARIwXWN`lcv%X2^_TePX9d!adYB6KUqrmM zv+i<&YC``y>J^SWG-@cdW@xs*Lz)_{=@rzxy8M$W^RO6C z|9)@uA=5|UQW~Biz%k3?&ki3Hfr68zP`_rM!BksNA(lV|N3KR(e{3y3cBLzJWpXLP zegb${MO%w;(wJ7?i`RA%Yib0i^8rlc*TM_-@LyFMPz3q+fuRI{np}u>N=>f;v-rl9m(`UtGyglRF2g)0Mt?P!O2W* zK8}8BXyayCi~cGvnM{>XP`oNyl0>xycTjTMM=<~t93tYJC4Z%eoA>BIU zq71yZ$F?JIvW8FC=Y(pTPe-yTdh6$@ulelR$u%M!#J{5)zng4xS4});4K?@U^?w|K zP~GQBOv+sWz(#0;=!Kw<_fs-g{lk@+z5I8){`*7F)@k-=u!5Uv=T!7X_;wy^)YGa_ z7$gup413DmV2SYK_I#rqE~@S)SiUYRv4s(6kTqqRn0fXI2^CPy$|w&kLwZW>c=^u< zzXIrWG(u;Q;2FAk&#Flnh-J_H`S+w_Tnp<1U@EJNjvC4rbS;5h`R&fFCojtKrZfy= zc|;V@X|2gzJ+|jIUNNux02}2=M4C~cK2P92Og=pMn+9Z4267Ns{;0hb+#ydpR@-Q> z-^K?I^foJac8U8)r>}OeCEQTKxvL5v zGbe_?ZBf%rClPiEBYTex*zjoXb1A@I2KY&2X&1yMDgCI0Nd-zkwH0m{Cq5DRCTW+N z)V!SGAFvm(x|5ryHIw@|FTejjgd-~hCv z1h{#FesEBl5?!HHw>Fe($2lKf*D+=52n@H&7pOtV9+Lu9*AA3p+o6(vk7IP!si!R? zfF8_7Qg`hW=1&5@*~7s~Y?Mx0N~Y^>$MBSZl;k#Hob>U@mX`&cZQ;zR!9^?TkJ7Mx zm}TE#e^MQ*6mg5d>tHO#`HiRWQMYW>eg^3ZE~t2CyIS#uxRrvXKhh-|qMF(#Wicl` z^Yg}9d|=2f1kPz>Xr@PHqZCLq)`Lsa z(eV+FQ=xi$SZR7-ew-!|2DR2>%!zbjigpfQ4fuHow{!Uxe|Pz}C^2eB>oaOm5k-!O1@d9hlM!Ayp3n<`mh(s@>K% zz%P9(5p{^&%?!t5jSh8^T&d$B$uL_aDm(SXe7^3OU5xnr0SU@t?lg_Vxt`zX;a7{f zNYlVU!9rL`s6#L$_$r1Pj7>}XB^dy|k0)SsY*F6N4aPQ@5UC4Lul{a8hPfC;gqyJX zB-D;;Hb$^Aim^R~R9*oecRW%6qOE`WNi1q)Fu~9_$!LHsvI4l74hjpA0zfZeT}-xn zj2{3q$RIH@>qn_+V$k|`A$w^c_YkK?g$a`aaN3jy5&Fl z48n%rNkf##)uKy|mTbY7o{Jf#DN07i7buwcyH8PFWm_j*Yv|bt`uo*Zc1Imq3}EW4 z^9MAd@%sA*$djc}A_;xeB*8Y5xPz&2jQJ`at@MV;^M3UD%N5fWv7!X+Tq(797w#bw z;8ZCAR&30au3FpIv|$Agni`5NdxBg#YB8%c9z9~Qogz5#{lWJi{~Z;hCLnE>O=vuA ziiQZX+&A8+`04r1N5>}xqw%Y}3SfJqdU)ePXoN3?J@<#7Ce=kELZgnnP*M_9i+PS| zq>1F~fpjla1p#~#Y$pp)xW%CNEM&;;m?JSGG2gQ31*9=!G;R=l9tiU= z-^)@;BG-YlyL}QqM(0|k@P?ZNR<2PT*)VRtR6-uO!uWb}8AYRT?dbT1w ziZP4efg%D{2@(M(@RUVZ^}7?Ss3oESX{qmL_xx(g&g5-SH+V*XGN}*XDN=CSoq7FY zP)O`T>Kin>yMofF!PepUR|$fxiw?QO_h}2nwfl{xee>y%l+awcgNcQNSaA+Ab&Ze2 zW58x(DgY04aYI@eD6+sAD~G_Y5I^eOY@U}1u~&eSO>VSE`ULPq({exF!U^;!NA%+X z^9y2bIHFsPMNGkCaz?WCWCGQsoHtGx3f6jHL*Cq}Qq^HxV~v}xbE*XndRahSGE3l@ zB>?k5aEBW7;Y%2tm!W>EkBSXsGeKvkFh|ix>`@luB!|^=0-^&vjiSB?Ekw?p+laTI z&i3b?_B~bRPhVexsovk~)$^EcQ?edg-81R{Hfa*m7{PH1`_oeIc$T5+apfFEf_Xeo z^u-?>6y-zV%2nD~>CFH{j2WG{IDpp6Ceq1eK{^wly-mK+q^)-*u`S#;)$bvJtY%lD z8!rtc)P^Dk1FWKqx|%{3`$-Sb*r1MC{AG8<_X~IU1XEpesm-|AM|RS;IeGrkR~CyZ z(vca2RiY+ieH_phvZIkd6MMY+^|p~(Ij!3%8ww7kA)*p)CY=3h$a@i6ofAb$oieAQ z+!PKHzebC&4__p5Kdn~BKZRPU6+gp_0;qJ+7fLl5=*o>Ko9pboHotIb^UI&KK{E)V zXPUp~JzeTyqKUeZsM`6Ko8F(OWZ*uBOuB$+=%qSQtF=g?yP!51ej+(`5C=-c<12F$DPWDlqkRaffo@uq6Rd8iknss6biovT;I` z=2=ZM*w;~KOIaa}cAQ1hpCr`~%t{ou6H@7S{d4_m8C6EAdgw2j9kdRfokk}cxZ)`U zO*kW1vE)Cp$-k;?Xou4%8h=@IYZV*+G0i##Fq!`{{(3S{LdS~9Ayw68V{u1X)DL|l z@huWdPJyZ6%5{C7j@+ZMrH_D7pj=S&($@hGpV&ACA0LQ^x=^B$E(Oql8T@-w=J-u5 zZOOtG?8F~eaAF@Cj80x}XZUwaA4Pzmyu3DH#=-(Y`#wDW65`^BBnU?cFqKM-??W|c z_Lbl=k}{LZ)~^F8sxFhnE>aHCp$-NI(hm}+;g+sL(O?T;X;NHtdnln)mH}#+2iq_- zhJmJJRNLh|YYoeVZX`ws>NRHRP!LJ`oBD0X`c+bBl=X;h|nL$$?V z+*;^PUrPW)C|yPLH(M=Y!La#Jr05Ff04P=YZx4s(SaUYlF_{qp?0JA5++*bebIsQY z8xByXASiokgkTj~|54Dz#|cRI5yBg!xAlopmzWJbUbjkC?AB#zRgjIYnG4 z!!)LQ94C&Cm6loeYKtY2RxMNG0EFSh)vv>dL(}r*p@!ydsdpL{a+R3?=4F-?VBE$K z6*ZVGnc0-Xa@J+{RSC{SP#|PHXfd`Y$QiZpk62EZc0Q31v@dnTC-(D8>t7o2X9*bSA0ABY8?J6htK?N9#*s1Omt$n&dYR zXM^|W5~kv`Jg}q|>QLLt?}9a;xy*N*bk2T4RSm3r1Dk3m;Yo{U#=jjykx@fA%T-Qv zG{CXihyOE_L@Z6Cm%s%epQgbn%PYbydHtm>C(Wg@3}mN>CMpmTj;B>s9k#@6>RO0A z)A#T6@ZrwDYJ8N~^aLh)MqIN-QtV&lOrC$o_WcRP1RxL-Cmn@`CplyjtH(fqaN^u#ei$cx&Lu3>Sf?vPzVW@w~wzWBG zJ?BS_vTg(F46vkXM~)HHVFDi(0bTX3)s#Xe)$SzhI25c$lJdZWiC)Nw7U9t<3BJp@ zwY2Ti6Ckm+BQE%6eST^V8Kedy!nD_Qbz-pUkHzML|*@GI~{Gl}PC&x+{Mf;~QWrSkO{SZ~UY}7|7P3R7cA51PbaX z;sO#;M<*A$9V1aZ!U^0I;(4angS5K)!)O2u^}@2zt+AH>vXswKo29CM@P3-3C@snU zVxF$~T@MuijX5gIc`^Da#%_E}^C_}J_NJe<=jMJd^?P0cp+9OX#;H6!DtJj%}s^n+HF49B7IY&X;KfHJ;%Fvy8Qd9JrP3-fFC%qMhB_L-rR*5a6^!Mvq~ z=Y%vk!x50hFc>|DiU5B9mZBiYNA2J9QZPkawF5xo!fWUvrN2u%B}36=W5xn8Yngs1 z{0o2sE7JseA+C(GwX6Lb8G6;sg~3n?<+7^}1{Q}VkbVeZlr21TE13j);g<>dy>BYa z--?Z$7ys!TvKoZdfX8)wR|A6D{4%-u&&0O9^;P%tl>e|R`XN0_oLD&-0^DmVKT=S{ z>TrR2F_3U96lMFCD7+o%f`6~dyQ0W@X~r_@3S~+iZc|~-1mGA;me-wyn?8l9Mjhmc zC`LA;WY7k*Zs-{rC|0%crGLxG)+|OTm#d2~ z3~BC%{Ut|oX+RC6dBja=QUNr=XYoB=y!h#c$~LiWT2d%tvg1eIOFjiA5(_p~rM^Dj zWXGLrEyIZ8)u3sOO;^E~y|?{zGPa;gz`S%T9r86*XhIl_Y!9an646D!f%{v&PSh5t zKgz5CQ*L-kQ}zmtOPP~iL8A#H#;-E%O1`|q_=| zNPJ%b{a~~dDkbIODL%8^jnS85=h1KI1gGT)wU8BMy&}n29+ReT#&27P2?yPKE+;^! z(>w$0bcEnExgHndcpkbTi?YzgARt*PGnqVk&<5dhyj4;+%lqJij5p|Hyp-cKcqaN0 zW2mA(;(AdAWmAQ)8}xNv1D{&zoQYCs+;+!fy{Y}PK1>Jp;ia3X8PlKnlC9_J&32w} z9*O+LfTL0>7ql{LK4i>ALSB>+C`8M+O--wseY2v8kMa)RZorTLz~_%s54 z23eXEN&rs5b{!3#WHXaUN09vu+HC2!B(ooeF(n`|#%M+SH zZkt2kpLns1WgllkawtT=7&cxoGS=9NrH5mu5ty3ckB2nzJGeOg=?q@?i<@9Qk3Ro5 zgw@*8EyWu6inNyo#zyMbpKy*Ts z`P-e%_~eCmOPiNB*a;|;oK62QVTGAt)jNj2jW;&X201w!pH}CI$jX1frmYgDAxZ~B zFEs+!WSk>O5-dH8NH<`LV)bg|5UTT{)FpDdjA__4*i+d$^~xakZNMd|J$;HWOG~l< zsu63g#c5CgAUv^CH;qgx9l8!mzFnE%?&px4Bto7c8ZU*AHGO0Sn4Po)I5M}p`|+go zEZ5fFWwain!5P6a!H@pCsU-44R1H{#of0!iElYFc6kv49orwfu?xLGI2#Y8Q_>2*3 zrSHyaVVd?Jbk(cj%PdkZ#$oX!al<7Rhz)uA+2ZoUK{S9$pNkY}9^NAznmH+L-`!XAh$kc=i!>Dk%DZ9CF^@xG4jgv3g`v+#Z_zgfXMn z_{ld)1x=%JadrR(5Ls&3fOZY0s9XX39BB?dfj4V}HtB3Z`PepcxIV)xR*XK)oxx_( zP26N^0@wil;}YZ*tO%+Ln4*D3fY3CM80?T5L-z1mQmcRyzrmjAa%rB{HXXAwZ8I3^ioAq4YL};pvXsNgZx(=( z5TLYCy~sU0y~hK zYwQJBi&v86tU3)<1ayWWGpCxwWLdIeMmDI{((c(ITjJHggk0Ga@P{)+l(5dtye}b{ zF1J1WW+Bnz?|Y=KErrmxC@aQ=Y#$`mh?(Z!uuJHtEi$ZW!f{}sELeg^LJr3Wbl`P(OF^SuCCPM<0O|QQ6p`3J*oGO96Q z1XQtpinvT4U_{&g!gKx`)Zdt&_vK}|k1_^qlBVa^Q@g#Q}PL1)Vkm<}O`#$WYLa9@Xg{m~Bhx-F=Wk%NozHiF}!C+sA$db(n`eIBX# zWq>tm&?UXaBLlM=Ay|PDduim@J$c`Lr3nS2r4<8-Vo_cN$OEkfZ%U03444nF&AEHd ze{4_INITfMTf6B;^Vkg_Jq;f=8=h(t-weERU7nL<2hd|n?NjysO&17Vh533ijt{M2 z2`n-w!2kiCsQQa9y3j|n-4-LP+ltEos#Qey@)!qzuTU1%nOtf5k(!+Ne^y8#2-uKFEM?^&qtPa*-5dndL5cdAiM8#iH~)vP+HRH-0A*z# zF(u!-{jrN33@t17T-7vEDFxaUogRpa1tT4=5dT7i>8XYv?dSvJ3ES`Gs6xFhpBLE2 ztk{V1R}GL4vl?L(2Rcr$SG;k-8-XHh}BlfYPjVb`|5OGG}rMVU#sT ztZUiFcFKKSCy`^2-?bN7y_6Bog+QR+HXY=q)QdSPH}7Yvo6)@-GLhbV5{FK_--0za zUGTa|K`-7+v8)3kB~P!M2;h6arzzici8p^kPhgPiG>5(Jj=gXh8^s)rq5JQ2f02m$ zkFwn+1RIK0J26YM6koqEg8FgMrf#_ZetjLY`O|-Dj_CFlVxr8#$-wtwR6+!}aMt_9 zDLb~zFP(r+tv1kqBU_Ocn6*??d`K!&`3OU|*mG~;t(9WM1I74tik8U1;&fQH2y^O*9?Uk>8Sm8Zkdlbg|h< zu76uK0-ro;D;6~jT~|QDE`TWBI(c%!FO?Z2Mod0x*eKw~lN;=eA`V!Z%o+i8!|&lG z-9T9%9SZoLN-|P(+jxlEK{B8PtzHazL9PgI zwH{?RNTe6jtmJ7`G{)gh-PhKJ^BZ?!Q zb4S@;W9nr69)T8i(1DLDmd>nfvq`^>UReYKt*w#XcegxUeO<(1L?EIu!`I9{jn>Ihc04v~ z`+CvXI2^FbJ#>}MV*0e{dEQ%sS{U$!IB;+4pa%bIT#b9-79W2E$c1>5$aT+&xNKjp^`ZNXm?$B zJ1YwJzy4kBtdvtI#qNJ!p+mh_Z2Y5_aAJXK&M{It-uQLU$!J>&a}F^07594QP~df( zwQ=ZM#p21WhPvTLB3(P$i560}#s`|(02~>2L>u?c&{#w{5Ss~J8s9;W^kGNv(PL)| z2RMo_iJBX{y+Jf)?PLoqkG;Z=GMj+avhpiF*Wk0TcDGi~` zWTI6e!clXQXv^0UPw>itGGNXMtI5YexS_LxEPDC735}+h*1;$hJ%eh<6CO=P79$2By&7?F z{3k#g3-I=^nSL|SLs5Lv)0#j%kD(#+Vv;+(t6p zJir?B=up&C9e5ohu-QrxOq-}=YT|?~NqyM|VQ5c%(Hu{acUG3gm#VT@YP10M)ITGG z12KZNfNCcmdK1_SL~wxjeK5`I!yK5cMNYqeoZM7tnFK|NR7?F=*ENK8IK#yk;PT!(@L9prNg&7ys(eIYCye`JwmioLg-s@ZAhmd_NH zIP1$QaMt(KMm!T$d5c#tOP1_&#oKdH^>1TA?I1J~??g9FS4H{n=w2(=g$7j%tpCX} z`Rnt`hGoD&^;kq?L&LbFu89(c(h*`*1%xL&`0EL}?mh?kUeqj2LkAB-v%hZZ!fBh# zT|(qTYRC;|0qIzz1N{j9eN&@}#=d-(`sEWvp?1(tzT;U`K1R)`uYnkbXu`)*o59kG zBV{U8Nrtarv1`GhtL3jV49Us>rtPgLlh`(EDi{x&KWA&p*Xd6f-mtFFVGHUp~DXxTDQ- zh_tjCQB7ByDv+-@w?=g8Pf}{h#8|be%t7ub>|jS*L!*!{ouUc4LHh6YMfg#1)HnA*g2i&L(822IGjw!NJI8Vw z+CLm!HH6ta@f1!0Dyr5^P6Pa(tReOXiG$-70bBhBC}0ZaS6)I0vF!>YNl$m7Pd9kV z|LJVT%Y-1!5>x`a$B8fWl5wY1Q?2(2A15NYi^u=DD^SxC?I$~5`j~a$skp*LRbe$( zf9o_~F&0_6kwa*CgJ&pk33&E6&FKY zk0Oi$aHZm>+D-K9)lyW{WmLWbW?DwwL_l|@V3BQy~Sk?+bq@L5<3 zI9fI`RhR0t;D~HizlWKS$NBXm)}^18!Jx_i~1K?KX4U*YD4EeAr5mr zEQ#TN$bT_1z;7%f7@t}?b;iK(ceLf#nzXYTae`IR;0qiN(1r%&V7t1`81aXuD0-&} zB8EM^cHw`=h}816wqad;zdeTOS9Z5q4QrM{CH3nkHMh5@Cio63#;LrdLsc$L$3Dyy zlhG9xXF+%W=$Z}nMZeGIRi1QfOSo>b`2aH(;^B14A5{%>Cs}V5h%Nc?&PQ+CNCdG9 zef~nQx!|>Ipr&kV$jF@h(@aigWdB+Y>MFlA8{cd(SIB#MgSUGEgH%hM$uO31hEp_ zVFGe%^PSOcw@@aO!i?(-X3B1#9u^Qjj~mAs<3C>BzKmZt!c%k$p=sbnS>24d^&J;{ z)<1)(ua1Jy*h4gAaTrl-1N>teWO6Qm0;R9uG0R6cXzv5s0E-U&Hpt-L+aem;_&&{^ zC(K)B{3}IhBMX#Z@$S9SSA-JA1#$!8tlSsaa`vQOhU}>Jptfe4} zVQ z8%cXTTKG_1;L4Z|2~ej?{IR3Li{*Nhj{qAY6K^xX*U~_@_Ta`|p70>wepMI3vWm+i zB#L*BRkHqvu_(v3t(Y56Cv4J>tE+o_Ys)EN8n2~x*2gYhNVCJBkBOVy<6$w4r-0)Wij9m3M5*-Vu$ zQ$-tEM{_efq66MUb8k;j>&?0hvZ$&>FLD1O`iX!nsVqAKgXRqa^CEL>rQ zx~%a&?n+6djvP);LAg8ZD4XNB->;Uq-gkdnFVz0C@@7XhiP}vZ2>2w*kGBwzeYY`5 z|2pY4SF9@C(m09^>OVmEM_~J`I2-x>G9vJG zuJG>5P*K`Y*cMrB$j}+4%)x^W;vHwJ zGCMSGAlps!LYatJV;l`0TDstc-}T-fKf1AhjJsS2ro3T#_kKiww|_+c0@p`W{H5)J zy9YohC1N1PlOCyAfx=G9|b_Y|G9h8G3Kl?Dp2qG-%g3d0YB6Zr@B%2#;;-%jWe%|P~NqE zpF}rBzujV(+bsaat}GCP?kvz8jc`Z8bXQB|58g!!<+x&LxADj45>zRyLtn?d;P#&C zNj>)-og9BG*K6011_>1mr^=4s{9Kq0aw?6c1VmyBpejZ{bk)>ZN`@A#NG+Naeh4J@ zz3Jd(9Zny~Y#LRQRLWC0xLNi4cJr31`Sg_GYuNF#v08f7T_Ev8NFd~@Ag6|?1ufO; zPcy&<9l~lTQH(>8l`<}SIBG3-vaF=lCN(eH@)`0xA;hu@Qvi-8tPs6l+RTWycw-nl zxJ}>EJ*u2^`q8uJj-8<@6IFKi7vz;rdT-_+)$34KTn7!$dQJj~YbmoYY; zoRK%uQ>oxiI=KCEQq87yn9TrP2x)yqls$4xAw6@?A}A&g)JX|7K(74t4Itj6MiKab zJbiUoRA03AkRpwABi$_>f&xkiNOyO4mq@pCqqKDA0MgykT|?KJ^ z3-_p~xZU!MgP!aefp;amS&$aEpH>O%p;&M;h4=X)Zw%?zURl8Jj-=0J_9-2-MSdI~ z-?&ax*-yl|uKeqnG7{x^y88N9NH9d0f6jPdiAtTZEBlX4aq*->ZPKHz zL@ydcQT5R*uX|wJYnU z(K)OsmGYt#=g;X5AkXcUvnWnrx(wFCgnQeKreBH^Sjf&M1(n}?C=hZA zCUh$Wpnve=y1MP>84M zD&BT|$SLM%(6)1C3ZzCaXaii2YseBKko&FmD6*2lTy=Lm<{+`w zEeySvON%yl@@I`eOQ2nA!IrxHv{mau%GD(6ML=q!+ib6fq0WClNpX``(@%H6x;@3( z3hY&&{Jgahvd=MXt40D*ZJ8(>X97v&BFtBy7(%(lmwG7bXPXTZ9qaNBM3Cuc1h_J~ zgrB*{D8J}#9ha?x&; zV@uFk*mhw}-)(U{ipFr_esSdMd#2VdH8y|5NW?b0$L9-II0vkL*~TIw=UZwWou?B2 zD!nMtw(SVk*iViQ^apfNB2g5@7)^~D+jFs?O6!|Hou9z#mh^EmxJ+bGv=?)RWHQu4 zBfY$rL~2{x0D)yUCZ-Fl4gR%$dDV3nO$~(;v&MS^-l%+FZAfCbPc{*Y=nA^aY9`t| zvqm}#hI8s>!`_j($TgW=Q1-$brf&}mkM|)glJoIaRY?rIbF*B<`xdrQ-_I${$&=IH zLZc$!;`!!-=#@V={8P~pDdyQPAK6L?8C z3eVqUjzV^hz`~K%pdr36tn}lgh6w(w6rlpTOpT-jpDFL9Xt%+W6jxoyvTazE#&7dcIQGTjtfY= zJYKY4y?ST7q9@F;GazC`LJ~sQ;ZfqYL3m; z@Ut--)QsJ*7^d3@e>axQ3By^Kg(iv;{qfin{NmqNf2!RmnFKensGZ%B5lec@FV zR6cU++Qr^*UyAoV;~(HbLLW8d)Eh04Xn16O&LQF2OA*3+7E8U$rpF0AhM!bDk<@8G zQ?n<1`a;Srh!f!pw;e$yZM-;wF1&;KA=Nj>(auKHy?*pstu2tb7~>liRmKoaI&Q)! zAt(0O%L>5RpY$K!YE^81YMBTv0;xE3;G;C&RlkgzyXxYGj*J|Dquokgw>e!`C^SFJ z(f68j3v@V;g&B}b$rn%F7QzFaow>LQ9?wuOoNbfO;W&O1WxKvyJjwm8HLCK-1SPwJ z1!((6;H#J>9GC5Jas}E8j)Xy@+ans1#z9YJrW>X5Faqq!=% zBUOt6(V+g&uf+M&E%5C7mOs8?M&1=CU1$<)*GzvK`q~1M%g#bUA2sJxVkKXuwW>}m z6`@z@GZ4`fRBQUWNfO9BHLDgJrdyT? zQD`Y6D<=#}(wC(v3*uE_$j|}ZOS0=eE}*h@{vMya;fy9&AcyC(#mcwQAz$;d!}EEu z0covWvHS|fh7(}Re}Dfqm_L8G1sZGSC|kY`4+6NhWAPFN6vlN$X@}AJ#(G{~a^jc` zFC#0%qDR|ZuRrqh30w@F*dVr*shD48CfLIME*L9_GK!%!)BsK?v$v9z(ZKM3IE?Ty{S) z_|dp#=tGqI!5&SYou{)*3^`-B8H(y{lwOzgl@Yu2*vN&}FpA)5;E52FAsEO=J0x8R z9tet$PLx_*7!M50b(ur7;r|0~YWL~bXJ=dAE)?Y2cxTaoSnzqm zOZ4<4>YFqYI-o022%&R@raLAf%jWby=TYaQlrh&Wfc4^p@u1ha8o?r~g5CPnCARIbhi3Q39%O9GIH1H*dZ*Kn`=c6#CFk*4yw=IB1gt)Sb+ z(IDqs7}9%cw-dE?Y%?MdMJ~dhYF8N@WX*=RwD)y&Dpui0M;s01=V;}V((f+3$roDc z(yy{VgfUW~X@ZSd;Oe*@n&ot*!UAMS*%%m{tqaZkv77J(_qqgv_WEQGbJ~X!7~n zrK@aHvOpzCLuk6Na3c^^DjOAd5@#!M3Kh}K#-4#SgP+V$!)`<-layXI>2hT$gCFD? zoFCp_YbI_If)RFsS>ZEhjJzTq#)=%%JB8v@!&JG z7Ky0&gIF}660Trj=z@YW=QY_;gzOfhuQfoiyDw{wQ^GMXB>a1-f&J6N_(&BdO(Ii# zM-QiTcP68>!`*I%kmgnhP7h~v-#_)5Qr-=jOytoTjisMzrq7d%B9azr%RT6EyJcme zY@rV-4ZjElNjqHms$rtQE9sm`*?~oVRuOH}y#81`{hK^aOB>IJ9TBeiXk2~Tsh{Di zfTyQ!wlo)h%#;aN4<|Z8>=kYI*7J0i{Ouz85nUx5WAFu3d2xLVf+}#cf}QgM?fEon zs%neh^J)8d^Yaq~CiG3^RlixBEjj-_r{FzFjSV%LH|_ZqJ+u$ls}G3 zDR=(XzV!Hr(_9@u7Un-2w(pam!Y6!G75yTR#TPB}=58n;tLJB`O!ao~$W&`2L4>#K znZVbGENQlSOzmc>C?uTXIQbHPT^5s~5F4h^6=@)cS$42_>sQ>w5sF(}^v)V5AGtlk z_^N+&o2-X(^v54~sCbp~C>mNIH#Xey%e2+XqekW?Mp7JT*aCHbjr30q1evRi%56cK zv3H!)deiY)(vzYCyXRKi1AdIacSDwS4w=U-&+F2)k(UlUe&jXgYZ_1- zCE36+Z81#Ni0MX9aK+J&1|E*^K$xS0w?x?n@aO`p)miUm%#$FMg>e78O7p}N=S+JZ z10hlu-NSY=a7pI&VyFKr9kJQin+i8aZ+~S)ws)C*njU!IR}7OLmTwkSor+Zn0J7at zfFn+X{4ioOog+6$e4}@%?5NsV1$zuA{f!;XM=Y|&p2irSxdac^GJ!zXNL{w{ar_2k zO4n)5HCNQXzGXp@zwUZ$g&vW9UKjK?Mg{wE>67C9nVvv0`CQ&E!KJcF7ivV*@99Zy zCXx~X;@yU4Ya=Ftk`+aJEcF|D-J6x9ytzMKo^0+-W}< zhs@gy@tZ=mG*Nj(T#Y3@+EQaE=cRl9YllMY zqXtF2CHM%yh43-U6)LoC6)yPmYsiDsH*yVPd@I{4yf-^D!f!K)GQ3ZKWykX1l z#B(XmHO|Y#Hr*UnMExu0JgvO(-nk>Ff$iaw1plpVQUpjhk})0cFDKmR;!Dqi1E_#c zK|PcMh|OfD9cB29Qr6z%4B6w`f9FF%j(AA<;_VVfM4?=81dRFJ|3=Mh#X)a#tNod< z>(c^K8W%i}YRZu_ZLc^Qpkt;gsMF?YVmqBK;JE{I%9NG%6=x4?e+H0zw0zq;1w&$# zd$*2H$I{TzY_B|dhW9bf`iQ_alDRx8q+_!L(!%AQxoo-cwq^qG@m%mcD1qu^tO4Wg z12^|)ZU`W2(soNNi*C-`)BEWQ%wV$CdsOD(uA|V?8vXS z@1DddX|g@sQ$qF&Lz6>TP z@>w3})N@@Y0(BkK(h#}#oVazJ)X*f4z9pV`E0irk|D40pEEQl=XjJ4Sp|BM}w)NU- z$~V~?gDA8Hn;;FhqE*_c9BpC(fH_84b&{lGqd(q&#*k$|+|q`jC|$A)nL{+(NuaJM z{*mq;o_rj23f%G)_x=pWT$8ksO~|)@?l?JkPd3mpNb;Ao1k*TRrA7u+Am>dtz?qJ% z{^w{2D}jkb<9T)%UWGoAB5w0B!J`F6!&RS;a-u^i2#7H6=`etBL~M4|FV7Jz z`$vK;dJPDmmT*9;XgAh76GV9N>sF&8i!7<{g+b{LS;?D6gO;y=wHeoJ9=QNMh*T9t z4CWf~G*81bOhGfGH4dtR&lgXofU^u~8U)^t&O@n>!|mPU7du7DQa)8NDP2@M>BG9G zNrhkp2gvS=C_kJp8W0(W{$FIA`ZvRxwEqH*x~9;yN`6rt8Yj-XSBwcV!u_E7aL`U= zIa>$73s+vA+5DOz#!H!$3n_%kr4j@&L(IPHLJ1wFED08MCC<#boU6|>GT(OHrXx=1 zMXv#?ch!%|F%N|M3gFxXx*fdGT@;{()Qp_hfWm)&e(vD@=cFd}X>{rtr~Pi$i(IEq z71ca5WUiw055k!P$30BrCVJyuBh=LvC!f#!$VGnFBqS#1zhEpvzynNjoJ>;^wPf~l z!oSiKGZ-1@lS65__`@)lf)5+dPV26;VBeq4ktL714y`h=x}l_yff=^%z^XbMDa1HB z+f0e11=~?A>c2e_+VM-Kfy5O z`MS2*$?4?W3ze(2*h|En)!{ZDoR1narvLp&6eUd5Q9yQmP?b4u zD1!Il)z&^3TSie4FA>*@T&IHK8wk-x7bQgZ+>89&>m>E*MpwW0N}gbbhge?0ES^i5 z_h5*JCoOx73fmF?8J5Cg4YZ3(-wK)^7)6YgApL`8|Fpp*LTtc<%LHB%#EbpUqjK6H zQaLDr!e&C-D72LuznI((S+%!0l<&Y&AKYy1Z;ZG1w9D6(f{V;4TWIqJOk_s5DFMdG z@ucAz6d3d(u;%B18ix7BaIQc(6*CN@Vx(OY*p__BC$60VMhRPW6e>u5z?U*){UPZP zKnCIHb-db%{@K}}gzdCHNNe9wQ@RfmPSv2nb~JXM%eoCVk{9~R&CNBlGVEt#1z3pD zXpCB}GEk1_Etf=BS#<2=;h0~aid0%qxr9Y{VseQ3=?@TSA#|<>*)1PAT{m)eUl&&8 z>S`}PS=vFjE8BD&H@Xn@ecbT2x0!&pPdYqTBR}Ax7kd=rzmbQv2{BP`QQn&bzly=2 zux)F_#EN=<8$yH$=80&+`jQlbIcxpN>sHeWP6<1Zjnw*LX-zq1e4MpcUL`DjsoV0^ zx&?_Z_&>C}ACs=%pVYf!X9_p@{-5{%?uD9{Y}1r>aG;#ereNo`3M?4vDN?*BNXX5d^3jw!cWC|_^{gx|~_F9g6(4b1%_;F(-g31nv@Yv2QE@EzE5NFPq5-(WrcH=<3MsLy~szjOp5s zp`-SeM-aH1q66}@MydV zMnv0OA3-}2kJP`s&5# zCXF=jWpHL>wBIewkC$4@*0?Kxu*4UFluDX$E0ycnpqp6!7UVCF7Qvp^*VCcA)|0EG zQtS*V zicF!khE6s2OVX-HvS=baiB@EDE#v8n-h)Mepk^uUcnk0NWppwrbMJbE5aGY{4LG-* z-0g-5o!7u>`dzoy$-abPP_|cLP(JTsE}RT&j21rV@~OOEe_y_HE0k)Z@hYLzcC>zq zB_w^xve7GO3Yllsf~O&53bSM4qdYGM=Ui`p_Mm{X^AQ18H2kF?LC)B>8M}eG>@hR( zI!KHE4ph#?ix#D8TqpBlf15=i;j)crvzn9=cBieXF+vD;bhvT{R;=u4Snf8nk<19O zsr=o;;P3Zt#oq2T4h7D_=NBDpefn5MDlln|z?=#22UDo0IzasHHz`WBk?z6?ME)^R zjoq`am_q|n=-E5LIDe4SFeW;P+Z?U=Os-=}aqJF<6<~^V>K{->_q|&1@JQ5iD?+Un zvF2~t?~Bs)6G@ePjo=ikfsEw8cP9o07^!lZbbVb)zjk0`?r?4rP4p16*)R)AX@~Oa zDF6|r4}Tjl{wEcjDMx)^m(+V_VDlVu{DezqX3wWE1;C4z+1oNdrqAb6YX0CUT4mpu z8qhF?s<}z?OA*-F`L6^7=uE+qm-gK%%KkV!J&@e_&0Xys+TLzQ#FbC>CVQEdw=aJ3 z^K(iy>=&wD24b0e{sqy(-&9w;!KI7JGu0zaqclz|PiD8(QiX+u|xE?Lr#W^X|{ z1Q9RGO*Tr!{b{cxKw?*)82B`GpE9?DDRnzamAS=x) zwT>?@D3KA4kM1u}yEsDa*qSe`ZHk@=fPTo0)3#J#;3oNml+S~akvOnD&#Mn7D03x8 z`XVAwK?oT&e1ph(kcwPYhmI~4<%pNrZ5kEYy4GEn$}gcS?ic}n&$8;lp#>T^WLh;Zv*hB{i?qD!v3Fk zeFXUunD09U=gsuu_DP~vOQY!e~lJK(nQI;Y|vZETa`0hqyqW%)S_6bUiX;4AGQJ-Rykg-E?YwZ zA8+7qi?l)_)g3`SxlTE7k*~6U6l3Db8{v7pwQzK?g!9|&A6&7SL#(bWlTLJ9Vbig` zmk>CfG-o2J0GO^4KiC8*wFV*pO9zC@C5R$hI$18 zP!%R2MnP8kDsb^kiu8XZ(euR@d5b}1uiduvn_66Bgq0Cv$-@}|3Q|ZzZTmx9Mc|nE z=;+l03yl=aA9^yTxu#tBaF2?F6NV{oIAN1d#H1|Fo<2$=nASCE2=B8_^uetGcV1s3 zF4LX2`$$bOo{Zv)q;FL|FTan|nP|mtav!l8PfUm77(>{f*U^dvQU2z8;7_eNP_AQd zJ3O%}a{e>R-Z6f@t`l{Q?|Pj@$k#^v)Z2Z_l17d4h*ZGwB-yMWb|+8VJ|OOq@v%*Q z3^huV9gw%d1K3b;g+^QJ zf0_oI_GWjB=-sXFoa^_k0Z zG<^4*U<^HTary@@V?n0Td2hZ3RS*8>}9!46&*jk;4- z-=JGW$Riq{CJ`PZS$NK1?=ycJYXS#V?52j+9EJSMm01~jOEIxY3iHCm@~tLct|~LQ zf&vE+0gf_DkSD6n!h(x4p7EgtKv0%?dx`7S)W_liK`?K3P>tu8DHj~Yo%6pAuG>Hu za+=6y`~BBsrIxE862ut!o;5>?Q*%Xc;pDdn4?YX4ZiNs5&s+$Qm-44DTA2mWbEBJJ z{+mlB;Fu)T%@Om`*H)6dkL`4|`k!c0**1=yy!I%gaEKMCxtT>tDrb?Soc#!w3o7(A zf*X$hM)5@rINwcF=q7JRP@i9^fPuc75|?LEAhW0jEA@XtK7kqj^qS&@_p0ZlTK zsJmbmeZZYs@9{Pn=szAi4MZ?X46f-VF^lMd&kHh5ypN*c5oe6n4?mOvU2aK|05!Lm zio%(I$5xAk5!UR7=%%%w`iEzuuE+-W(^em;hi%5jWblG0UpC;U4@5{v!TVl{S4Sr? zZ*w~rpoKTEY++Jmy%W_!z5Dj?Dt|C3LsWrU*`LaJ>8aU`z6Kq+`Lcp)e}x!kctduL z2W#QuL!$I^&wd~~n?TCvS3h-8w-+{X*5ol%4MchmZ;iDN^ryIryz;>4Fna8al(o;2iFo!d-{NvV2go(4^ z_ecej$S6Ok{;)>A31(ClY+%w!QEw7;XL0qhRj3*_`g&WeRkxQ}WHDwvV(;Ozl47>_ zgRYTvNS#_r`AslUFBLtuV7^j`q?9V=8TcNQ2!FBWcWn=Lxm|r+ft^KtRNd?d_PJez zWId0AJ^WU4a|N>OB#O1(x?K3Ib&3u;G24L!Hy()Hr`<#XaF% z^`oC>Ims5ezy0rJ92~+{99^D`E%W_fW~<4B4BH%;sV^%%DxRnlq|vb^Mm}KWqB)mK zw|g5(j$@6D+}Mqsz^|;V(~+N4l&&lJno(9XAyohE`LJX}fhJ_>qTGBrDGdtOEfJI* zgQvo!5``XvzW)+*2kjY;#M&Q}LZ6zp!>rd|GLxd3&JI%yU}5vJGGVKFiU=0gozdHD z4$HI^6=PNOMa8K*f6m^)+!xDF7HZTR?a-if69KRzmDHTzRHK@s%Rb9O>dRxa{L72S zOdqe$a#5cY72D+H+4<%hg&ed?n}o4J*g0YR$u#WjsC(g&VXc}=x}%s~Sz`tTAQVxr zb@=1A5$2nR`1UYS|GWzbtU_y@2LjnySzOEz_c>bTL%NSb8Q@MTdz}aKh|VngNuyUpy*6FS?W(Vx z`;jHC8j2L$&p!L(I_IYl zj9gw#gsQ;60q3L|Bg?1gP;(tSalb2H_FQ3J5stdLk?Gad71y7|Z50QNvu&$>g1aYu zQV?UpkT5>@_d6nlLL0bTscfi$f|+ufj0jq3nT=j()lq$xxQ!>W?B3bpJT%G3Q7+Wf zgJE&(=1=Wgwok;O0=99Z(mSvD zKNnJS^VKZ4!d3g&L1$ua8h6M8qwqjM%qjiq6KFzm5S+e^mQIk?Tf#7-TZ-C1R)(qc zB&?TL8C;@MD{P*hs@j@9c+Y##zdSvqA5i6P8!5Fvw02Mp2;ONw7%zm0x6YibvOV4^ ze##zLH#;>B%swo{lRBlnw)e@+uJ99XF1%R(suNFM=_30D$_?TjmB@is$G#^@#|N?K zYiJJ_$uNN=C$7vUHnv*igbq($aB&0!S}&FZGz~ z5AJ_z<@h3Yl|XLq0aIh99#A0=`Jkf#!I+EDMm_%#o70N)J~NI#5R-t>$dLk$DqzRh zv~c?CIeZ>c6wgR4ufdPlcVpsI7q@F`Mb%LkrATh;;#jht`2zBpijzs5I!~ zo|pYANWYP zu<*+TZIn$d$@wBQLF#OZyJB5t)e-4N()vhK}&XYh#MkXz&Ilt~gatnNGhoSB{ zD(BN}SEYS}|D-#UbnuKn=tg;g--N z`vnR=R-io|m2{u`yVyWW%`LbG%NXUqJ8tj}ndE&S@qH`h;}f;=SjpM+4r2BEuxHR! zqg_$K<~+a5_s%ct4|MFzb@eOcK0)gYWE%7a8N$WQ`U{jg*f)sU_l{EDA?6(WV=@RU zwUx-w39RRg25>jy03D7)Qb` zj~*+|+d_-g9rq9zxyaI!PV~_Uh|Fm^4zzy>n5Hgc<^NhxKQ)&gNdAoub`1hH2_xw6 zXgRIj#>(m^Ezb@zjT282bZZ$t{?4|b4@=jm6|<}3-N=ukdqCtb;L$ZvZ%^V$-J z!|wJZIeaU-{CsL*JkWJT8T4j$s3xTKHGiY7F0-1c(@$DGo23k~xfuS6)Zi+G+2?GY zVvC))4#)R5x~nWF?{dT!_U~6++~QW;m;2S~xwG2Nj#E<&h$o(0*bVYlTr4_u8(8iE zLlzR{oVh}K&u6=z)UmeIMwOZ}%-zicf_tA_xCP(WU|j8*ZIYRv->6yT(lY^yzDCqk zVplWuHkFvOlUv&a@d6en*iEgaqiEm^=lEdk&a;w68qFC~2$%+A3y*BeUQw~=LRyBtAFEqz!dSnZz zJv=p^U?bIt+-`fOk^X*A8+e@5GL@i51a~6w=ldpW;2!QokF_SY!I~fS_2Ex*4hAfw z>YlOrp>uNt_nvv*R%C^ibap+&U9zuS`C+lug@v^u&nuQQ11;7b&QqR_`(Nd`4q~H! zZjl7`Cq!*`Uw`K_X7|AZ#SjU9fWuyFacsO{XGCK?`Sq!O7`RxMu6Us@WD6e@h9FjU zfu@yY?Z!grS)yycJXziFy9acfBDmKkGX4o)9{&}d)0BNilf|iNa}!@ zd1K3RfYisEW7#!0&tXUV51y>(d1HNKNXl;~3VZIq@ttwWz$qz%4T?5=+7%R8!CUsATReBArp?XG6Tr1WQ=0C{=vU79SQdL!)*Z$!3Ze8BZ zeOePcSWkL#Klr=QVp8!`1$&@x5!}G+ff!IE@*s?z*>?InPLsgMt0Y!!%w^`DCxaYV;%`1E{IvM7S9rZ^Qn5#rE{4&-{XuORvEF1_^-+!;I&&dPZ@F%t`3rc#A!kXIBoctNV&0=xNG52x)g3&67b;|NjW8 zH^i8?em5PWiWXSnaJQed@{mC8#gC96=pnMUyu;cm@-sChai$I6R{ITA*)?{CWWsjN zo|jJLW7lV9*eUNOG4H#=+y^h6rKH(MM~;tcSvfQcz+kC|2cd!h7md}`^s$Fctcjt? zNe+)yv4{Qm6`!+|UW>(d`E~hfr!8d${~m(WVdsa@=;&zH_KTa<*c&P{C)J^)`%842R28f&E($OJ&}4GC!?p%f@T(_U~>inGw&pR66*3l zxW}N;kuAx~D8D7xDbic1ED#%e((V9O?noQ)9|Z-rq0~;_@;$a&EK%1ld({3@GZQ() z3Av&!Zx=3Z^j8^c@`jt|3*2lYLcbA8}>~GFE469H#chC)fJBX2*+tvTezjwB0P9@1o?dYrv zv2nClJ1Yb4zkfhrFtcTil@TQq7i0^#>Z7S`CTa|S9B*r)@8C4EDrM!6Qvdk3-xzNx zQsCvot9x|Nv{eacu&V;HHQ6q&w=qZm-RTg7v@4=uAm8!a+Ezs~!$0MHu?5Y)yi**9 ztVXM~s6GkPDT)cMoUf;6vJ6qehK$lPE}xN?X$Fb__~OZX=YkTQ3{yI|lKF>jk119Y1C1j_%(FSdR8x>}2Z$2y$8v zLib9)!W{l;!((D_A{X@*{xt@WIZ}9T6o}lLKDft4vknr%SbZ<#M-nHZ2PvNczgYtP zk@>e3a*|nCEPQ`5sVJ)9*C8t3DhlZ7Pa+Jv9l<kCj=7rJ^kD^ zB@plaO3-H}4j)VoO~=D`s6!IGHItdTn_3Q;O&(P*8>taST-v4KzK@-`Nv}G6)R{wP z*3)NrE<-rT;&B-=0)j8Py2^bw#VO#^FzpjZlT#&!X47Th}l?I>l?s8$7SE3d{6xJZ7>?k(TXUnpy# z@bN_NxZyG~bd&monm*gF5*LAp5(oT67y~Z)Xt;GCNX%Z0$;_72vqfTd?TR#EZ|)6tu`Av_Db>^B=hAk>kWB#CnEzdb_uk zEV3+J`2{RMf2PeYr{nY1rrvTIC%-KsW7mn3L{OYXxay$dzD{9zPFBY!Dyez5^Isr* z>LZLBJn^vo5%+Rve{eP*pue@zqILgr`)X%HA{%0L8ek_Go16CIF~BUf9ZIfKP{Ecf zwPzDXzZ)hl#xbDC7m(>f!*}a3 zTy41cAmQx3MKi(Mr2<{I$Nu(AcnboETsx>Y9xZsNNujZ!VQ}wcmD{!Pf{+w%Y=k3r z=tZM{H^;+1;nvSFtbBbk{Q!~-z$914uQ~pQ-^fG+m_C!1xS=cjUoXH*Ey)akFD}O% zvQu)OuG)QfJ_zqr3O^R=ZARtY-~SMSO{W3M_US{@L+DjR+)2XC&Cbvg76oy0{bLms zRFJ;1&(KQWwv;UpKbgRp%hhYVkK(ktmku!X<2xQ2090!4F9&?y9~1O&{(U-8)nX)y z)WxDJ2*(U4*DP+&}cx8uXB9(E~hXNReJLAk^&&8jKEVeG>r02{HZ(Ql@IUjv7a<2AxYy8B== zPxmq&)RV|8j03oZp}xtzp9WOyy<|!V+v4!QKnsEG$oYu?kOJqns<$4B5Wa4nW>GvZ zl9vP0dsBONtvUDk(}DSwYKfft3&CCWdsSwQovxSHg4F?%t#kE59i($ZG5l4jz@2ZV z%Qp=egoqG`oU^~1S`*-Me;PT@!2Z zR*bVIW@PV zvutH&;f?(oxyJQi#;JRH?pPCO*X~UXr@;>m1Bz7OA+)keXlx`nSZ|kpcvvcIs`}8{ zy0mt;DqBcB!!yFo17;;;AT;weu6YP~gGnC<@N}1T#q5Id`Q?N=s0@WL2ca|s zU*|ikW~3mN2#540t{>h~IN-PoFJKBt@8z-+UQg16`FrczKTl#3Myc$a?J|VgxtRuE zdUni}_9R9ib*0P^w0N(33ts&gBW&^<>I0%iXxN_VT`flSXT7&6?A$zctUNp;&Ra>H zI)i8J`3ClK`?%E9!5>^M?^KS>Lt{0a;jxnvFDNH_J>l$rn#5|1lJIj0Fno~V*jq&x zPy#Xn8%2Y`HsX@ag%_t%TCjrT$D7rMJxP?rsAf;5U)BJ&lk=|_ujt%$mg=}@DlSm{ z#ZXRQfms~`gdtWvi;3-Tg-{ev8Mw(H4a%*1L(N4S#kj$h`Y}kFSB~p>V&ekd}8$)+IqeprK#vpnhH=@&NOXMbe(6)Yc{8Pa=JG zTPgN!%+u6nRyDM*0utKMsy+OQS2T+SwzLu|d90Ux6mnN!o2wDx9Dx7O2=LP_DoXw4 zt1rJTj!oB(l!t0USkEQHkyC;e$iy_Unwt{8(gh!8ky;$TA-(Z0t=COpU_>v|IWcuQ?#?3fb)vn19Pzsi>8!Wd zCCdcx$O(%oqc=oOk@C3?pZQwnmTRd%3Mu0-hF#nqkmA3G2rD7nB4xT2U80Qqnm1}dPfwS2MZfU=*i|)jWAM|c^Nb!SA@y-s1 z^lnDezs!1K#-Kbj>=ZOj;UAm;egNqvMo0Mk942GiQ zF-5~9IO87ZPy<=2g&ZmUjWx$RxN|&s;)Czg&F-f(Vj)Y&WzX;z)O&d`X$aPaI7n&z z3u@>ol{|g#3>ZO`KSEyBieEqmO#Qu?&dlGdNbSfNh_dq#ww$62Ow&@oLueraNgCqd zBSUKb8TUMtk&5ODEmNKV#75^jV%|@<%K*EsP8xBo`sH~l1KBx8GXc_1IM4lt9b-%A z`7uFn-v@T&*@Va-=4isn6sb|LGUy6>l47ak1b~WOFPX(qdt|&UL0{#ZyPNd(%%JE5 zNFa%3G`t#X3?bfGRL~K+oiA}VrXL=QmeZ@e!XYGUpMyVef)^{6Yh&+2*<;xTJzzU1 zZK9dIc_^wT&KPz%E?mz@?|D6=WwFc0x86Kn^Ln9ie*GOz=5PFoK5P$a|C+nHUh_xi zGe?+dEJW0t{^lc=&p&C-0cQpDqXD!yuu^ix=^S5~Ktl6#Q_LQ){=b(~0WMMH>y6UG zbghcELPEMch!!X!%-4oq6~7n9PxbSXik{@4IA ziiZ6+Boj7uc|eo%1~Wci@q-;QR~}1k3M;Zn?3L>qnudK~FMpl?#vJXwG7w>g){T_L zZQi%Yy*J+{c#r=txBRczQa39tXD8v_Jnn?1E&7|tJKnN9XnAgp`Rrw2e#j5DFBBPo zf1;R^WthD z(pI&HqtDB1a`I?;0ta|RxjO8RwlQ)30_Usw?^5b9_>l_jroVc1X^$BQ4{gsgiG{c8 zCF2J4h-A;}6n>c%QrCR~y0U~efSqA5ca}bJRU4d^GhB_KR{=*Eq57ELjy1nr0s8CK zI7zmlV87|5)3??MO**Hm z)HVPQ*akZZXJt#}dCY2m?Az6)b>$uVI_lLA_|n0?_dRKCR4maz)k_Q-rW2?ku|1~b zbGkr#_L$4~@-`dX-C{{hLgYdXX!6vgIl_vA0FCFXHM-S;}tZu8He^avgGwOOBr~COiV3n2E(iX&A8NfBUn?^Y^n`ngH{nxXcRUqXv*Efd0=zQv-p`;tu*W znU#m?&!a&IiI>A45S-0n-&HFI4M{!RN7w-QEIVj2Jh!##tv~!UviJV{rPEzm-_ukn zR8gVx0)mfYb!MAP3Ka00+C4Iln;SI>McIHd4eU&p!{DJ)|3^!@S@OhHc#tsqI1P8t zf(p7Wgo;s`hUJ+38{ho>sLQgEc3n&J?oqDa({SsA*(=4xwC~F`(_{>#`Nj5f!`sEe z(yfVWgZHbA0P%V(zEb2avpl-+P2{04pZk1i2NiPjHF(ZWkLG_0`x0;{_xJtJ41;iN z$&58yl4Tf`EK`l0!%?InBN{DQC=H5J86+}eOQE8ym5ziY(y@f0vbT^n>kLPmrM>t+ z@2Kq6swKkxfK&;8u@{XCzU&2^#q^w4J_uob`}OfDAA|6APa{BB}e zQrax(aiqrNs}20VmCsI1nH43uIlS5X=tz=y@KI~krC*+3Qf5w7**9=2l;$?QI!YPu z+^eEHYAzX^J)w5_$vctrHQG%fDb>#Xl?9)tAMqQ?$i3$};SU!^rf;itIhvieW_e>RDTw~)?~c&qv>Bp%Tleyd4v)Y3Z0I#_ zEL%Blw7nk|*!|t&dXTq?h5g^J)*VVbSJ&YbSoH1Pf*#_-io)uNHYu6}beDe2+=Ta9 zAMQ-NKkU0khkZl;jrg}6F=VOE2l~>|_u=dJXXiFO4Sg8#G<-PXY2~fWmGl?h@wXOj z*3mVn{eP3~u4z{anfGN^15$Q~&X2gvXjsj~zrt z$FFB!x&2~Q;%IDnR>W?)>)$KLnwo*C*UCr#QG0s(*1E5U4n5#zzbFspZ`pn5j&mvf zYHQQ`I~=LD`>plGlG&N*(@QVy2(Fbc$Tu=dA5Fb}Atg#{!&L4%Uwu`d!1l!zNi}P4 zZk$kVA zB`$h#O@&ALw8Q1?@5de~M?UH4*PJjtbPv|k)B9@4L}iH6yG!KpiMpA(y$26>-#Y_B z*#um}4O$WhgZEiovM27?v(Jhv!r#saa81v+vG^aUHI|zBG0drxk-_;hL1S&NnI(}E zI$pPmHb52K`BBlXL0GK6XH6J>jaNi%dK{IXn;6e7T$8|1@5G52&gpS9*&9co2lX`g z;a8Wn`R*mH{*hrWNmTRV%4db})5!ZfE>fMn-^2HMvqL_Fwf4UZ^Lp5;@nXWF!2gf) zAz`X{=tSkIlio-2C>Cx8n15U z@#@m*)jef8HP6=Sk0wyRUUnEAdq_0-#(Aupp=V-Qt$QPF|I5_$Ubg5XpYGc6lXhRY zb2Yxleq4N^Tl4YSgAuJmKE3=(6wfGKvDFif-xzuV`faKm#EP;BwfzEGIWb;W0mW|w$o>fc*6H;|(e8BR?bKJ=-Oz4AI=cHDRz?b9OKC=XlH zt1hrMaOPSOp+3v7qj*N=<;JV=)GxpX-!E)@s#5oLSf+l;-K%!GC2sVlWJ09`v+zvh zf^C!oLK{h7w`%HDr1Ese9a;prMvK#kYKoE(&jGPw)TIB~wFA+DWtig!YIE9qGzm}3LbC5MksoWS ziiR`Z^*~~W`K(JlzkH+Zh46ubJ74oloAbLeLyB`Wr^Z&f-phYww&V<2x_5V(f#}7> zfwMG}BD>d@n_~{8aA#2!Pl->_1FopWyW9E?)g7%E_)@pVPy!c3DDkj8_7N`iOk4*c zV3EP$!Bnd+FE6U37sm3!PR_oz+_gQ(BL7)?c1{%%l*-0}XYS;W2Wq>hCCe))vqGf_ zc)#Dh?kMgFW4Nh(iHRw{Q}tDI9eBiRf21{r?*F#Kcxt@Wh&%Gv#Yj7DN9VPF-qnG` z37>8G@>*;MR!EOIUo1S^?~-=Rmt$BeNT(d4bN_T zBb=q^d-~n6*^TB3Q=1r>CKhH(eSdua3&WO3$6TG3@;pD%VGJ3?C_-RSyv(COQ;W86PDT96E zL=o4yA4SRULW0V!4i|iu!1ZT_Q*QGnH&5oCO_cCj-16CC>7&Wj-@h$yC!Uzi_EK9r zp~||%Em0Cp2x_~xj2K6%iB|RgVi9Lid+<#jR$TA!PUJoIsMz~L?fgfxn>t2C$*Dtq zyOYyCDhxDLYX68;NX5*J$)nFkJ|@38_41FE3DiZY4K`_0L*Rnd?@dd*wd8|l;YZ)n z$iyBst-)7b6VekZmK)dp9&WhjNBCUQ&4Uw`w_20-ek<5@H12e|zKMyke#=(HjK(_B znVy5A3H}9~7l_?&aGlvdTyVQX)2hTR#s5+2kwm4nlx-b>(=Av2%-Rx}nZn+j#*9(H zs;Hjil0?g6-xxg?S8XL-{j|ULExVzFnLt_fg|r~@!VX_~r_jV6I)CHM!YA)ekUHIy z<}y~SG5YvtHk0I?c5Ibr#~&6+Z~L}#QpLsweog1k0SxcaD7d{ASR)54R@N%toI!C+LkP zWLX+7<4d`e=sj&tVn(-_x$dG0lll*fsFe@>nRVHprl=9e`P|VQc0eun+7p!*&CawG z(VepOXGm4KiAKFSzZfrkn@AN^mjFvu+;VkJd!ukGHoe1;0>2Cyvu=9o$-m2O|9a#k zK4;0u(wuGb`KgO0gjNj=M??)(jh80UB?DjImX0yE@FNmvqM67_S!fkBpO{#)AcdU^ z$3`M%q&dTOH+p31RiFoW9a<}A_`7W#%akGIrBTU}SLyWeJI|-@D|(aOAN~43cDI2z zizTZaf=R@7n>H+U8M}#ugedG-BgV7m^^gfJ+En^k?AZb<qAdo8k=vn5s;JnH1UHA9!*&nLde$EF)WT&cp{d zQrt+MF7&bp^U7yN?>0V1RQqeyfz`Q*RumZ^(02iEFSHQ8O0WV_*xTqSc)#X`7+YDQ zoQDPVFtorhTXPsMI0q&^y?eG48(ShTjGX^5gKcX=|u{ldRL{e94gr(QU&TVfzvg{#Da4cl;yl!_G z*s~iz;>ph;RD)H!%otN==8QJ>fZ_1q$alDx=8#D8U>)1XLO6^J8Ji`eyg{>zlw|8o zedA}P2=z)-ZQY;pPK$?zGnr60VK@yACGA@h9^kL0i!RkaOG;fN5`Kbcq0rEzy!3Af$AW90JsGmwr89VAh z7UtQx-HC$}xJO{%PqtTr8NGP+1VOxd5!R9&kdru#tu$pV&#hIw3^Qw(PXdd)P(=uS zy`(`NmKR9Gc=qz$K0@GYvYv=@b#vqGuAVQ}l;hZS8z5;XdE=PbXpXU$BzQ!DZTYM{ zf(koy_d}Z~p`LSCEC9P`Pg^Txn8%YmU_9%6F5ft8kx;KxBcc$q)TeC3ER44u_8ndu6m(uLx-tdOGpZe_j9y9mBz!sEZ z$#V-XThWnEiZ}O^+X&Mw{AS%Fn%zQzAWM)C1eqth&}MP0DWim>Czpd-r3mVT+Y%;2 z)$#QOLXhd(TlSMHkHZL0@$TWF@U4>Dm)%fhNj~r5-eO}-bF?CQ5)Us6m@ZWShvRDi z#j~a(wu(SP9*XDa+GVCBjbo5YUR%b(u_+ek-lsq3Tsc0iGGG$L9+wKdKn$57qHcAf z=wP^u%}VF8ScW=}wTE-W6|V_@XrU^F%&yU>B&Q4>Sn*}nb-@MAQn6$ z_aU}NPL~T2IgLT9DQd`^XOQiH0uqLg@WNH10-F>n%4)>0b5*8PS6xMpG@+HobGWO< zTLoQbwOabGHIf-gbV*T{cgQx3uI(lY?)I-m3g8W?N0#vz;;aP5Z<^{#^1UoQj?cVT zWYh~CU4d&ve|_|CN1V*&(#749_Vd45)mf8Spn(xK=A5vdqRtrEBj8E4H#VI)h7;gj zU+6o(Dl|aTA<#tLLi~4eUDxF(0g7Vtl+ehZ;+qqfJ4`03vXG2i3`6h2QW{_#Bq{|H zD%d0yS$wlvzgVv>+8Wg$CWs0d0o+4x7@Qo`CC1YgYqrI8H4BR&O$W05rpzh@V8$+M zgsyu1ldmfRtE_%wOl~nxIEhz>rN#8h5_M#o{ffp0GmRMa3|WBh2h3-<&x`)RrD(&X+y5Tyh{0FyEK6cJrV;AJJ7tl}aJ6%@-?+u{ z>UfD*seh48mRYKEPL(CrFnd3H{$LQWiPlCH0C(*@8G8{DQjwIS)JF{3yWJ$Q>fEUEx9RS#0D&HW%y!~oXjh_wnLlIz;}I2VmcV&zF7<_{#8AM$8z zhH-8{V78?HUK#oPiF|Ze@suvynO}oD8W38|_+}er9NI(Y_*~Kd1Z6{g>b*Va?PgtU z2*sK#g6Q1lzSt|q4gkhj5LIb^!u!cZi+K%(zUgwShwk2D0G5M6*Vc^{ ztV{3gPqe`2K^7(Fk*xWQFl5geQmP0i$zy{Yf^~m>2S=1Khcay6Hp<5P;kw6j5}S3Q zqNek7vGXK?DV{!+C7NXvz=oG05d~&xy^c(Uo+=ct1eh>;OR_H*h5+~xtY~7HHwTyQ<#-l*67RNct7M?slevsr zl+RK$>7^(*m{R>7+x(&e$?+cwHN^Nk{)%Ti(+#$qoO$erJFozpfGEvSu2ywm9UKsr&NT;3p`rV+-7^+`*$fh2xUxD$*`3K zAcnOE8!w}Cic>oA_rBbCH0OzsRG*Wrl?Ro#1!>3kXyn=`Q#OMkL!m%fB`Fb4c`DVc zmuz$U$?=YzRAuQ8Kt1zg#n9Wp5zY*;IUmO-cLU{^T#RwNK3klOjCY#dyutqOMuS** ztf`$cH&%(W((x~_!Xpxb>d%{zh#1Admfhzw&o=wZTcK_X|5LVh&PrVJ`Fv~(U3J5T zt|{e178k>VEX9F!Hy9tVIX{6@g`GPw!BJ%r{h9f&6l^0!(~^&w`A7^&DMY~K8h%_L z^Lp}M@WTdUY|9n$(G?bCwc3RjLpTWO5D5rrO8MQ4y+339gRM6Tk+r?lA8?g|);kL3 z8_GzrkQ*(;qMySAbc&rf60S(KZ5#lXjY3p>^2VwQ<6btK=q(EVN-S2i0`gh9xvI%^ z9VD=TolG;Z8v|Gcu5#E5oAa2ZRAs{J!->^5YyTF!4ex;&6X1qv5aDW$MZqi&*7;a( zjgu*&9EfWO=ST{z8j!0tgE4^IL=}7=7YJy23qhQPQZ$@}SOQkE3nHoqGuLJ?pkOIU za^~bw_WLYb!<*TBGRAC56M;sg^E3n0>>km8UY+yXz3&5+9B{!woE7@s&l4HfTaiCf z$64#rbzbO-h<_S4Cd>acPDU6jV(@+i2EW;aZD56&8!ZRr(mK?0{bHN_@*hm-=_6J1rKtU+ZKcqyAIW}b#nwpU1e-bZL|4&82 zXV)?K`-{+#I#<}N3z=8d(V9?r1bh-%+Cmb)(a)uE6z*M4pNeDx#)Jw4hxyk~Uk3f) z6Y(X}SK;kuREhm04i?6ftfK+>Fe9dRkOH~k|2n$To<9Z~&ulmLh?sC$NyYC+u~@av zeaAhH2`>!vh@Ogs`a5Q=>t4^WOm%4gg{;sXJ82$4IgpKeNKNvA!SYFxE$xY}y$w)c zui(FJ8iF6<1`f`vuZrpm4vI9KOzWwb?@jf;1wHuSrId#-}@PR;Fkw0YYU=GJAV+~pTMh| zj7CtzGcv2J&&Qqo>n9`g?^y5%6hM@oof>E=`_@M5PJp=#&7~0|F<3+OyXY_O)kL@O zI-2@Bu9P+w0=#{`)?td4NxoEvXtM^Rj86Y8`2g4M$Ni!V(@*V?!&o@h%Y_6ro}Fij zH%TS7IAee9hybY3K%WoPbnH6Op@mj70>Y+G1Pha8nXQu5B&ljLlB>dsxVOM`bZx<; zVz&ce`cGQiQ{3!MMEX)MwVMF79A^ZCO5`@-(Nk#)OyX^k?5DP|VNPh1J}>$W(#{#a z3lC^SrrwlGfVLlopBycs&~N^4ql*u#@_k<6Rg|w=ohHgzRQ&g8K-hxae%=AH)l)j4 zps`+642x9kYKQL>PRBFeKlk;^8a{&YK<*rOQ7v+e29ZqJRg|bb!!TaNgciMCRmZYa zHctHzR`ItOkYkOIPT5TPLb;<@l%bc#VOGk(0H#y;*i^0t@5uc^80v(0hR2OPXk}1l zIxku!eXwkSez^4}5IZZeT&X%$ z`1UeMdysu1E|6TNKc+UmbZHSGSfjH+{MId$-}gC8wx25fJiR}t$Plgf z7#R{+0yjO?vmCZ-@22OV_!F4g@f#2kB!s|E5bXcp#$qnS>Jf7Q?6&gLT@R;>5jK}* zGHgcl9twAfS;vtG9*n@dDk{w_LrM;LaUdIjtwS$9KVHx+EHuT8`^M0ZY@pKt?*YsN zZA6_HFclOx9gCR(90~0156FU~$8?w1xvEQGh8TkcTES`7EoT(vuo>zX33sV_pk0pp zT|$ns@ae(WIsRuyGCuJv#{QYsyP%q2n~0fNogj#~pOcGAYs4I*-bQn_XR~2P@;t^z*PxOilJy!w1{)q2-+0J;BnafVcFLD*dZ{N)?iZ?gFZ?+lUGh zR7Ci){h3H(dD65ZND&auv3+>=1JpxNnC(Hm*m6mNbzyF@T0K{Y_78xI&oOU~D@37ZIHWKkiv&R14m=L$YwCo*Y;$ zT@rOOVKhvDY8;4>*3>bq-D46wulc`U-Z@6qB$s?6(96Lkvx~$fB;x&SsnYfVSpH7alHl_H%xgIxyc*m5iWy*CJ9j5Zc@8eYL|46Y zsk$0)h+kj@AvXm+l>p^7Y`eV6EY=W3WFoIU`TugP|KX8=g_XY&UZpb_^}1F3r#Sse zs3@Z?0OtvP@mCu?zvurl$y(0=#TLfwGANsi!~`}Ohm*o?%sDI_c*JRehZa#wej$O@ z6zc>QE=hhIvJr4p;I69q=3TlAgZEP$fS2`+kZYtaTs`8>d0 zFXwoic0yH^n*6v?Yy8A9Tn1?lXe^#Ko9WQp-1DL0Q99KP?TZGTh>1`;d1#)YdL%kr zS75W`+xbwxMyRq8{nMj+PC5? zZQX!r878%c&=3B0JB+YEv6;85yx(kT^8s@Z1InMC7)C_|F>Ijv6;rd#r9GiRS77{Z zZUs1lQT{RgYfVUdKJCk(R;=k z0HInBCT$aDSSf^HcH6&J7G}HbrAP->TuC9&q#bAe>r@I6zZ`Zbx&u`?o6z?4!uzyx zZO-#E{au8xIBuIZ!#@K995i5*Dl22KeSMdGp6I^^=aXS?t5NTKt<%zmsw#Cd{6VV&+lB!Qi(98W8jt*_^}$ zG6IW$LHQ>K93%K&dXq{uslyF%G7GiuH{Apnz|@;K9(fi7ZqP7;c{y=yA+!Zrz27 zPl5{1OW<5F&Jp5LFv@Sf2jellyfin-accounts \ No newline at end of file + +( ) diff --git a/images/src/banner-jakarta.svg b/images/src/banner-jakarta.svg new file mode 100644 index 0000000..a983a47 --- /dev/null +++ b/images/src/banner-jakarta.svg @@ -0,0 +1,348 @@ + +image/svg+xml + + + + + + + + + + + + + + + + + + ( ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/src/jfa-go-social-jakarta.svg b/images/src/jfa-go-social-jakarta.svg new file mode 100644 index 0000000..5108c74 --- /dev/null +++ b/images/src/jfa-go-social-jakarta.svg @@ -0,0 +1,668 @@ + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + ( ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 0fe574fbd967033d8e8e461edcfa2035b769c930 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 1 Sep 2025 19:18:48 +0100 Subject: [PATCH 15/90] discord: clarify "Invite channel" setting mention it's the name you put there, not the ID. --- config/config-base.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config-base.yaml b/config/config-base.yaml index aef4bb5..5f879a7 100644 --- a/config/config-base.yaml +++ b/config/config-base.yaml @@ -924,7 +924,7 @@ sections: requires_restart: true depends_true: provide_invite type: text - description: Channel to invite new users to. + description: Name of channel to invite new users to. - setting: apply_role name: Apply Role on connection requires_restart: true From 6ebc7d18bfef64506f12e5760796362bc5b54037 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 1 Sep 2025 20:43:34 +0100 Subject: [PATCH 16/90] accounts: fix bool queries on (some) string fields wasn't implemented for things like email on the server side. also changed text mail variant's footers to all use {{ .footer }} like I should have before. --- mail/confirmation.txt | 2 +- mail/created.txt | 2 +- mail/deleted.txt | 2 +- mail/expired.txt | 2 +- mail/expiry-adjusted.txt | 2 +- mail/expiry-reminder.txt | 2 +- mail/invite-email.txt | 2 +- mail/password-reset.txt | 2 +- mail/template.txt | 2 +- mail/user-expired.txt | 2 +- mail/welcome.txt | 2 +- usercache.go | 70 ++++++++++++++++++++++++++++++++++------ 12 files changed, 71 insertions(+), 21 deletions(-) diff --git a/mail/confirmation.txt b/mail/confirmation.txt index cccc813..6ea4631 100644 --- a/mail/confirmation.txt +++ b/mail/confirmation.txt @@ -5,4 +5,4 @@ {{ .confirmationURL }} -{{ .message }} +{{ .footer }} diff --git a/mail/created.txt b/mail/created.txt index 2b79071..2b16e44 100644 --- a/mail/created.txt +++ b/mail/created.txt @@ -6,4 +6,4 @@ {{ .timeString }}: {{ .time }} -{{ .notificationNotice }} +{{ .footer }} diff --git a/mail/deleted.txt b/mail/deleted.txt index e003e52..349041a 100644 --- a/mail/deleted.txt +++ b/mail/deleted.txt @@ -4,4 +4,4 @@ {{ .reasonString }}: {{ .reason }} -{{ .message }} +{{ .footer }} diff --git a/mail/expired.txt b/mail/expired.txt index 892892c..364b7a9 100644 --- a/mail/expired.txt +++ b/mail/expired.txt @@ -2,4 +2,4 @@ {{ .expiredAt }} -{{ .notificationNotice }} +{{ .footer }} diff --git a/mail/expiry-adjusted.txt b/mail/expiry-adjusted.txt index 28b5f69..4a59919 100644 --- a/mail/expiry-adjusted.txt +++ b/mail/expiry-adjusted.txt @@ -8,4 +8,4 @@ {{ .reasonString }}: {{ .reason }} -{{ .message }} +{{ .footer }} diff --git a/mail/expiry-reminder.txt b/mail/expiry-reminder.txt index 0bbae39..06d08e2 100644 --- a/mail/expiry-reminder.txt +++ b/mail/expiry-reminder.txt @@ -2,4 +2,4 @@ {{ .yourAccountIsDueToExpire }} -{{ .message }} +{{ .footer }} diff --git a/mail/invite-email.txt b/mail/invite-email.txt index c39f4c3..3aed685 100644 --- a/mail/invite-email.txt +++ b/mail/invite-email.txt @@ -5,4 +5,4 @@ {{ .inviteURL }} -{{ .message }} +{{ .footer }} diff --git a/mail/password-reset.txt b/mail/password-reset.txt index 0e4c090..ffb3eb2 100644 --- a/mail/password-reset.txt +++ b/mail/password-reset.txt @@ -10,4 +10,4 @@ {{ .pinString }}: {{ .pin }} -{{ .message }} +{{ .footer }} diff --git a/mail/template.txt b/mail/template.txt index d6af7d2..54a67cf 100644 --- a/mail/template.txt +++ b/mail/template.txt @@ -1,3 +1,3 @@ {{ .plaintext }} -{{ .message }} +{{ .footer }} diff --git a/mail/user-expired.txt b/mail/user-expired.txt index bfd2dcc..649018c 100644 --- a/mail/user-expired.txt +++ b/mail/user-expired.txt @@ -2,4 +2,4 @@ {{ .contactTheAdmin }} -{{ .message }} +{{ .footer }} diff --git a/mail/welcome.txt b/mail/welcome.txt index 9344210..190293a 100644 --- a/mail/welcome.txt +++ b/mail/welcome.txt @@ -8,4 +8,4 @@ {{ .yourAccountWillExpire }} -{{ .message }} +{{ .footer }} diff --git a/usercache.go b/usercache.go index ca5f8d5..2988fe5 100644 --- a/usercache.go +++ b/usercache.go @@ -345,8 +345,18 @@ func (q QueryDTO) AsFilter() Filter { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(q.Value.(string))) == int(operator) } case "email": - return func(a *respUser) bool { - return cmp.Compare(strings.ToLower(a.Email), strings.ToLower(q.Value.(string))) == int(operator) + switch q.Class { + case BoolQuery: + return func(a *respUser) bool { + if q.Value.(bool) { + return a.Email != "" + } + return a.Email == "" + } + case StringQuery: + return func(a *respUser) bool { + return cmp.Compare(strings.ToLower(a.Email), strings.ToLower(q.Value.(string))) == int(operator) + } } case "notify_email": return func(a *respUser) bool { @@ -391,16 +401,36 @@ func (q QueryDTO) AsFilter() Filter { return cmp.Compare(bool2int(a.Disabled), bool2int(q.Value.(bool))) == int(operator) } case "telegram": - return func(a *respUser) bool { - return cmp.Compare(strings.ToLower(a.Telegram), strings.ToLower(q.Value.(string))) == int(operator) + switch q.Class { + case BoolQuery: + return func(a *respUser) bool { + if q.Value.(bool) { + return a.Telegram != "" + } + return a.Telegram == "" + } + case StringQuery: + return func(a *respUser) bool { + return cmp.Compare(strings.ToLower(a.Telegram), strings.ToLower(q.Value.(string))) == int(operator) + } } case "notify_telegram": return func(a *respUser) bool { return cmp.Compare(bool2int(a.NotifyThroughTelegram), bool2int(q.Value.(bool))) == int(operator) } case "discord": - return func(a *respUser) bool { - return cmp.Compare(strings.ToLower(a.Discord), strings.ToLower(q.Value.(string))) == int(operator) + switch q.Class { + case BoolQuery: + return func(a *respUser) bool { + if q.Value.(bool) { + return a.Discord != "" + } + return a.Discord == "" + } + case StringQuery: + return func(a *respUser) bool { + return cmp.Compare(strings.ToLower(a.Discord), strings.ToLower(q.Value.(string))) == int(operator) + } } case "discord_id": return func(a *respUser) bool { @@ -411,16 +441,36 @@ func (q QueryDTO) AsFilter() Filter { return cmp.Compare(bool2int(a.NotifyThroughDiscord), bool2int(q.Value.(bool))) == int(operator) } case "matrix": - return func(a *respUser) bool { - return cmp.Compare(strings.ToLower(a.Matrix), strings.ToLower(q.Value.(string))) == int(operator) + switch q.Class { + case BoolQuery: + return func(a *respUser) bool { + if q.Value.(bool) { + return a.Matrix != "" + } + return a.Matrix == "" + } + case StringQuery: + return func(a *respUser) bool { + return cmp.Compare(strings.ToLower(a.Matrix), strings.ToLower(q.Value.(string))) == int(operator) + } } case "notify_matrix": return func(a *respUser) bool { return cmp.Compare(bool2int(a.NotifyThroughMatrix), bool2int(q.Value.(bool))) == int(operator) } case "label": - return func(a *respUser) bool { - return cmp.Compare(strings.ToLower(a.Label), strings.ToLower(q.Value.(string))) == int(operator) + switch q.Class { + case BoolQuery: + return func(a *respUser) bool { + if q.Value.(bool) { + return a.Label != "" + } + return a.Label == "" + } + case StringQuery: + return func(a *respUser) bool { + return cmp.Compare(strings.ToLower(a.Label), strings.ToLower(q.Value.(string))) == int(operator) + } } case "accounts_admin": return func(a *respUser) bool { From d88194b9bd2fb69c45d0e26cb2501c6b5c10575f Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 1 Sep 2025 21:28:56 +0100 Subject: [PATCH 17/90] accounts: invalidate user cache in more/all places using app.userSummary as a source of relevant storage places and things to look for. --- api-invites.go | 1 + api-messages.go | 7 +++++++ api-userpage.go | 1 + api-users.go | 7 +++++++ user-d.go | 2 ++ users.go | 1 + 6 files changed, 19 insertions(+) diff --git a/api-invites.go b/api-invites.go index 4a47d28..feb54cb 100644 --- a/api-invites.go +++ b/api-invites.go @@ -104,6 +104,7 @@ func (app *appContext) deleteExpiredInvite(data Invite) { if ok { user.ReferralTemplateKey = "" app.storage.SetEmailsKey(data.ReferrerJellyfinID, user) + app.InvalidateWebUserCache() } } wait := app.sendAdminExpiryNotification(data) diff --git a/api-messages.go b/api-messages.go index c3378d1..5be2af3 100644 --- a/api-messages.go +++ b/api-messages.go @@ -271,6 +271,7 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) { } linkExistingOmbiDiscordTelegram(app) + app.InvalidateWebUserCache() respondBool(200, true, gc) } @@ -336,6 +337,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err) } } + app.InvalidateWebUserCache() respondBool(200, true, gc) } @@ -564,6 +566,7 @@ func (app *appContext) MatrixConnect(gc *gin.Context) { Lang: "en-us", Contact: true, }) + app.InvalidateWebUserCache() respondBool(200, true, gc) } @@ -635,6 +638,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) { }, gc, false) linkExistingOmbiDiscordTelegram(app) + app.InvalidateWebUserCache() respondBool(200, true, gc) } @@ -672,6 +676,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) { Time: time.Now(), }, gc, false) + app.InvalidateWebUserCache() respondBool(200, true, gc) } @@ -708,6 +713,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) { Time: time.Now(), }, gc, false) + app.InvalidateWebUserCache() respondBool(200, true, gc) } @@ -737,5 +743,6 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) { Time: time.Now(), }, gc, false) + app.InvalidateWebUserCache() respondBool(200, true, gc) } diff --git a/api-userpage.go b/api-userpage.go index dbdc525..10da341 100644 --- a/api-userpage.go +++ b/api-userpage.go @@ -796,6 +796,7 @@ func (app *appContext) GetMyReferral(gc *gin.Context) { inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour) app.storage.SetInvitesKey(inv.Code, inv) } + app.InvalidateWebUserCache() gc.JSON(200, GetMyReferralRespDTO{ Code: inv.Code, RemainingUses: inv.RemainingUses, diff --git a/api-users.go b/api-users.go index dc89f23..2b3f894 100644 --- a/api-users.go +++ b/api-users.go @@ -625,6 +625,7 @@ func (app *appContext) EnableReferralForUsers(gc *gin.Context) { inv.UseReferralExpiry = useExpiry app.storage.SetInvitesKey(inv.Code, inv) } + app.InvalidateWebUserCache() } // @Summary Disable referrals for the given user(s). @@ -648,6 +649,7 @@ func (app *appContext) DisableReferralForUsers(gc *gin.Context) { user.ReferralTemplateKey = "" app.storage.SetEmailsKey(u, user) } + app.InvalidateWebUserCache() respondBool(200, true, gc) } @@ -848,6 +850,8 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) { respondBool(204, true, gc) } +// userSummary generates a respUser for to be displayed to the user, or sorted/filtered. +// also, consider it a source of which data fields/struct modifications need to trigger a cache invalidation. func (app *appContext) userSummary(jfUser mediabrowser.User) respUser { adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true) allowAll := app.config.Section("ui").Key("allow_all").MustBool(false) @@ -1016,6 +1020,7 @@ func (app *appContext) SetAccountsAdmin(gc *gin.Context) { app.info.Printf(lm.UserAdminAdjusted, id, admin) } } + app.InvalidateWebUserCache() respondBool(204, true, gc) } @@ -1048,6 +1053,7 @@ func (app *appContext) ModifyLabels(gc *gin.Context) { app.storage.SetEmailsKey(id, emailStore) } } + app.InvalidateWebUserCache() respondBool(204, true, gc) } @@ -1087,6 +1093,7 @@ func (app *appContext) modifyEmail(jfID string, addr string) { } } } + app.InvalidateWebUserCache() } // @Summary Modify user's email addresses. diff --git a/user-d.go b/user-d.go index d4aca9c..c1fd102 100644 --- a/user-d.go +++ b/user-d.go @@ -131,6 +131,7 @@ func (app *appContext) checkUsers(remindBeforeExpiry *DayTimerSet) { activity.Type = ActivityDeletion // Store the user name, since there's no longer a user ID to reference back to activity.Value = user.Name + app.InvalidateUserCaches() } else { app.info.Printf(lm.DisableExpiredUser, user.Name) // Admins can't be disabled @@ -138,6 +139,7 @@ func (app *appContext) checkUsers(remindBeforeExpiry *DayTimerSet) { user.Policy.IsAdministrator = false err, _, _ = app.SetUserDisabled(user, true) activity.Type = ActivityDisabled + app.InvalidateUserCaches() } if err != nil { app.err.Printf(lm.FailedDeleteOrDisableExpiredUser, user.ID, err) diff --git a/users.go b/users.go index 4fa9822..e641ba4 100644 --- a/users.go +++ b/users.go @@ -155,6 +155,7 @@ func (app *appContext) NewUserPostVerification(p NewUserParams) (out NewUserData out.Status = 200 out.Success = true + app.InvalidateWebUserCache() return } From 465ed9f84f3b6249d21f716364ca3ac716996e0b Mon Sep 17 00:00:00 2001 From: Max Kieltyka Date: Thu, 25 Sep 2025 20:28:02 +0200 Subject: [PATCH 18/90] adjust template key --- html/setup.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/html/setup.html b/html/setup.html index 9c8839e..67d488c 100644 --- a/html/setup.html +++ b/html/setup.html @@ -12,7 +12,7 @@ {{ template "lang-select.html" . }} - +

jfa-go {{ .lang.StartPage.welcome }} @@ -106,7 +106,7 @@

{{ .lang.General.urlBaseNotice }}

From 6a8b21c5f21d7c641699c0b23eac015f72ece4b2 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 21 Oct 2025 17:15:00 +0100 Subject: [PATCH 19/90] mention 10.11.0 compatibility seems to work, someone opened an issue but closed it right after also. Release notes don't say anything alarming either. --- README.md | 2 +- api-users.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bfd678a..105a7bb 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Studies mean I can't work on this project a lot outside of breaks, however I hope i'll be able to fit in general support and things like bug fixes into my time. New features and such will likely come in short bursts throughout the year (if they do at all). #### Does/Will it still work? -jfa-go currently works on Jellyfin 10.9.8, the latest version as of 31/07/2024. I should be able to maintain compatability in the future, unless any big changes occur. +jfa-go currently works on Jellyfin 10.11.0, the latest version as of 21/10/25. I should be able to maintain compatability in the future, unless any big changes occur. #### Alternatives If you want a bit more of a guarantee of support, I've seen these projects mentioned although haven't tried them myself. diff --git a/api-users.go b/api-users.go index 2b3f894..899ba18 100644 --- a/api-users.go +++ b/api-users.go @@ -1304,5 +1304,6 @@ func (app *appContext) ApplySettings(gc *gin.Context) { if len(errors["policy"]) == len(req.ApplyTo) || len(errors["homescreen"]) == len(req.ApplyTo) { code = 500 } + app.InvalidateUserCaches() gc.JSON(code, errors) } From 60ccc51232ea06d5ee2bd2f1ce2256ba9dc4b904 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 21 Oct 2025 17:32:05 +0100 Subject: [PATCH 20/90] settings: deprecate most custom file path settings --- config/config-base.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/config/config-base.yaml b/config/config-base.yaml index 5f879a7..9e8d535 100644 --- a/config/config-base.yaml +++ b/config/config-base.yaml @@ -1608,30 +1608,36 @@ sections: requires_restart: true type: text description: Location of stored invites (json). + deprecated: true - setting: password_resets name: Password Resets requires_restart: true type: text description: Location of stored non-Jellyfin password resets (json). + deprecated: true - setting: emails name: Email Addresses requires_restart: true type: text description: Location of stored email addresses (json). + deprecated: true - setting: users name: User storage type: text description: Stores users temporarily when a user expiry is set. + deprecated: true - setting: ombi_template name: Ombi user template type: text description: Location of stored Ombi user template. + deprecated: true - setting: user_profiles name: User Profiles requires_restart: true type: text description: Location of stored user profiles (encompasses template and configuration and displayprefs) (json) + deprecated: true - setting: html_templates name: Custom HTML Template Directory requires_restart: true @@ -1649,19 +1655,23 @@ sections: type: text description: JSON file generated by program in settings, different from email_html/email_text. See wiki for more info. + deprecated: true - setting: custom_user_page_content name: Custom user page content type: text description: JSON file generated by program in settings, containing user page messages. See wiki for more info. + deprecated: true - setting: telegram_users name: Telegram users type: text description: Stores telegram user IDs and language preferences. + deprecated: true - setting: matrix_users name: Matrix users type: text description: Stores matrix user IDs and language preferences. + deprecated: true - setting: matrix_sql name: Matrix encryption DB type: text @@ -1670,7 +1680,9 @@ sections: name: Discord users type: text description: Stores discord user IDs and language preferences. + deprecated: true - setting: announcements name: Announcement templates type: text description: Stores custom announcement templates. + deprecated: true From b5f28da452d2ab06063f28abc331471427b0a311 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Fri, 21 Nov 2025 17:22:49 +0000 Subject: [PATCH 21/90] updater: demote "tag empty" to debug log the stable tag is usually empty because i rarely update it so it'd be nice if this didn't show up so much for normal users. For #313, #329 and more, probably. --- logmessages/logmessages.go | 2 ++ updater.go | 22 ++++++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/logmessages/logmessages.go b/logmessages/logmessages.go index 8d2c730..38da362 100644 --- a/logmessages/logmessages.go +++ b/logmessages/logmessages.go @@ -296,6 +296,8 @@ const ( FailedGetUpdateTag = "Failed to get latest tag: %v" FailedGetUpdate = "Failed to get update: %v" UpdateTagDetails = "Update/Tag details: %+v" + TagEmpty = "tag was empty" + TagAtEmpty = "tag at \"%s\" was empty" // user-auth.go UserPage = "userpage" diff --git a/updater.go b/updater.go index c9836e8..4126f26 100644 --- a/updater.go +++ b/updater.go @@ -27,6 +27,18 @@ const ( repo = "jfa-go" ) +type TagEmptyError struct { + url string +} + +func (t *TagEmptyError) Error() string { + if t.url != "" { + return fmt.Sprintf(lm.TagAtEmpty, t.url) + } else { + return lm.TagEmpty + } +} + var buildTime time.Time = func() time.Time { i, _ := strconv.ParseInt(buildTimeUnix, 10, 64) return time.Unix(i, 0) @@ -213,7 +225,7 @@ func (ud *Updater) GetTag() (Tag, int, error) { var tag Tag err = json.Unmarshal(body, &tag) if tag.Version == "" { - err = errors.New("Tag at \"" + url + "\" was empty") + err = &TagEmptyError{url: url} } return tag, resp.StatusCode, err } @@ -568,7 +580,13 @@ func (app *appContext) checkForUpdates() { if err != nil && strings.Contains(err.Error(), "strconv.ParseInt") { app.err.Println("No new updates available.") } else if status != -1 { // -1 means updates disabled, we don't need to log it. - app.err.Printf(lm.FailedGetUpdateTag, err) + // Silence empty tag errors (which occur when there hasn't been any tags in ages it seems) + var tagEmpty *TagEmptyError + if errors.As(err, &tagEmpty) { + app.debug.Printf(lm.FailedGetUpdateTag, err) + } else { + app.err.Printf(lm.FailedGetUpdateTag, err) + } } return } From 7c9f917114603068e1de36106565bc088c86c793 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sun, 23 Nov 2025 16:55:06 +0000 Subject: [PATCH 22/90] swag: add new statistics tag, add filtered user count route --- api-activities.go | 6 +++--- api-invites.go | 6 +++--- api-users.go | 36 ++++++++++++++++++++++++++++++++++-- main.go | 3 +++ router.go | 1 + 5 files changed, 44 insertions(+), 8 deletions(-) diff --git a/api-activities.go b/api-activities.go index 5ceba8b..df797ab 100644 --- a/api-activities.go +++ b/api-activities.go @@ -116,7 +116,7 @@ func (app *appContext) generateActivitiesQuery(req ServerFilterReqDTO) *badgerho // @Success 200 {object} GetActivitiesRespDTO // @Router /activity [post] // @Security Bearer -// @tags Activity +// @tags Activity,Statistics func (app *appContext) GetActivities(gc *gin.Context) { req := ServerSearchReqDTO{} gc.BindJSON(&req) @@ -185,7 +185,7 @@ func (app *appContext) DeleteActivity(gc *gin.Context) { // @Success 200 {object} PageCountDTO // @Router /activity/count [get] // @Security Bearer -// @tags Activity +// @tags Activity,Statistics func (app *appContext) GetActivityCount(gc *gin.Context) { resp := PageCountDTO{} var err error @@ -202,7 +202,7 @@ func (app *appContext) GetActivityCount(gc *gin.Context) { // @Success 200 {object} PageCountDTO // @Router /activity/count [post] // @Security Bearer -// @tags Activity +// @tags Activity,Statistics func (app *appContext) GetFilteredActivityCount(gc *gin.Context) { resp := PageCountDTO{} req := ServerFilterReqDTO{} diff --git a/api-invites.go b/api-invites.go index feb54cb..e8535b6 100644 --- a/api-invites.go +++ b/api-invites.go @@ -270,7 +270,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) { // @Success 200 {object} PageCountDTO // @Router /invites/count [get] // @Security Bearer -// @tags Invites +// @tags Invites,Statistics func (app *appContext) GetInviteCount(gc *gin.Context) { resp := PageCountDTO{} var err error @@ -286,7 +286,7 @@ func (app *appContext) GetInviteCount(gc *gin.Context) { // @Success 200 {object} PageCountDTO // @Router /invites/count/used [get] // @Security Bearer -// @tags Invites +// @tags Invites,Statistics func (app *appContext) GetInviteUsedCount(gc *gin.Context) { resp := PageCountDTO{} var err error @@ -310,7 +310,7 @@ func (app *appContext) GetInviteUsedCount(gc *gin.Context) { // @Success 200 {object} getInvitesDTO // @Router /invites [get] // @Security Bearer -// @tags Invites +// @tags Invites,Statistics func (app *appContext) GetInvites(gc *gin.Context) { currentTime := time.Now() app.checkInvites() diff --git a/api-users.go b/api-users.go index 899ba18..51f59bd 100644 --- a/api-users.go +++ b/api-users.go @@ -911,7 +911,7 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser { // @Success 200 {object} PageCountDTO // @Router /users/count [get] // @Security Bearer -// @tags Activity +// @tags Activity,Statistics func (app *appContext) GetUserCount(gc *gin.Context) { resp := PageCountDTO{} users, err := app.jf.GetUsers(false) @@ -952,7 +952,7 @@ func (app *appContext) GetUsers(gc *gin.Context) { // @Failure 500 {object} stringResponse // @Router /users [post] // @Security Bearer -// @tags Users +// @tags Users,Statistics func (app *appContext) SearchUsers(gc *gin.Context) { req := ServerSearchReqDTO{} gc.BindJSON(&req) @@ -991,6 +991,38 @@ func (app *appContext) SearchUsers(gc *gin.Context) { gc.JSON(200, resp) } +// @Summary Get a count of users matching the search provided +// @Produce json +// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search / pagination parameters" +// @Success 200 {object} PageCountDTO +// @Failure 500 {object} stringResponse +// @Router /users/count [post] +// @Security Bearer +// @tags Users,Statistics +func (app *appContext) GetFilteredUserCount(gc *gin.Context) { + req := ServerSearchReqDTO{} + gc.BindJSON(&req) + if req.SortByField == "" { + req.SortByField = USER_DEFAULT_SORT_FIELD + } + + var resp PageCountDTO + // No need to sort + userList, err := app.userCache.GetUserDTOs(app, false) + if err != nil { + app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err) + respond(500, "Couldn't get users", gc) + return + } + if len(req.SearchTerms) != 0 || len(req.Queries) != 0 { + resp.Count = uint64(len(app.userCache.Filter(userList, req.SearchTerms, req.Queries))) + } else { + resp.Count = uint64(len(userList)) + } + + gc.JSON(200, resp) +} + // @Summary Set whether or not a user can access jfa-go. Redundant if the user is a Jellyfin admin. // @Produce json // @Param setAccountsAdminDTO body setAccountsAdminDTO true "Map of userIDs to whether or not they have access." diff --git a/main.go b/main.go index e94f8a6..bc909cb 100644 --- a/main.go +++ b/main.go @@ -757,6 +757,9 @@ func flagPassed(name string) (found bool) { // @tag.name Other // @tag.description Things that dont fit elsewhere. +// @tag.name Statistics +// @tag.description Routes that expose useful info/stats. + func printVersion() { tray := "" if TRAY { diff --git a/router.go b/router.go index e086ae6..6f113bb 100644 --- a/router.go +++ b/router.go @@ -201,6 +201,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) { api.GET(p+"/users", app.GetUsers) api.GET(p+"/users/count", app.GetUserCount) api.POST(p+"/users", app.SearchUsers) + api.POST(p+"/users/count", app.GetFilteredUserCount) api.POST(p+"/user", app.NewUserFromAdmin) api.POST(p+"/users/extend", app.ExtendExpiry) api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry) From b1c578ccf49f2d12d220f5958dda8aadb49e0e57 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sun, 23 Nov 2025 20:10:25 +0000 Subject: [PATCH 23/90] mediabrowser: bump also updated everything else. --- go.mod | 115 ++++++++++++++++++++++++++++++++------------------------- go.sum | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 51 deletions(-) diff --git a/go.mod b/go.mod index 8e52aa1..343de44 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/hrfee/jfa-go -go 1.23.0 - -toolchain go1.24.0 +go 1.24.0 replace github.com/hrfee/jfa-go/docs => ./docs @@ -30,47 +28,48 @@ require ( github.com/fsnotify/fsnotify v1.9.0 github.com/getlantern/systray v1.2.2 github.com/gin-contrib/pprof v1.5.3 - github.com/gin-gonic/gin v1.10.1 + github.com/gin-gonic/gin v1.11.0 github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible github.com/golang-jwt/jwt v3.2.2+incompatible - github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b - github.com/hrfee/jfa-go/common v0.0.0-20250716174732-bcb6346f8115 - github.com/hrfee/jfa-go/docs v0.0.0-20250716174732-bcb6346f8115 - github.com/hrfee/jfa-go/easyproxy v0.0.0-20250716174732-bcb6346f8115 - github.com/hrfee/jfa-go/jellyseerr v0.0.0-20250716174732-bcb6346f8115 - github.com/hrfee/jfa-go/linecache v0.0.0-20250716174732-bcb6346f8115 - github.com/hrfee/jfa-go/logger v0.0.0-20250716174732-bcb6346f8115 - github.com/hrfee/jfa-go/logmessages v0.0.0-20250716174732-bcb6346f8115 - github.com/hrfee/jfa-go/ombi v0.0.0-20250716174732-bcb6346f8115 - github.com/hrfee/mediabrowser v0.3.29 - github.com/itchyny/timefmt-go v0.1.6 + github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a + github.com/hrfee/jfa-go/common v0.0.0-20251123165523-7c9f91711460 + github.com/hrfee/jfa-go/docs v0.0.0-20251123165523-7c9f91711460 + github.com/hrfee/jfa-go/easyproxy v0.0.0-20251123165523-7c9f91711460 + github.com/hrfee/jfa-go/jellyseerr v0.0.0-20251123165523-7c9f91711460 + github.com/hrfee/jfa-go/linecache v0.0.0-20251123165523-7c9f91711460 + github.com/hrfee/jfa-go/logger v0.0.0-20251123165523-7c9f91711460 + github.com/hrfee/jfa-go/logmessages v0.0.0-20251123165523-7c9f91711460 + github.com/hrfee/jfa-go/ombi v0.0.0-20251123165523-7c9f91711460 + github.com/hrfee/mediabrowser v0.3.30 + github.com/itchyny/timefmt-go v0.1.7 github.com/lithammer/shortuuid/v3 v3.0.7 github.com/mailgun/mailgun-go/v4 v4.23.0 - github.com/mattn/go-sqlite3 v1.14.28 + github.com/mattn/go-sqlite3 v1.14.32 github.com/robert-nix/ansihtml v1.0.1 github.com/steambap/captcha v1.4.1 github.com/swaggo/files v1.0.1 - github.com/swaggo/gin-swagger v1.6.0 + github.com/swaggo/gin-swagger v1.6.1 github.com/timshannon/badgerhold/v4 v4.0.3 github.com/writeas/go-strip-markdown v2.0.1+incompatible github.com/xhit/go-simple-mail/v2 v2.16.0 gopkg.in/ini.v1 v1.67.0 gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mautrix v0.24.2 + maunium.net/go/mautrix v0.26.0 ) require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect - github.com/bytedance/sonic v1.13.3 // indirect - github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.2 // indirect + github.com/bytedance/sonic/loader v0.4.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cloudwego/base64x v0.1.5 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/dgraph-io/ristretto v1.0.0 // indirect - github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect + github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gabriel-vasile/mimetype v1.4.11 // indirect github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect github.com/getlantern/errors v1.0.4 // indirect github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 // indirect @@ -78,68 +77,82 @@ require ( github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 // indirect github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/go-chi/chi/v5 v5.2.2 // indirect + github.com/go-chi/chi/v5 v5.2.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.21.1 // indirect - github.com/go-openapi/jsonreference v0.21.0 // indirect - github.com/go-openapi/spec v0.21.0 // indirect - github.com/go-openapi/swag v0.23.1 // indirect + github.com/go-openapi/jsonpointer v0.22.3 // indirect + github.com/go-openapi/jsonreference v0.21.3 // indirect + github.com/go-openapi/spec v0.22.1 // indirect + github.com/go-openapi/swag v0.25.3 // indirect + github.com/go-openapi/swag/conv v0.25.3 // indirect + github.com/go-openapi/swag/jsonname v0.25.3 // indirect + github.com/go-openapi/swag/jsonutils v0.25.3 // indirect + github.com/go-openapi/swag/loading v0.25.3 // indirect + github.com/go-openapi/swag/stringutils v0.25.3 // indirect + github.com/go-openapi/swag/typeutils v0.25.3 // indirect + github.com/go-openapi/swag/yamlutils v0.25.3 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/go-playground/validator/v10 v10.28.0 // indirect github.com/go-stack/stack v1.8.1 // indirect github.com/go-test/deep v1.1.0 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/google/flatbuffers v25.2.10+incompatible // indirect + github.com/google/flatbuffers v25.9.23+incompatible // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.1 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b // indirect github.com/mailgun/errors v0.4.0 // indirect - github.com/mailru/easyjson v0.9.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb // indirect + github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.57.0 // indirect github.com/rs/zerolog v1.34.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect - github.com/swaggo/swag v1.16.4 // indirect + github.com/swaggo/swag v1.16.6 // indirect github.com/technoweenie/multipartstreamer v1.0.1 // indirect github.com/tidwall/gjson v1.18.0 // indirect - github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.3.0 // indirect - go.mau.fi/util v0.8.8 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mau.fi/util v0.9.3 // indirect go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.uber.org/mock v0.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect - golang.org/x/arch v0.19.0 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect - golang.org/x/image v0.29.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/text v0.27.0 // indirect - golang.org/x/tools v0.35.0 // indirect - google.golang.org/protobuf v1.36.6 // indirect + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.23.0 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect + golang.org/x/image v0.33.0 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.39.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect ) diff --git a/go.sum b/go.sum index 6ae34fd..832601c 100644 --- a/go.sum +++ b/go.sum @@ -16,15 +16,21 @@ github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.12.4 h1:9Csb3c9ZJhfUWeMtpCDCq6BUoH5ogfDFLUgQ/jG+R0k= github.com/bytedance/sonic v1.12.4/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= +github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= +github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E= github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o= +github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -36,6 +42,8 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/ github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= @@ -58,6 +66,8 @@ github.com/dgraph-io/ristretto v1.0.0 h1:SYG07bONKMlFDUYu5pEu3DGAh8c2OFNzKm6G9J4 github.com/dgraph-io/ristretto v1.0.0/go.mod h1:jTi2FiYEhQ1NsMmA7DeBykizjOuY88NhKBkepyu1jPc= github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= +github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk= +github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= @@ -84,6 +94,8 @@ github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= +github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 h1:oEZYEpZo28Wdx+5FZo4aU7JFXu0WG/4wJWese5reQSA= github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201/go.mod h1:Y9WZUHEb+mpra02CbQ/QczLUe6f0Dezxaw5DCJlJQGo= @@ -126,10 +138,14 @@ github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= @@ -145,16 +161,22 @@ github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1 github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8= +github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo= github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= github.com/go-openapi/jsonreference v0.19.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc= github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc= +github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4= github.com/go-openapi/spec v0.19.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= github.com/go-openapi/spec v0.19.4/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo= github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k= +github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA= github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= @@ -162,6 +184,22 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-openapi/swag v0.25.3 h1:FAa5wJXyDtI7yUztKDfZxDrSx+8WTg31MfCQ9s3PV+s= +github.com/go-openapi/swag v0.25.3/go.mod h1:tX9vI8Mj8Ny+uCEk39I1QADvIPI7lkndX4qCsEqhkS8= +github.com/go-openapi/swag/conv v0.25.3 h1:PcB18wwfba7MN5BVlBIV+VxvUUeC2kEuCEyJ2/t2X7E= +github.com/go-openapi/swag/conv v0.25.3/go.mod h1:n4Ibfwhn8NJnPXNRhBO5Cqb9ez7alBR40JS4rbASUPU= +github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A= +github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.3 h1:kV7wer79KXUM4Ea4tBdAVTU842Rg6tWstX3QbM4fGdw= +github.com/go-openapi/swag/jsonutils v0.25.3/go.mod h1:ILcKqe4HC1VEZmJx51cVuZQ6MF8QvdfXsQfiaCs0z9o= +github.com/go-openapi/swag/loading v0.25.3 h1:Nn65Zlzf4854MY6Ft0JdNrtnHh2bdcS/tXckpSnOb2Y= +github.com/go-openapi/swag/loading v0.25.3/go.mod h1:xajJ5P4Ang+cwM5gKFrHBgkEDWfLcsAKepIuzTmOb/c= +github.com/go-openapi/swag/stringutils v0.25.3 h1:nAmWq1fUTWl/XiaEPwALjp/8BPZJun70iDHRNq/sH6w= +github.com/go-openapi/swag/stringutils v0.25.3/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.3 h1:2w4mEEo7DQt3V4veWMZw0yTPQibiL3ri2fdDV4t2TQc= +github.com/go-openapi/swag/typeutils v0.25.3/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.3 h1:LKTJjCn/W1ZfMec0XDL4Vxh8kyAnv1orH5F2OREDUrg= +github.com/go-openapi/swag/yamlutils v0.25.3/go.mod h1:Y7QN6Wc5DOBXK14/xeo1cQlq0EA0wvLoSv13gDQoCao= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -172,6 +210,8 @@ github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27 github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= @@ -183,6 +223,8 @@ github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -219,12 +261,16 @@ github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81 h1:5lyLWsV+qCk github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk= github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= +github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A= +github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/flatbuffers v23.5.9+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI= github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/flatbuffers v25.9.23+incompatible h1:rGZKv+wOb6QPzIdkM2KxhBZCDrA0DeN6DNmRDrqIsQU= +github.com/google/flatbuffers v25.9.23+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -249,9 +295,13 @@ github.com/hrfee/mediabrowser v0.3.28 h1:KkSgODXxUnZLrkmjSWpma8mXwEVxlOtI51uS2QP github.com/hrfee/mediabrowser v0.3.28/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= github.com/hrfee/mediabrowser v0.3.29 h1:xTqGS9u8HuolZAhouYHxutnE0fF/8aVCInbByKZEzIo= github.com/hrfee/mediabrowser v0.3.29/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= +github.com/hrfee/mediabrowser v0.3.30 h1:llJo4hxWchbwROnkfhlYsrvtZ6/8WDTp3QxAvbgjUfI= +github.com/hrfee/mediabrowser v0.3.30/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= +github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= +github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= @@ -266,6 +316,8 @@ github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IX github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= +github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= @@ -302,6 +354,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -319,6 +373,8 @@ github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBW github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -338,12 +394,18 @@ github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274 h1:qli3BGQK0tYDkS github.com/petermattis/goid v0.0.0-20241025130422-66cb2e6d7274/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb h1:3PrKuO92dUTMrQ9dx0YNejC6U/Si6jqKmyQ9vWjwqR4= github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a h1:VweslR2akb/ARhXfqSfRbj1vpWwYXf3eeAUyw/ndms0= +github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE= +github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= github.com/robert-nix/ansihtml v1.0.1 h1:VTiyQ6/+AxSJoSSLsMecnkh8i0ZqOEdiRl/odOc64fc= github.com/robert-nix/ansihtml v1.0.1/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yDXcNeHHKZINE= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= @@ -373,6 +435,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -381,18 +444,24 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= github.com/swaggo/gin-swagger v1.2.0/go.mod h1:qlH2+W7zXGZkczuL+r2nEBR2JTT+/lX05Nn6vPhc7OI= github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= github.com/swaggo/swag v1.5.1/go.mod h1:1Bl9F/ZBpVWh22nY0zmYyASPO1lI/zIwRDrpZU+tv8Y= github.com/swaggo/swag v1.6.7/go.mod h1:xDhTyuFIujYiN3DKWC/H/83xcfHp+UE/IzWWampG7Zc= github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM= github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -400,6 +469,8 @@ github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= @@ -423,6 +494,8 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw= @@ -438,39 +511,57 @@ go.mau.fi/util v0.8.1 h1:Ga43cz6esQBYqcjZ/onRoVnYWoUwjWbsxVeJg2jOTSo= go.mau.fi/util v0.8.1/go.mod h1:T1u/rD2rzidVrBLyaUdPpZiJdP/rsyi+aTzn0D+Q6wc= go.mau.fi/util v0.8.8 h1:OnuEEc/sIJFhnq4kFggiImUpcmnmL/xpvQMRu5Fiy5c= go.mau.fi/util v0.8.8/go.mod h1:Y/kS3loxTEhy8Vill513EtPXr+CRDdae+Xj2BXXMy/c= +go.mau.fi/util v0.9.3 h1:aqNF8KDIN8bFpFbybSk+mEBil7IHeBwlujfyTnvP0uU= +go.mau.fi/util v0.9.3/go.mod h1:krWWfBM1jWTb5f8NCa2TLqWMQuM81X7TGQjhMjBeXmQ= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.9.0/go.mod h1:np4EoPGzoPs3O67xUVNoPPcmSvsfOxNlNA4F4AC+0Eo= go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/trace v1.9.0/go.mod h1:2737Q0MuG8q1uILYm2YYVkAyLtOofiTNGg6VODnOiPo= go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4= golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.19.0 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU= golang.org/x/arch v0.19.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= +golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -482,16 +573,22 @@ golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc= golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= +golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= +golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s= golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78= golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas= golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA= +golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= +golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -504,6 +601,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -529,6 +628,8 @@ golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -543,6 +644,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -574,6 +677,8 @@ golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -589,6 +694,8 @@ golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -607,6 +714,8 @@ golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -639,6 +748,8 @@ google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFyt google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -662,4 +773,6 @@ maunium.net/go/mautrix v0.21.1 h1:Z+e448jtlY977iC1kokNJTH5kg2WmDpcQCqn+v9oZOA= maunium.net/go/mautrix v0.21.1/go.mod h1:7F/S6XAdyc/6DW+Q7xyFXRSPb6IjfqMb1OMepQ8C8OE= maunium.net/go/mautrix v0.24.2 h1:+AVT5kbcA/QuT5svrJKp4ivwoUmz+RRplMp3DnfpheI= maunium.net/go/mautrix v0.24.2/go.mod h1:1ut900w++eE9by9yqCR2dQdMqwsHwZG5L+1bKB1EvSA= +maunium.net/go/mautrix v0.26.0 h1:valc2VmZF+oIY4bMq4Cd5H9cEKMRe8eP4FM7iiaYLxI= +maunium.net/go/mautrix v0.26.0/go.mod h1:NWMv+243NX/gDrLofJ2nNXJPrG8vzoM+WUCWph85S6Q= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= From 704157be00446e630e8d82ce5c77454268519d38 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 24 Nov 2025 11:49:26 +0000 Subject: [PATCH 24/90] bump api version --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index bc909cb..8f15939 100644 --- a/main.go +++ b/main.go @@ -709,7 +709,7 @@ func flagPassed(name string) (found bool) { } // @title jfa-go internal API -// @version 0.5.2 +// @version 0.6.0 // @description API for the jfa-go frontend // @contact.name Harvey Tindall // @contact.email hrfee@hrfee.dev From 6bfb345169e56e7afc016047dea01bb7ee6b3ec6 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 24 Nov 2025 15:16:47 +0000 Subject: [PATCH 25/90] config add "Group" notion a group contains an ordered list of settings sections and/or other groups. Intended to be rendered as an accordion tree in the app. Has no effect on INI structure. --- common/config.go | 14 ++++++++++++++ config/config-base.yaml | 23 +++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/common/config.go b/common/config.go index 7d68233..8b23b87 100644 --- a/common/config.go +++ b/common/config.go @@ -48,8 +48,22 @@ type Section struct { Settings []Setting `json:"settings" yaml:"settings"` } +// Member is a member of a group, and can either reference a Section or another Group, hence the two fields. +type Member struct { + Group string `json:"group,omitempty", yaml:"group,omitempty"` + Section string `json:"section,omitempty", yaml:"section,omitempty"` +} + +type Group struct { + Group string `json:"group" yaml:"group" example:"messaging_providers"` + Name string `json:"name" yaml:"name" example:"Messaging Providers"` + Description string `json:"description" yaml:"description" example:"Options for setting up messaging providers."` + Members []Member `json:"members" yaml:"members"` +} + type Config struct { Sections []Section `json:"sections" yaml:"sections"` + Groups []Group `json:"groups" yaml:"groups"` } func (c *Config) removeSection(section string) { diff --git a/config/config-base.yaml b/config/config-base.yaml index 9e8d535..c0b6c6f 100644 --- a/config/config-base.yaml +++ b/config/config-base.yaml @@ -1,3 +1,26 @@ +groups: + - group: external_services + name: "Integrations" + description: "Integrations with external services." + members: + - group: email + - group: chatbots + - section: ombi + - section: jellyseerr + - group: email + name: "Email" + description: "Options for sending emails through jfa-go." + members: + - section: email + - section: smtp + - section: mailgun + - group: chatbots + name: "Chat bots" + description: "Options for messaging through chat services." + members: + - section: discord + - section: telegram + - section: matrix sections: - section: updates meta: From a3dc8b7e07e840edb52382673b121f5a634142d4 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 24 Nov 2025 15:17:49 +0000 Subject: [PATCH 26/90] settings: render groups a little off at the moment but works, groups show as accordions and can be nested. Maybe add indentation, and probably show them first in the list. Also make sure search works with them. --- ts/modules/settings.ts | 98 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 96 insertions(+), 2 deletions(-) diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts index 2a992b3..5e8a99b 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -472,6 +472,13 @@ class DOMNote extends DOMSetting implements SNote { asElement = (): HTMLDivElement => { return this._container; } } +interface Group { + group: string; + name: string; + description: string; + members: ({ group: string } | { section: string })[]; +} + interface Section { section: string; meta: Meta; @@ -567,6 +574,7 @@ class sectionPanel { } interface Settings { + groups: Group[]; sections: Section[]; } @@ -580,8 +588,12 @@ export class settingsList { private _panel = document.getElementById("settings-panel") as HTMLDivElement; private _sidebar = document.getElementById("settings-sidebar") as HTMLDivElement; private _visibleSection: string; - private _sections: { [name: string]: sectionPanel } - private _buttons: { [name: string]: HTMLSpanElement } + private _sections: { [name: string]: sectionPanel }; + private _buttons: { [name: string]: HTMLSpanElement }; + + private _groups: { [name: string]: Group }; + private _groupButtons: { [name: string]: HTMLSpanElement }; + private _needsRestart: boolean = false; private _messageEditor = new MessageEditor(); private _settings: Settings; @@ -595,6 +607,83 @@ export class settingsList { private _backupSortDirection = document.getElementById("settings-backups-sort-direction") as HTMLButtonElement; private _backupSortAscending = true; + // Must be called -after- all section have been added. + // Takes all groups at once since members might contain each other. + addGroups = (groups: Group[]) => { + groups.forEach((g) => { this._groups[g.group] = g }); + const addGroup = (g: Group): HTMLElement => { + if (g.group in this._groupButtons) return null; + + const container = document.createElement("div") as HTMLDivElement; + container.classList.add("flex", "flex-col", "gap-2"); + + const button = document.createElement("span") as HTMLSpanElement; + container.appendChild(button); + button.classList.add("button", "~neutral", "@low", "settings-section-button", "justify-between"); + button.innerHTML = ` + ${g.name} + + `; + + const icon = button.querySelector("i.icon"); + const check = button.querySelector("input[type=checkbox]") as HTMLInputElement; + + button.onclick = () => { + check.checked = !check.checked; + if (check.checked) { + icon.classList.add("rotated"); + dropdown.classList.remove("unfocused"); + dropdown.style.maxHeight = dropdown.scrollHeight+"px"; + dropdown.style.opacity = "100%"; + } else { + icon.classList.remove("rotated"); + const hide = () => { + dropdown.classList.add("unfocused"); + dropdown.removeEventListener("transitionend", hide); + }; + dropdown.addEventListener("transitionend", hide); + dropdown.style.maxHeight = "0"; + dropdown.style.opacity = "0"; + } + } + + const dropdown = document.createElement("div") as HTMLDivElement; + container.appendChild(dropdown); + dropdown.style.maxHeight = "0"; + dropdown.style.opacity = "0"; + dropdown.classList.add("unfocused", "flex", "flex-col", "gap-2", "flex-1", "max-h-0", "transition-all"); + + for (const member of g.members) { + if ("group" in member) { + let subgroup = addGroup(this._groups[member.group]); + if (!subgroup) { + subgroup = this._groupButtons[member.group]; + // Remove from page + subgroup.remove(); + } + dropdown.appendChild(subgroup); + } else if ("section" in member) { + const subsection = this._buttons[member.section]; + // Remove from page + subsection.remove(); + dropdown.appendChild(subsection); + } + } + + this._groupButtons[g.group] = container; + return container; + } + for (let g of groups) { + const container = addGroup(g); + if (container) { + this._sidebar.appendChild(container); + } + } + } + addSection = (name: string, s: Section, subButton?: HTMLElement) => { const section = new sectionPanel(s, name); this._sections[name] = section; @@ -759,6 +848,8 @@ export class settingsList { }); constructor() { + this._groups = {}; + this._groupButtons = {}; this._sections = {}; this._buttons = {}; document.addEventListener("settings-section-changed", () => this._saveButton.classList.remove("unfocused")); @@ -920,6 +1011,9 @@ export class settingsList { } } } + + this.addGroups(this._settings.groups); + removeLoader(this._loader); for (let i = 0; i < this._loader.children.length; i++) { this._loader.children[i].classList.remove("invisible"); From 8f3b860cc73b10838e5f2ec36d5df78b16a7f8a2 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 24 Nov 2025 18:31:35 +0000 Subject: [PATCH 27/90] settings/config: add root order and use on web, fix nesting and animation added an optional root "Order" field to the config. scripts/ini will warn if you've used this and forgot to include any sections. added more/most sections to a group now. groups have their maxHeight set to 9999px once animation finishes, and have it quickly set back to ~scrollHeight before they're animated closed. --- common/config.go | 3 ++ config/config-base.yaml | 38 ++++++++++++++++++- html/admin.html | 5 ++- scripts/ini/go.mod | 6 ++- scripts/ini/go.sum | 11 ++++++ scripts/ini/main.go | 44 +++++++++++++++++++++ ts/modules/settings.ts | 84 +++++++++++++++++++++++++++++++++-------- 7 files changed, 170 insertions(+), 21 deletions(-) diff --git a/common/config.go b/common/config.go index 8b23b87..1a8eba5 100644 --- a/common/config.go +++ b/common/config.go @@ -64,6 +64,9 @@ type Group struct { type Config struct { Sections []Section `json:"sections" yaml:"sections"` Groups []Group `json:"groups" yaml:"groups"` + // Optional order, which can interleave sections and groups. + // If unset, falls back to sections in order, then groups in order. + Order []Member `json:"order,omitempty" yaml:"order,omitempty"` } func (c *Config) removeSection(section string) { diff --git a/config/config-base.yaml b/config/config-base.yaml index c0b6c6f..b58f960 100644 --- a/config/config-base.yaml +++ b/config/config-base.yaml @@ -1,3 +1,17 @@ +order: + - section: ui + - section: advanced + - section: jellyfin + - group: sign_up + - group: accounts + - section: messages + - group: external_services + - section: activity_log + - section: backups + - section: updates + - section: url_paths + - section: template_email + - section: files groups: - group: external_services name: "Integrations" @@ -7,6 +21,7 @@ groups: - group: chatbots - section: ombi - section: jellyseerr + - section: webhooks - group: email name: "Email" description: "Options for sending emails through jfa-go." @@ -14,6 +29,7 @@ groups: - section: email - section: smtp - section: mailgun + - section: email_confirmation - group: chatbots name: "Chat bots" description: "Options for messaging through chat services." @@ -21,6 +37,24 @@ groups: - section: discord - section: telegram - section: matrix + - group: sign_up + name: "Invites & Referrals" + description: "Settings relating to invites, the sign up page and referrals." + members: + - section: captcha + - section: password_validation + - section: invite_emails + - section: notifications + - section: welcome_email + - group: accounts + name: "Accounts" + description: "Settings relating to account management." + members: + - section: user_page + - section: password_resets + - section: user_expiry + - section: disable_enable + - section: deletion sections: - section: updates meta: @@ -1259,7 +1293,7 @@ sections: description: Path to custom email text template for announcements/custom messages. - section: notifications meta: - name: Admin invite notifications + name: Admin notifications description: Allows toggling "user created" and "invite expired" notifications to be sent to the admin per-invite. depends_true: messages|enabled @@ -1471,7 +1505,7 @@ sections: description: Path to custom email in plain text - section: user_expiry meta: - name: User Expiry + name: Account Expiry description: When set on an invite, users will be deleted or disabled a specified amount of time after they create their account. Expiries can also be set and extended for invididual users, optionally with a message why. diff --git a/html/admin.html b/html/admin.html index 2c2c3af..fddb5c8 100644 --- a/html/admin.html +++ b/html/admin.html @@ -916,7 +916,7 @@
-
+
@@ -927,8 +927,9 @@ {{ .strings.wiki }} {{ .strings.userProfiles }}
+
-
+
{{ .strings.noResultsFound }} diff --git a/scripts/ini/go.mod b/scripts/ini/go.mod index 531d89f..412489d 100644 --- a/scripts/ini/go.mod +++ b/scripts/ini/go.mod @@ -2,11 +2,15 @@ module github.com/hrfee/jfa-go/scripts/ini replace github.com/hrfee/jfa-go/common => ../../common -go 1.18 +go 1.22.4 require ( + github.com/fatih/color v1.18.0 // indirect github.com/hrfee/jfa-go/common v0.0.0-20240824141650-fcdd4e451882 // indirect github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.25.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/scripts/ini/go.sum b/scripts/ini/go.sum index 1e5face..624d5d9 100644 --- a/scripts/ini/go.sum +++ b/scripts/ini/go.sum @@ -1,5 +1,16 @@ +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a h1:qbXZgCqb9eaPSJfLEXczQD2lxTv6jb6silMPIWW9j6o= github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a/go.mod h1:c5HKkLayo0GrEUDlJwT12b67BL9cdPjP271Xlv/KDRQ= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= diff --git a/scripts/ini/main.go b/scripts/ini/main.go index 98810fc..3221462 100644 --- a/scripts/ini/main.go +++ b/scripts/ini/main.go @@ -7,6 +7,7 @@ import ( "os" "strings" + "github.com/fatih/color" "github.com/hrfee/jfa-go/common" "gopkg.in/ini.v1" "gopkg.in/yaml.v3" @@ -26,6 +27,49 @@ func generateIni(yamlPath string, iniPath string) { if err != nil { panic(err) } + // Validate that all groups/sections are listed in the root order, if it exists + if len(configBase.Order) > 0 { + // Expand order + var traverseGroup func(groupName string) []string + traverseGroup = func(groupName string) []string { + out := []string{} + for _, group := range configBase.Groups { + if group.Group == groupName { + for _, groupMember := range group.Members { + if groupMember.Group != "" { + out = append(out, traverseGroup(groupMember.Group)...) + } else if groupMember.Section != "" { + out = append(out, groupMember.Section) + } + } + break + } + } + return out + } + listedSects := map[string]bool{} + for _, member := range configBase.Order { + if member.Group != "" { + for _, sect := range traverseGroup(member.Group) { + listedSects[sect] = true + } + } else if member.Section != "" { + listedSects[member.Section] = true + } + } + + missingSections := false + for _, section := range configBase.Sections { + if _, ok := listedSects[section.Section]; !ok { + if !missingSections { + color.Red("WARNING: Root order specified but the following sections were not listed, directly or indirectly:") + missingSections = true + } + color.Red("\t%s", section.Section) + } + } + } + conf := ini.Empty() for _, section := range configBase.Sections { diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts index 5e8a99b..ed0928b 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -476,7 +476,7 @@ interface Group { group: string; name: string; description: string; - members: ({ group: string } | { section: string })[]; + members: Member[]; } interface Section { @@ -573,9 +573,12 @@ class sectionPanel { asElement = (): HTMLDivElement => { return this._section; } } +type Member = { group: string } | { section: string }; + interface Settings { groups: Group[]; sections: Section[]; + order?: Member[]; } export class settingsList { @@ -586,7 +589,7 @@ export class settingsList { private _loader = document.getElementById("settings-loader") as HTMLDivElement; private _panel = document.getElementById("settings-panel") as HTMLDivElement; - private _sidebar = document.getElementById("settings-sidebar") as HTMLDivElement; + private _sidebar = document.getElementById("settings-sidebar-items") as HTMLDivElement; private _visibleSection: string; private _sections: { [name: string]: sectionPanel }; private _buttons: { [name: string]: HTMLSpanElement }; @@ -611,7 +614,7 @@ export class settingsList { // Takes all groups at once since members might contain each other. addGroups = (groups: Group[]) => { groups.forEach((g) => { this._groups[g.group] = g }); - const addGroup = (g: Group): HTMLElement => { + const addGroup = (g: Group, indent: number = 0): HTMLElement => { if (g.group in this._groupButtons) return null; const container = document.createElement("div") as HTMLDivElement; @@ -627,38 +630,70 @@ export class settingsList { `; + + const dropdown = document.createElement("div") as HTMLDivElement; + container.appendChild(dropdown); + dropdown.classList.add("ml-" + ((indent+1)*2)); + dropdown.style.maxHeight = "0"; + dropdown.style.opacity = "0"; + dropdown.classList.add("settings-dropdown", "unfocused", "flex", "flex-col", "gap-2", "transition-all"); const icon = button.querySelector("i.icon"); const check = button.querySelector("input[type=checkbox]") as HTMLInputElement; + button.onclick = () => { check.checked = !check.checked; + onCheck(); + }; + // When groups are nested, the outer group's scrollHeight will obviously change when an + // inner group is opened/closed. Instead of traversing the tree and adjusting the maxHeight property + // each open/close, just set the maxHeight to 9999px once the animation is completed. + // On close, quickly set maxHeight back to ~scrollHeight, then animate to 0. + const onCheck = () => { if (check.checked) { icon.classList.add("rotated"); + // Hide the scrollbar while we animate + this._sidebar.style.overflowY = "hidden"; dropdown.classList.remove("unfocused"); - dropdown.style.maxHeight = dropdown.scrollHeight+"px"; + const fullHeight = () => { + dropdown.removeEventListener("transitionend", fullHeight); + dropdown.style.maxHeight = "9999px"; + // Return the scrollbar (or whatever, just don't hide it) + this._sidebar.style.overflowY = ""; + }; + dropdown.addEventListener("transitionend", fullHeight); + dropdown.style.maxHeight = (1.2*dropdown.scrollHeight)+"px"; dropdown.style.opacity = "100%"; } else { icon.classList.remove("rotated"); - const hide = () => { + const mainTransitionEnd = () => { + dropdown.removeEventListener("transitionend", mainTransitionEnd); dropdown.classList.add("unfocused"); - dropdown.removeEventListener("transitionend", hide); + // Return the scrollbar (or whatever, just don't hide it) + this._sidebar.style.overflowY = ""; }; - dropdown.addEventListener("transitionend", hide); - dropdown.style.maxHeight = "0"; - dropdown.style.opacity = "0"; + const mainTransitionStart = () => { + dropdown.removeEventListener("transitionend", mainTransitionStart) + dropdown.style.transitionDuration = ""; + dropdown.addEventListener("transitionend", mainTransitionEnd); + dropdown.style.maxHeight = "0"; + dropdown.style.opacity = "0"; + } + // Hide the scrollbar while we animate + this._sidebar.style.overflowY = "hidden"; + // Disabling transitions then going from 9999 - scrollHeight doesn't work in firefox to me, + // so instead just make the transition duration really short. + dropdown.style.transitionDuration = "1ms"; + dropdown.addEventListener("transitionend", mainTransitionStart); + dropdown.style.maxHeight = (1.2*dropdown.scrollHeight)+"px"; } } - - const dropdown = document.createElement("div") as HTMLDivElement; - container.appendChild(dropdown); - dropdown.style.maxHeight = "0"; - dropdown.style.opacity = "0"; - dropdown.classList.add("unfocused", "flex", "flex-col", "gap-2", "flex-1", "max-h-0", "transition-all"); + check.onchange = onCheck; for (const member of g.members) { if ("group" in member) { - let subgroup = addGroup(this._groups[member.group]); + let subgroup = addGroup(this._groups[member.group], indent+1); if (!subgroup) { subgroup = this._groupButtons[member.group]; // Remove from page @@ -727,6 +762,21 @@ export class settingsList { this._sidebar.appendChild(this._buttons[name]); } + setOrder(order: Member[]) { + this._sidebar.textContent = ``; + for (const member of order) { + if ("group" in member) { + this._sidebar.appendChild(this._groupButtons[member.group]); + } else if ("section" in member) { + if (member.section in this._buttons) { + this._sidebar.appendChild(this._buttons[member.section]); + } else { + console.warn("Settings section specified in order but missing:", member.section); + } + } + } + } + private _showPanel = (name: string) => { // console.log("showing", name); for (let n in this._sections) { @@ -1014,6 +1064,8 @@ export class settingsList { this.addGroups(this._settings.groups); + if ("order" in this._settings && this._settings.order) this.setOrder(this._settings.order); + removeLoader(this._loader); for (let i = 0; i < this._loader.children.length; i++) { this._loader.children[i].classList.remove("invisible"); From 607d8e956684d9a53a57a5e3a23d0f51be6aabb0 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 24 Nov 2025 18:43:08 +0000 Subject: [PATCH 28/90] settings: show updates at top if one available --- ts/modules/settings.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts index ed0928b..a4ab749 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -1043,6 +1043,21 @@ export class settingsList { icon.classList.add("button", "~urge"); icon.innerHTML = ``; icon.onclick = () => window.updater.checkForUpdates(window.modals.updateInfo.show); + // Put us first + if ("order" in this._settings && this._settings.order) { + let i = -1; + for (let j = 0; j < this._settings.order.length; j++) { + const member = this._settings.order[j]; + if ("section" in member && member.section == "updates") { + i = j; + break; + } + } + if (i != -1) { + this._settings.order.splice(i, 1); + this._settings.order.unshift({ section: "updates" }); + } + } } this.addSection(section.section, section, icon); } else if (section.section == "matrix" && !window.matrixEnabled) { From 65a25a7e66773639cb5b98f6b22b03dd47b17fc3 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 25 Nov 2025 14:42:18 +0000 Subject: [PATCH 29/90] config: update wiki links some were outdated. --- config/config-base.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/config/config-base.yaml b/config/config-base.yaml index b58f960..53be7fd 100644 --- a/config/config-base.yaml +++ b/config/config-base.yaml @@ -573,7 +573,7 @@ sections: meta: name: Captcha description: Settings related to user creation CAPTCHAs. - wiki_link: https://wiki.jfa-go.com/docs/captcha/ + wiki_link: https://wiki.jfa-go.com/docs/external-services/captcha/ settings: - setting: enabled name: Enabled @@ -727,7 +727,7 @@ sections: meta: name: Messages/Notifications description: General settings for emails/messages. - wiki_link: https://wiki.jfa-go.com/docs/emails/ + wiki_link: https://wiki.jfa-go.com/docs/customization/emails/ settings: - setting: enabled name: Enabled @@ -917,7 +917,7 @@ sections: meta: name: Discord description: Settings for Discord invites/signup/notifications - wiki_link: https://wiki.jfa-go.com/docs/bots/discord/ + wiki_link: https://wiki.jfa-go.com/docs/external-services/bots/discord/ settings: - setting: enabled name: Enabled @@ -1012,7 +1012,7 @@ sections: name: Telegram description: Settings for Telegram signup/notifications. See the jfa-go wiki for info on setting this up. - wiki_link: https://wiki.jfa-go.com/docs/bots/telegram/ + wiki_link: https://wiki.jfa-go.com/docs/external-services/bots/telegram/ settings: - setting: enabled name: Enabled @@ -1061,7 +1061,7 @@ sections: name: Matrix description: Settings for Matrix invites/signup/notifications. See the jfa-go wiki for info on setting this up. - wiki_link: https://wiki.jfa-go.com/docs/bots/matrix/ + wiki_link: https://wiki.jfa-go.com/docs/external-services/bots/matrix/ settings: - setting: enabled name: Enabled @@ -1339,7 +1339,7 @@ sections: for accounts to be created, which you can do by refreshing then checking Settings > User Profiles. To handle password resets for Ombi & Jellyfin, enable "Use reset link instead of PIN". - wiki_link: https://wiki.jfa-go.com/docs/ombi/ + wiki_link: https://wiki.jfa-go.com/docs/external-services/ombi/ settings: - setting: enabled name: Enabled @@ -1647,7 +1647,7 @@ sections: description: jfa-go will send a POST request to these URLs when an event occurs, with relevant information. Request information is logged when debug logging is enabled. - wiki_link: https://wiki.jfa-go.com/docs/webhooks/ + wiki_link: https://wiki.jfa-go.com/docs/dev/webhooks/ settings: - setting: created name: User Created From fe20187b0c27c4624a723c8bd18071f6056c2987 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 25 Nov 2025 14:42:37 +0000 Subject: [PATCH 30/90] scripts: add "yaml" script takes over the "Order" validation of scripts/ini, and also re-orders the "Sections" section according to "Order". Used instead of copying config-base.yaml into the executable's data. --- Makefile | 2 +- scripts/yaml/go.mod | 18 +++++++ scripts/yaml/go.sum | 16 ++++++ scripts/yaml/main.go | 121 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 scripts/yaml/go.mod create mode 100644 scripts/yaml/go.sum create mode 100644 scripts/yaml/main.go diff --git a/Makefile b/Makefile index 5cc7019..6fde249 100644 --- a/Makefile +++ b/Makefile @@ -195,7 +195,7 @@ COPY_TARGET = $(DATA)/jfa-go.service # $(DATA)/LICENSE $(LANG_TARGET) $(STATIC_TARGET) $(DATA)/web/css/$(CSSVERSION)bundle.css $(COPY_TARGET): $(INLINE_TARGET) $(STATIC_SRC) $(LANG_SRC) $(CONFIG_BASE) $(info copying $(CONFIG_BASE)) - cp $(CONFIG_BASE) $(DATA)/ + go run scripts/yaml/main.go -in $(CONFIG_BASE) -out $(DATA)/$(shell basename $(CONFIG_BASE)) $(info copying crash page) cp $(DATA)/crash.html $(DATA)/html/ $(info copying static data) diff --git a/scripts/yaml/go.mod b/scripts/yaml/go.mod new file mode 100644 index 0000000..9bf7c82 --- /dev/null +++ b/scripts/yaml/go.mod @@ -0,0 +1,18 @@ +module github.com/hrfee/jfa-go/scripts/yaml + +replace github.com/hrfee/jfa-go/common => ../../common + +replace github.com/hrfee/jfa-go/logmessages => ../../logmessages + +go 1.22.4 + +require ( + github.com/fatih/color v1.18.0 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/hrfee/jfa-go/common v0.0.0-20251123201034-b1c578ccf49f // indirect + github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.25.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/scripts/yaml/go.sum b/scripts/yaml/go.sum new file mode 100644 index 0000000..e6f5eb9 --- /dev/null +++ b/scripts/yaml/go.sum @@ -0,0 +1,16 @@ +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/scripts/yaml/main.go b/scripts/yaml/main.go new file mode 100644 index 0000000..5d472f8 --- /dev/null +++ b/scripts/yaml/main.go @@ -0,0 +1,121 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + + "github.com/fatih/color" + "github.com/goccy/go-yaml" + "github.com/hrfee/jfa-go/common" +) + +func flattenOrder(c common.Config) (sections []string) { + var traverseGroup func(groupName string) []string + traverseGroup = func(groupName string) []string { + out := []string{} + for _, group := range c.Groups { + if group.Group == groupName { + for _, groupMember := range group.Members { + if groupMember.Group != "" { + out = append(out, traverseGroup(groupMember.Group)...) + } else if groupMember.Section != "" { + out = append(out, groupMember.Section) + } + } + break + } + } + return out + } + sections = make([]string, 0, len(c.Sections)) + for _, member := range c.Order { + if member.Group != "" { + sections = append(sections, traverseGroup(member.Group)...) + } else if member.Section != "" { + sections = append(sections, member.Section) + } + } + return +} + +func validateOrderCompleteness(c common.Config, sectOrder []string) (missing []string) { + listedSects := map[string]bool{} + for _, sect := range sectOrder { + listedSects[sect] = true + } + + for _, section := range c.Sections { + if _, ok := listedSects[section.Section]; !ok { + missing = append(missing, section.Section) + } + } + return missing +} + +func main() { + var inPath string + var outPath string + flag.StringVar(&inPath, "in", "", "Input of the config base in yaml.") + flag.StringVar(&outPath, "out", "", "Output of the checked and processed") + + flag.Parse() + + if inPath == "" { + panic(errors.New("invalid input path")) + } + if outPath == "" { + panic(errors.New("invalid output path")) + } + + yamlFile, err := os.ReadFile(inPath) + if err != nil { + panic(err) + } + info, err := os.Stat(inPath) + if err != nil { + panic(err) + } + + configBase := common.Config{} + err = yaml.Unmarshal(yamlFile, &configBase) + if err != nil { + panic(err) + } + + red := color.New(color.FgRed) + + if len(configBase.Order) > 0 { + sectOrder := flattenOrder(configBase) + missing := validateOrderCompleteness(configBase, sectOrder) + if len(missing) > 0 { + red.Fprintln(os.Stderr, "ERROR: Root order specified but the following sections were not listed, directly or indirectly:") + for _, section := range missing { + red.Fprintln(os.Stderr, "\t"+section) + } + os.Exit(1) + } + + sectionMap := map[string]common.Section{} + for _, sect := range configBase.Sections { + sectionMap[sect.Section] = sect + } + + for i, sect := range sectOrder { + configBase.Sections[i] = sectionMap[sect] + } + + fmt.Println("Re-ordered sections to follow root order.") + } + + bytes, err := yaml.Marshal(&configBase) + if err != nil { + panic(err) + } + + err = os.WriteFile(outPath, bytes, info.Mode()) + if err != nil { + panic(err) + } +} From 08c350d50b8ef68dda96f160ebbf0da522a05fcf Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 25 Nov 2025 14:43:56 +0000 Subject: [PATCH 31/90] settings: fix search with groups works now, as in searching for a group's name works, and seeing matches within groups works. --- scripts/ini/go.mod | 14 +- scripts/ini/go.sum | 9 +- scripts/ini/main.go | 43 ------ ts/modules/settings.ts | 331 +++++++++++++++++++++++++++++------------ 4 files changed, 254 insertions(+), 143 deletions(-) diff --git a/scripts/ini/go.mod b/scripts/ini/go.mod index 412489d..63b80b8 100644 --- a/scripts/ini/go.mod +++ b/scripts/ini/go.mod @@ -2,15 +2,21 @@ module github.com/hrfee/jfa-go/scripts/ini replace github.com/hrfee/jfa-go/common => ../../common +replace github.com/hrfee/jfa-go/logmessages => ../../logmessages + go 1.22.4 require ( - github.com/fatih/color v1.18.0 // indirect - github.com/hrfee/jfa-go/common v0.0.0-20240824141650-fcdd4e451882 // indirect + github.com/fatih/color v1.18.0 + github.com/hrfee/jfa-go/common v0.0.0-00010101000000-000000000000 + gopkg.in/ini.v1 v1.67.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/stretchr/testify v1.11.1 // indirect golang.org/x/sys v0.25.0 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/scripts/ini/go.sum b/scripts/ini/go.sum index 624d5d9..495a954 100644 --- a/scripts/ini/go.sum +++ b/scripts/ini/go.sum @@ -1,16 +1,21 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a h1:qbXZgCqb9eaPSJfLEXczQD2lxTv6jb6silMPIWW9j6o= -github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a/go.mod h1:c5HKkLayo0GrEUDlJwT12b67BL9cdPjP271Xlv/KDRQ= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= diff --git a/scripts/ini/main.go b/scripts/ini/main.go index 3221462..9e68358 100644 --- a/scripts/ini/main.go +++ b/scripts/ini/main.go @@ -7,7 +7,6 @@ import ( "os" "strings" - "github.com/fatih/color" "github.com/hrfee/jfa-go/common" "gopkg.in/ini.v1" "gopkg.in/yaml.v3" @@ -27,48 +26,6 @@ func generateIni(yamlPath string, iniPath string) { if err != nil { panic(err) } - // Validate that all groups/sections are listed in the root order, if it exists - if len(configBase.Order) > 0 { - // Expand order - var traverseGroup func(groupName string) []string - traverseGroup = func(groupName string) []string { - out := []string{} - for _, group := range configBase.Groups { - if group.Group == groupName { - for _, groupMember := range group.Members { - if groupMember.Group != "" { - out = append(out, traverseGroup(groupMember.Group)...) - } else if groupMember.Section != "" { - out = append(out, groupMember.Section) - } - } - break - } - } - return out - } - listedSects := map[string]bool{} - for _, member := range configBase.Order { - if member.Group != "" { - for _, sect := range traverseGroup(member.Group) { - listedSects[sect] = true - } - } else if member.Section != "" { - listedSects[member.Section] = true - } - } - - missingSections := false - for _, section := range configBase.Sections { - if _, ok := listedSects[section.Section]; !ok { - if !missingSections { - color.Red("WARNING: Root order specified but the following sections were not listed, directly or indirectly:") - missingSections = true - } - color.Red("\t%s", section.Section) - } - } - } conf := ini.Empty() diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts index a4ab749..9162226 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -479,6 +479,168 @@ interface Group { members: Member[]; } +class groupButton { + private _el: HTMLElement; + private _button: HTMLElement; + private _dropdown: HTMLElement; + private _icon: HTMLElement; + private _check: HTMLInputElement; + private _group: Group; + private _indent: number; + private _parentSidebar: HTMLElement; + + asElement = () => { return this._el; }; + + remove = () => { this._el.remove(); }; + + update = (g: Group) => { + this._group = g; + this.group = g.group; + this.name = g.name; + this.description = g.description; + }; + + append(item: HTMLElement|groupButton) { + if (item instanceof groupButton) { + this._dropdown.appendChild(item.asElement()); + } else { + this._dropdown.appendChild(item); + } + } + + get name(): string { return this._group.name; } + set name(v: string) { + this._group.name = v; + this._button.querySelector(".group-button-name").textContent = v; + } + + get group(): string { return this._group.group; } + set group(v: string) { + this._group.group = v; + this._el.setAttribute("data-group", v); + this._button.setAttribute("data-group", v); + this._check.setAttribute("data-group", v); + this._dropdown.setAttribute("data-group", v); + } + + get description(): string { return this._group.description; } + set description(v: string) { this._group.description = v; } + + get indent(): number { return this._indent; } + set indent(v: number) { + this._dropdown.classList.remove("ml-" + ((this._indent+1)*2)); + this._indent = v; + this._dropdown.classList.add("ml-" + ((this._indent+1)*2)); + } + + get hidden(): boolean { return this._el.classList.contains("unfocused"); } + set hidden(v: boolean) { + if (v) this._el.classList.add("unfocused"); + else this._el.classList.remove("unfocused"); + } + + get open(): boolean { return this._check.checked; } + set open(v: boolean) { + this.openCloseWithAnimation(v); + } + + openCloseWithAnimation(v: boolean) { + this._check.checked = v; + // When groups are nested, the outer group's scrollHeight will obviously change when an + // inner group is opened/closed. Instead of traversing the tree and adjusting the maxHeight property + // each open/close, just set the maxHeight to 9999px once the animation is completed. + // On close, quickly set maxHeight back to ~scrollHeight, then animate to 0. + if (this._check.checked) { + this._icon.classList.add("rotated"); + // Hide the scrollbar while we animate + this._parentSidebar.style.overflowY = "hidden"; + this._dropdown.classList.remove("unfocused"); + const fullHeight = () => { + this._dropdown.removeEventListener("transitionend", fullHeight); + this._dropdown.style.maxHeight = "9999px"; + // Return the scrollbar (or whatever, just don't hide it) + this._parentSidebar.style.overflowY = ""; + }; + this._dropdown.addEventListener("transitionend", fullHeight); + this._dropdown.style.maxHeight = (1.2*this._dropdown.scrollHeight)+"px"; + this._dropdown.style.opacity = "100%"; + } else { + this._icon.classList.remove("rotated"); + const mainTransitionEnd = () => { + this._dropdown.removeEventListener("transitionend", mainTransitionEnd); + this._dropdown.classList.add("unfocused"); + // Return the scrollbar (or whatever, just don't hide it) + this._parentSidebar.style.overflowY = ""; + }; + const mainTransitionStart = () => { + this._dropdown.removeEventListener("transitionend", mainTransitionStart) + this._dropdown.style.transitionDuration = ""; + this._dropdown.addEventListener("transitionend", mainTransitionEnd); + this._dropdown.style.maxHeight = "0"; + this._dropdown.style.opacity = "0"; + } + // Hide the scrollbar while we animate + this._parentSidebar.style.overflowY = "hidden"; + // Disabling transitions then going from 9999 - scrollHeight doesn't work in firefox to me, + // so instead just make the transition duration really short. + this._dropdown.style.transitionDuration = "1ms"; + this._dropdown.addEventListener("transitionend", mainTransitionStart); + this._dropdown.style.maxHeight = (1.2*this._dropdown.scrollHeight)+"px"; + } + } + + openCloseWithoutAnimation(v: boolean) { + this._check.checked = v; + if (this._check.checked) { + this._icon.classList.add("rotated"); + this._dropdown.style.maxHeight = "9999px"; + this._dropdown.style.opacity = "100%"; + this._dropdown.classList.remove("unfocused"); + } else { + this._icon.classList.remove("rotated"); + this._dropdown.style.maxHeight = "0"; + this._dropdown.style.opacity = "0"; + this._dropdown.classList.add("unfocused"); + } + } + + // Takes sidebar as we need to disable scrolling on it when animation starts. + constructor(parentSidebar: HTMLElement) { + this._parentSidebar = parentSidebar; + + this._el = document.createElement("div"); + this._el.classList.add("flex", "flex-col", "gap-2"); + + this._button = document.createElement("span") as HTMLSpanElement; + this._el.appendChild(this._button); + this._button.classList.add("button", "~neutral", "@low", "settings-section-button", "justify-between"); + this._button.innerHTML = ` + + + `; + + this._dropdown = document.createElement("div") as HTMLDivElement; + this._el.appendChild(this._dropdown); + this._dropdown.style.maxHeight = "0"; + this._dropdown.style.opacity = "0"; + this._dropdown.classList.add("settings-dropdown", "unfocused", "flex", "flex-col", "gap-2", "transition-all"); + + this._icon = this._button.querySelector("i.icon"); + this._check = this._button.querySelector("input[type=checkbox]") as HTMLInputElement; + + this._button.onclick = () => { + this.open = !this.open; + }; + this._check.onclick = () => { + this.open = this.open; + } + this.openCloseWithoutAnimation(false); + } +}; + interface Section { section: string; meta: Meta; @@ -595,7 +757,7 @@ export class settingsList { private _buttons: { [name: string]: HTMLSpanElement }; private _groups: { [name: string]: Group }; - private _groupButtons: { [name: string]: HTMLSpanElement }; + private _groupButtons: { [name: string]: groupButton }; private _needsRestart: boolean = false; private _messageEditor = new MessageEditor(); @@ -614,82 +776,12 @@ export class settingsList { // Takes all groups at once since members might contain each other. addGroups = (groups: Group[]) => { groups.forEach((g) => { this._groups[g.group] = g }); - const addGroup = (g: Group, indent: number = 0): HTMLElement => { + const addGroup = (g: Group, indent: number = 0): groupButton => { if (g.group in this._groupButtons) return null; - const container = document.createElement("div") as HTMLDivElement; - container.classList.add("flex", "flex-col", "gap-2"); - - const button = document.createElement("span") as HTMLSpanElement; - container.appendChild(button); - button.classList.add("button", "~neutral", "@low", "settings-section-button", "justify-between"); - button.innerHTML = ` - ${g.name} - - `; - - const dropdown = document.createElement("div") as HTMLDivElement; - container.appendChild(dropdown); - dropdown.classList.add("ml-" + ((indent+1)*2)); - dropdown.style.maxHeight = "0"; - dropdown.style.opacity = "0"; - dropdown.classList.add("settings-dropdown", "unfocused", "flex", "flex-col", "gap-2", "transition-all"); - - const icon = button.querySelector("i.icon"); - const check = button.querySelector("input[type=checkbox]") as HTMLInputElement; - - - button.onclick = () => { - check.checked = !check.checked; - onCheck(); - }; - // When groups are nested, the outer group's scrollHeight will obviously change when an - // inner group is opened/closed. Instead of traversing the tree and adjusting the maxHeight property - // each open/close, just set the maxHeight to 9999px once the animation is completed. - // On close, quickly set maxHeight back to ~scrollHeight, then animate to 0. - const onCheck = () => { - if (check.checked) { - icon.classList.add("rotated"); - // Hide the scrollbar while we animate - this._sidebar.style.overflowY = "hidden"; - dropdown.classList.remove("unfocused"); - const fullHeight = () => { - dropdown.removeEventListener("transitionend", fullHeight); - dropdown.style.maxHeight = "9999px"; - // Return the scrollbar (or whatever, just don't hide it) - this._sidebar.style.overflowY = ""; - }; - dropdown.addEventListener("transitionend", fullHeight); - dropdown.style.maxHeight = (1.2*dropdown.scrollHeight)+"px"; - dropdown.style.opacity = "100%"; - } else { - icon.classList.remove("rotated"); - const mainTransitionEnd = () => { - dropdown.removeEventListener("transitionend", mainTransitionEnd); - dropdown.classList.add("unfocused"); - // Return the scrollbar (or whatever, just don't hide it) - this._sidebar.style.overflowY = ""; - }; - const mainTransitionStart = () => { - dropdown.removeEventListener("transitionend", mainTransitionStart) - dropdown.style.transitionDuration = ""; - dropdown.addEventListener("transitionend", mainTransitionEnd); - dropdown.style.maxHeight = "0"; - dropdown.style.opacity = "0"; - } - // Hide the scrollbar while we animate - this._sidebar.style.overflowY = "hidden"; - // Disabling transitions then going from 9999 - scrollHeight doesn't work in firefox to me, - // so instead just make the transition duration really short. - dropdown.style.transitionDuration = "1ms"; - dropdown.addEventListener("transitionend", mainTransitionStart); - dropdown.style.maxHeight = (1.2*dropdown.scrollHeight)+"px"; - } - } - check.onchange = onCheck; + const container = new groupButton(this._sidebar); + container.update(g); + container.indent = indent; for (const member of g.members) { if ("group" in member) { @@ -699,12 +791,12 @@ export class settingsList { // Remove from page subgroup.remove(); } - dropdown.appendChild(subgroup); + container.append(subgroup); } else if ("section" in member) { const subsection = this._buttons[member.section]; // Remove from page subsection.remove(); - dropdown.appendChild(subsection); + container.append(subsection); } } @@ -714,7 +806,8 @@ export class settingsList { for (let g of groups) { const container = addGroup(g); if (container) { - this._sidebar.appendChild(container); + this._sidebar.appendChild(container.asElement()); + container.openCloseWithoutAnimation(false); } } } @@ -762,11 +855,27 @@ export class settingsList { this._sidebar.appendChild(this._buttons[name]); } - setOrder(order: Member[]) { + private _traverseMemberList = (list: Member[], func: (sect: string) => void) => { + for (const member of list) { + if ("group" in member) { + for (const group of this._settings.groups) { + if (group.group == member.group) { + this._traverseMemberList(group.members, func); + break; + } + } + } else { + func(member.section); + } + } + } + + setUIOrder(order: Member[]) { this._sidebar.textContent = ``; for (const member of order) { if ("group" in member) { - this._sidebar.appendChild(this._groupButtons[member.group]); + this._sidebar.appendChild(this._groupButtons[member.group].asElement()); + this._groupButtons[member.group].openCloseWithoutAnimation(false); } else if ("section" in member) { if (member.section in this._buttons) { this._sidebar.appendChild(this._buttons[member.section]); @@ -1079,7 +1188,7 @@ export class settingsList { this.addGroups(this._settings.groups); - if ("order" in this._settings && this._settings.order) this.setOrder(this._settings.order); + if ("order" in this._settings && this._settings.order) this.setUIOrder(this._settings.order); removeLoader(this._loader); for (let i = 0; i < this._loader.children.length; i++) { @@ -1097,6 +1206,7 @@ export class settingsList { }) }; + // FIXME: Fix searching groups // FIXME: Search "About" & "User profiles", pseudo-search "User profiles" for things like "Ombi", "Referrals", etc. search = (query: string) => { query = query.toLowerCase().trim(); @@ -1104,11 +1214,20 @@ export class settingsList { if (query.replace(/\s+/g, "") == "") query = ""; let firstVisibleSection = ""; - for (let section of this._settings.sections) { + + // Close and hide all groups to start with + for (const groupButton of Object.values(this._groupButtons)) { + groupButton.openCloseWithoutAnimation(false); + groupButton.hidden = !(groupButton.group.toLowerCase().includes(query) || + groupButton.name.toLowerCase().includes(query) || + groupButton.description.toLowerCase().includes(query)); + } + + const searchSection = (section: Section) => { // Section might be disabled at build-time (like Updates), or deprecated and so not appear. if (!(section.section in this._sections)) { // console.log(`Couldn't find section "${section.section}"`); - continue + return; } const sectionElement = this._sections[section.section].asElement(); let dependencyCard = sectionElement.querySelector(".settings-dependency-message"); @@ -1117,19 +1236,38 @@ export class settingsList { let dependencyList = null; // hide button, unhide if matched - this._buttons[section.section].classList.add("unfocused"); + const button = this._buttons[section.section]; + button.classList.add("unfocused"); + const parentGroup = button.parentElement.getAttribute("data-group"); + let parentGroupButton: groupButton = null; + let matchedGroup = false; + if (parentGroup) { + parentGroupButton = this._groupButtons[parentGroup]; + matchedGroup = !(parentGroupButton.hidden); + } - let matchedSection = false; - - if (section.section.toLowerCase().includes(query) || - section.meta.name.toLowerCase().includes(query) || - section.meta.description.toLowerCase().includes(query)) { - if ((section.meta.advanced && this._advanced) || !(section.meta.advanced)) { - this._buttons[section.section].classList.remove("unfocused"); - firstVisibleSection = firstVisibleSection || section.section; - matchedSection = true; + const show = () => { + button.classList.remove("unfocused"); + if (parentGroupButton) { + if (query != "") parentGroupButton.openCloseWithoutAnimation(true); + parentGroupButton.hidden = false; } } + const hide = () => { + button.classList.add("unfocused"); + } + + let matchedSection = matchedGroup || + section.section.toLowerCase().includes(query) || + section.meta.name.toLowerCase().includes(query) || + section.meta.description.toLowerCase().includes(query); + matchedSection &&= ((section.meta.advanced && this._advanced) || !(section.meta.advanced)); + + if (matchedSection) { + show(); + firstVisibleSection = firstVisibleSection || section.section; + } + for (let setting of section.settings) { if (setting.type == "note") continue; const element = sectionElement.querySelector(`div[data-name="${setting.setting}"]`) as HTMLElement; @@ -1153,7 +1291,7 @@ export class settingsList { setting.description.toLowerCase().includes(query) || String(setting.value).toLowerCase().includes(query)) { if ((section.meta.advanced && this._advanced) || !(section.meta.advanced)) { - this._buttons[section.section].classList.remove("unfocused"); + show(); firstVisibleSection = firstVisibleSection || section.section; } const shouldShow = (query != "" && @@ -1198,7 +1336,12 @@ export class settingsList { } } } + }; + + for (let section of this._settings.sections) { + searchSection(section); } + if (firstVisibleSection && (query != "" || this._visibleSection == "")) { this._buttons[firstVisibleSection].onclick(null); this._noResultsPanel.classList.add("unfocused"); From a680db92a7b33fab72c9a5605b707fbb195eb4c9 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 25 Nov 2025 14:56:53 +0000 Subject: [PATCH 32/90] settings: fix group indent obviously groups don't need an increased margin value, they already have a margin from the parent! Also increased it to ml-6. --- ts/modules/settings.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts index 9162226..dee4d0f 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -489,6 +489,8 @@ class groupButton { private _indent: number; private _parentSidebar: HTMLElement; + private static readonly _margin = "ml-6"; + asElement = () => { return this._el; }; remove = () => { this._el.remove(); }; @@ -528,9 +530,9 @@ class groupButton { get indent(): number { return this._indent; } set indent(v: number) { - this._dropdown.classList.remove("ml-" + ((this._indent+1)*2)); + this._dropdown.classList.remove(groupButton._margin); this._indent = v; - this._dropdown.classList.add("ml-" + ((this._indent+1)*2)); + this._dropdown.classList.add(groupButton._margin); } get hidden(): boolean { return this._el.classList.contains("unfocused"); } From 442bdd2220857b5f8f970f9e807869b099583e57 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 25 Nov 2025 15:04:24 +0000 Subject: [PATCH 33/90] settings: leave groups opened/closed on advanced settings toggle --- ts/modules/settings.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts index dee4d0f..2370a68 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -1208,17 +1208,25 @@ export class settingsList { }) }; + + private _query: string; // FIXME: Fix searching groups // FIXME: Search "About" & "User profiles", pseudo-search "User profiles" for things like "Ombi", "Referrals", etc. search = (query: string) => { query = query.toLowerCase().trim(); // Make sure a blank search is detected when there's just whitespace. if (query.replace(/\s+/g, "") == "") query = ""; + const noChange = query == this._query; let firstVisibleSection = ""; // Close and hide all groups to start with for (const groupButton of Object.values(this._groupButtons)) { + // Leave these opened/closed if the query didn't change + // (this is overridden anyway if an actual search is happening, + // so we'll only do it if the search is blank, implying something else + // changed like advanced settings being enabled). + if (noChange && query == "") continue; groupButton.openCloseWithoutAnimation(false); groupButton.hidden = !(groupButton.group.toLowerCase().includes(query) || groupButton.name.toLowerCase().includes(query) || @@ -1355,6 +1363,9 @@ export class settingsList { this._visibleSection = ""; } } + + // We can use this later to tell if we should leave groups expanded/closed as they were. + this._query = query; } } From 3178ca7572264a934341520b5cbe7ba0bd1015fa Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 25 Nov 2025 17:13:43 +0000 Subject: [PATCH 34/90] settings: remove badge note, add tooltips to them change required and restart required badges to icons with tooltips, and removed the note at the top of settings. As a result, the sidebar is much thinner. --- css/tooltip.css | 10 ++++++---- html/admin.html | 1 - lang/admin/en-us.json | 4 +++- ts/modules/settings.ts | 45 +++++++++++++++++++++++++++--------------- 4 files changed, 38 insertions(+), 22 deletions(-) diff --git a/css/tooltip.css b/css/tooltip.css index fc92a1d..c6a193c 100644 --- a/css/tooltip.css +++ b/css/tooltip.css @@ -22,15 +22,17 @@ } .tooltip.below .content { - top: 2.5rem; - left: 0; + top: calc(100% + 0.125rem); + left: 50%; right: 0; + transform: translateX(-50%); } .tooltip.above .content { - bottom: 2.5rem; - left: 0; + bottom: calc(100% + 0.125rem); + left: 50%; right: 0; + transform: translateX(-50%); } .tooltip.darker .content { diff --git a/html/admin.html b/html/admin.html index fddb5c8..c8869a6 100644 --- a/html/admin.html +++ b/html/admin.html @@ -921,7 +921,6 @@
-
{{ .strings.aboutProgram }} {{ .strings.wiki }} diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index 65e0244..30fc673 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -209,7 +209,9 @@ "backupCanBeFound": "The backup can be found on the server at {filepath}.", "backupCanDownload": "Alternatively, click below to download the backup.", "wikiPage": "Wiki Page", - "wiki": "Wiki" + "wiki": "Wiki", + "restartRequired": "Restart required", + "required": "Required" }, "notifications": { "pathCopied": "Full path copied to clipboard.", diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts index 2370a68..ea53477 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -74,6 +74,9 @@ const splitDependant = (section: string, dep: string): string[] => { return parts }; +let RestartRequiredBadge: HTMLElement; +let RequiredBadge: HTMLElement; + class DOMSetting { protected _hideEl: HTMLElement; protected _input: HTMLInputElement; @@ -133,12 +136,10 @@ class DOMSetting { set required(state: boolean) { if (state) { this._required.classList.remove("unfocused"); - this._required.classList.add("badge", "~critical"); - this._required.textContent = "*"; + this._required.innerHTML = RequiredBadge.outerHTML; } else { this._required.classList.add("unfocused"); - this._required.classList.remove("badge", "~critical"); - this._required.textContent = ""; + this._required.textContent = ``; } } @@ -146,12 +147,10 @@ class DOMSetting { set requires_restart(state: boolean) { if (state) { this._restart.classList.remove("unfocused"); - this._restart.classList.add("badge", "~info", "dark:~d_warning"); - this._restart.textContent = "R"; + this._restart.innerHTML = RestartRequiredBadge.outerHTML; } else { this._restart.classList.add("unfocused"); - this._restart.classList.remove("badge", "~info", "dark:~d_warning"); - this._restart.textContent = ""; + this._restart.textContent = ``; } } @@ -619,7 +618,7 @@ class groupButton { this._button.innerHTML = ` `; @@ -1072,14 +1071,28 @@ export class settingsList { this._searchbox.oninput(null); }; }; + + // Create (restart)required badges (can't do on load as window.lang is unset) + RestartRequiredBadge = (() => { + const rr = document.createElement("span"); + rr.classList.add("tooltip", "below"); + rr.innerHTML = ` + + ${window.lang.strings("restartRequired")} + `; - // What possessed me to put this in the DOMSelect constructor originally? like what???????? - const message = document.getElementById("settings-message") as HTMLElement; - message.innerHTML = window.lang.var("strings", - "settingsRequiredOrRestartMessage", - `*`, - `R` - ); + return rr; + })(); + RequiredBadge = (() => { + const r = document.createElement("span"); + r.classList.add("tooltip", "below"); + r.innerHTML = ` + + ${window.lang.strings("required")} + `; + + return r; + })(); } private _addMatrix = () => { From 909614c3e7a48d80eab977db3e7e83d0d0ba5cbb Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 25 Nov 2025 20:49:24 +0000 Subject: [PATCH 35/90] invites: add details expand transition why not. --- html/admin.html | 2 +- ts/modules/invites.ts | 78 +++++++++++++++++++++++++++++++++--------- ts/modules/settings.ts | 2 ++ 3 files changed, 65 insertions(+), 17 deletions(-) diff --git a/html/admin.html b/html/admin.html index c8869a6..8ad6dd6 100644 --- a/html/admin.html +++ b/html/admin.html @@ -557,7 +557,7 @@
{{ .strings.invites }} -
+
{{ .strings.create }} diff --git a/ts/modules/invites.ts b/ts/modules/invites.ts index 3f8b722..ba73e8d 100644 --- a/ts/modules/invites.ts +++ b/ts/modules/invites.ts @@ -248,22 +248,65 @@ class DOMInvite implements Invite { private _right: HTMLDivElement; private _userTable: HTMLDivElement; + private _detailsToggle: HTMLInputElement; + // whether the details card is expanded. get expanded(): boolean { - return this._details.classList.contains("focused"); + return this._detailsToggle.checked; } set expanded(state: boolean) { - const toggle = (this._infoArea.querySelector("input.inv-toggle-details") as HTMLInputElement); + this._detailsToggle.checked = state; if (state) { + this._detailsToggle.previousElementSibling.classList.add("rotated"); + this._detailsToggle.previousElementSibling.classList.remove("not-rotated"); + this._details.classList.remove("unfocused"); this._details.classList.add("focused"); - toggle.previousElementSibling.classList.add("rotated"); - toggle.previousElementSibling.classList.remove("not-rotated"); + const fullHeight = () => { + this._details.removeEventListener("transitionend", fullHeight); + this._details.style.maxHeight = "9999px"; + }; + this._details.addEventListener("transitionend", fullHeight); + this._details.style.maxHeight = (1*this._details.scrollHeight)+"px"; + this._details.style.opacity = "100%"; } else { + this._detailsToggle.previousElementSibling.classList.remove("rotated"); + this._detailsToggle.previousElementSibling.classList.add("not-rotated"); + const mainTransitionEnd = () => { + this._details.removeEventListener("transitionend", mainTransitionEnd); + this._details.classList.add("unfocused"); + this._details.classList.remove("focused"); + }; + const mainTransitionStart = () => { + this._details.removeEventListener("transitionend", mainTransitionStart); + this._details.style.transitionDuration = ""; + this._details.addEventListener("transitionend", mainTransitionEnd); + this._details.style.maxHeight = "0"; + this._details.style.opacity = "0"; + }; + this._details.style.transitionDuration = "1ms"; + this._details.addEventListener("transitionend", mainTransitionStart); + this._details.style.maxHeight = (1*this._details.scrollHeight)+"px"; + } + } + + setExpandedWithoutAnimation(state: boolean) { + this._detailsToggle.checked = state; + if (state) { + this._detailsToggle.previousElementSibling.classList.add("rotated"); + this._detailsToggle.previousElementSibling.classList.remove("not-rotated"); + + this._details.classList.remove("unfocused"); + this._details.classList.add("focused"); + this._details.style.maxHeight = "9999px"; + this._details.style.opacity = "100%"; + } else { + this._detailsToggle.previousElementSibling.classList.remove("rotated"); + this._detailsToggle.previousElementSibling.classList.add("not-rotated"); this._details.classList.add("unfocused"); this._details.classList.remove("focused"); - toggle.previousElementSibling.classList.remove("rotated"); - toggle.previousElementSibling.classList.add("not-rotated"); + this._details.style.maxHeight = "0"; + this._details.style.opacity = "0"; } } @@ -272,11 +315,11 @@ class DOMInvite implements Invite { constructor(invite: Invite) { // first create the invite structure, then use our setter methods to fill in the data. this._container = document.createElement('div') as HTMLDivElement; - this._container.classList.add("inv", "overflow-visible"); + this._container.classList.add("inv", "overflow-visible", "flex", "flex-col", "gap-2"); this._header = document.createElement('div') as HTMLDivElement; this._container.appendChild(this._header); - this._header.classList.add("card", "dark:~d_neutral", "@low", "inv-header", "flex", "flex-row", "justify-between", "mt-2", "overflow-visible", "gap-2"); + this._header.classList.add("card", "dark:~d_neutral", "@low", "inv-header", "flex", "flex-row", "justify-between", "overflow-visible", "gap-2"); this._codeArea = document.createElement('div') as HTMLDivElement; this._header.appendChild(this._codeArea); @@ -314,15 +357,17 @@ class DOMInvite implements Invite {
${window.lang.strings("delete")} `; (this._infoArea.querySelector(".inv-delete") as HTMLSpanElement).onclick = this.delete; - const toggle = (this._infoArea.querySelector("input.inv-toggle-details") as HTMLInputElement); - toggle.onchange = () => { this.expanded = !this.expanded; }; + this._detailsToggle = (this._infoArea.querySelector("input.inv-toggle-details") as HTMLInputElement); + this._detailsToggle.onclick = () => { + this.expanded = this.expanded; + }; const toggleDetails = (event: Event) => { if (event.target == this._header || event.target == this._codeArea || event.target == this._infoArea) { this.expanded = !this.expanded; @@ -333,7 +378,9 @@ class DOMInvite implements Invite { this._details = document.createElement('div') as HTMLDivElement; this._container.appendChild(this._details); - this._details.classList.add("card", "~neutral", "@low", "mt-2", "inv-details"); + this._details.classList.add("card", "~neutral", "@low", "inv-details", "transition-all", "unfocused"); + this._details.style.maxHeight = "0"; + this._details.style.opacity = "0"; const detailsInner = document.createElement('div') as HTMLDivElement; this._details.appendChild(detailsInner); detailsInner.classList.add("inv-row", "flex", "flex-row", "flex-wrap", "justify-between", "gap-4"); @@ -394,8 +441,7 @@ class DOMInvite implements Invite { this._userTable.classList.add("text-sm", "mt-1", ); this._right.appendChild(this._userTable); - - this.expanded = false; + this.setExpandedWithoutAnimation(false); this.update(invite); document.addEventListener("profileLoadEvent", () => { this.loadProfiles(); }, false); @@ -440,7 +486,7 @@ export class inviteList implements inviteList { focusInvite = (inviteCode: string, errorMsg: string = window.lang.notif("errorInviteNoLongerExists")) => { for (let code of Object.keys(this.invites)) { - this.invites[code].expanded = code == inviteCode; + this.invites[code].setExpandedWithoutAnimation(code == inviteCode); } if (inviteCode in this.invites) this.invites[inviteCode].focus(); else window.notifications.customError("inviteDoesntExistError", errorMsg); @@ -488,7 +534,7 @@ export class inviteList implements inviteList { this._list.classList.add("empty"); this._list.innerHTML = `
-
+
${window.lang.strings("inviteNoInvites")}
diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts index ea53477..084a329 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -553,6 +553,7 @@ class groupButton { // On close, quickly set maxHeight back to ~scrollHeight, then animate to 0. if (this._check.checked) { this._icon.classList.add("rotated"); + this._icon.classList.remove("not-rotated"); // Hide the scrollbar while we animate this._parentSidebar.style.overflowY = "hidden"; this._dropdown.classList.remove("unfocused"); @@ -566,6 +567,7 @@ class groupButton { this._dropdown.style.maxHeight = (1.2*this._dropdown.scrollHeight)+"px"; this._dropdown.style.opacity = "100%"; } else { + this._icon.classList.add("not-rotated"); this._icon.classList.remove("rotated"); const mainTransitionEnd = () => { this._dropdown.removeEventListener("transitionend", mainTransitionEnd); From 875387166e57b3b9cee0e4a6d34433d4a375ea5e Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 25 Nov 2025 21:03:20 +0000 Subject: [PATCH 36/90] settings: fix weirdness on mobile the check and button's onclick were both firing occasionally, so added check to button.onclick that the target isn't the check or it's icon, as I did for the invite details toggle. --- ts/modules/settings.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts index 084a329..3f97c59 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -542,6 +542,7 @@ class groupButton { get open(): boolean { return this._check.checked; } set open(v: boolean) { + console.trace("set", v); this.openCloseWithAnimation(v); } @@ -581,7 +582,7 @@ class groupButton { this._dropdown.addEventListener("transitionend", mainTransitionEnd); this._dropdown.style.maxHeight = "0"; this._dropdown.style.opacity = "0"; - } + }; // Hide the scrollbar while we animate this._parentSidebar.style.overflowY = "hidden"; // Disabling transitions then going from 9999 - scrollHeight doesn't work in firefox to me, @@ -634,8 +635,8 @@ class groupButton { this._icon = this._button.querySelector("i.icon"); this._check = this._button.querySelector("input[type=checkbox]") as HTMLInputElement; - this._button.onclick = () => { - this.open = !this.open; + this._button.onclick = (event: Event) => { + if (event.target != this._icon && event.target != this._check) this.open = !this.open; }; this._check.onclick = () => { this.open = this.open; From 5e653c51f318c096d398e0ac67bb6a3d118aa494 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Wed, 26 Nov 2025 15:30:45 +0000 Subject: [PATCH 37/90] accounts: add "extend from previous expiry" If the expiry time of an expired user is still in the activity log, extending and re-enabling a user with this option checked will extend the expiry from this time, rather than the current time. For #379, i think this is basically what they wanted. --- api-users.go | 18 ++++++++ css/tooltip.css | 3 +- html/admin.html | 48 +++++++++++--------- lang/admin/en-us.json | 2 + logmessages/logmessages.go | 6 ++- models.go | 17 +++---- ts/modules/accounts.ts | 93 +++++++++++++++++++++++++------------- 7 files changed, 124 insertions(+), 63 deletions(-) diff --git a/api-users.go b/api-users.go index 51f59bd..13aa233 100644 --- a/api-users.go +++ b/api-users.go @@ -525,6 +525,24 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) { base := time.Now() if expiry, ok := app.storage.GetUserExpiryKey(id); ok { base = expiry.Expiry + app.debug.Printf(lm.FoundExistingExpiry) + } else if req.TryExtendFromPreviousExpiry { + var acts []Activity + app.storage.db.Find(&acts, badgerhold.Where("Type").Eq(ActivityDisabled).And("UserID").Eq(id).SortBy("Time").Reverse().Limit(1)) + if len(acts) != 0 { + // Only do it if the most recent reason for disabling was expiry + if acts[0].SourceType == ActivityDaemon { + app.debug.Printf(lm.FoundPreviousExpiryLog, acts[0].Time) + newExpiry := acts[0].Time.AddDate(0, req.Months, req.Days).Add(time.Duration(((60 * req.Hours) + req.Minutes)) * time.Minute) + if newExpiry.After(base) { + base = acts[0].Time + } else { + app.debug.Printf(lm.ExpiryWouldBeInPast) + } + } else { + app.debug.Printf(lm.PreviousExpiryNotExpiry) + } + } } app.debug.Printf(lm.ExtendCreateExpiry, id) expiry := UserExpiry{} diff --git a/css/tooltip.css b/css/tooltip.css index c6a193c..ab4d187 100644 --- a/css/tooltip.css +++ b/css/tooltip.css @@ -6,7 +6,7 @@ .tooltip .content { visibility: hidden; opacity: 0; - max-width: 10rem; + max-width: 16rem; min-width: 6rem; background-color: rgba(0, 0, 0, 0.6); color: #fff; @@ -29,6 +29,7 @@ } .tooltip.above .content { + top: unset; bottom: calc(100% + 0.125rem); left: 50%; right: 0; diff --git a/html/admin.html b/html/admin.html index 8ad6dd6..580963b 100644 --- a/html/admin.html +++ b/html/admin.html @@ -186,56 +186,60 @@