Diffstat (limited to 'internal')
| -rw-r--r-- | internal/b2/client.go | 7 | ||||
| -rw-r--r-- | internal/blogtrigger/blogtrigger.go | 72 | ||||
| -rw-r--r-- | internal/mailer/mailer.go | 208 | ||||
| -rw-r--r-- | internal/router/api-v1-email-send-verification-code.go | 2 | ||||
| -rw-r--r-- | internal/router/api-v1-general-page-body.go | 4 | ||||
| -rw-r--r-- | internal/router/api-v1-subs.go | 2 | ||||
| -rw-r--r-- | internal/router/client-cache.go | 18 | ||||
| -rw-r--r-- | internal/router/lang-user-unsubscribe.go | 71 |
8 files changed, 366 insertions, 18 deletions
diff --git a/internal/b2/client.go b/internal/b2/client.go index 7b745a8..d712736 100644 --- a/internal/b2/client.go +++ b/internal/b2/client.go @@ -3,6 +3,7 @@ package b2 import ( "context" "fmt" + "slices" "strings" "time" @@ -34,6 +35,7 @@ func NewB2Client(cfg *config.B2Config) (*B2Client, error) { type BlogPage struct { Link string FileName string + Lang string Metadata *frontmatter.Metadata } @@ -74,6 +76,9 @@ func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) { nameParts := strings.Split(linkParts[len(linkParts)-1], ".") fileName := strings.Join(nameParts[:len(linkParts)-1], ".") + tags := strings.Split(attrs.Info["tags"], ",") + slices.Sort(tags) + filePaths = append(filePaths, &BlogPage{ Link: obj.Name(), FileName: fileName, @@ -83,7 +88,7 @@ func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) { ActionDate: attrs.Info["action-date"], PublishedTime: publishedTime, Thumbnail: attrs.Info["thumbnail"], - Tags: strings.Split(attrs.Info["tags"], ","), + Tags: tags, Geolocation: attrs.Info["geolocation"], }, }) diff --git a/internal/blogtrigger/blogtrigger.go b/internal/blogtrigger/blogtrigger.go new file mode 100644 index 0000000..c5b3097 --- /dev/null +++ b/internal/blogtrigger/blogtrigger.go @@ -0,0 +1,72 @@ +package blogtrigger + +import ( + "fmt" + "log/slog" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/go-co-op/gocron/v2" +) + +type BlogTriggerScheduler struct { + s gocron.Scheduler + knownBlogPages map[string]map[string]*b2.BlogPage + b2Client *b2.B2Client + onTrigger func([]*b2.BlogPage) error +} + +func NewBlogTriggerScheduler(b2Client *b2.B2Client, availableLanguages []config.AvailableLanguageConfig, cron string, onTrigger func([]*b2.BlogPage) error) (*BlogTriggerScheduler, error) { + s, err := gocron.NewScheduler() + if err != nil { + return nil, fmt.Errorf("failed to create new scheduler: %w", err) + } + + knownBlogPages := make(map[string]map[string]*b2.BlogPage, len(availableLanguages)) + for _, lang := range availableLanguages { + knownBlogPages[lang.Name] = make(map[string]*b2.BlogPage) + } + + bts := &BlogTriggerScheduler{s, knownBlogPages, b2Client, onTrigger} + defer bts.s.Start() + + bts.s.NewJob(gocron.CronJob(cron, false), gocron.NewTask(func(bts *BlogTriggerScheduler) { + posts, err := bts.scan() + if err != nil { + slog.Error("failed to execute scanning new blog pages cron job", slog.String("error", err.Error())) + return + } + if err = onTrigger(posts); err != nil { + slog.Error("error happened on callback function after scanning new blog pages", slog.String("error", err.Error())) + return + } + }, bts)) + + if _, err = bts.scan(); err != nil { + return nil, fmt.Errorf("failed to scan existing blog pages in b2: %w", err) + } + + return bts, nil +} + +func (bts *BlogTriggerScheduler) Close() error { + return bts.s.Shutdown() +} + +func (bts *BlogTriggerScheduler) scan() (newPages []*b2.BlogPage, err error) { + newPages = make([]*b2.BlogPage, 0) + for lang := range bts.knownBlogPages { + posts, err := bts.b2Client.Scan(lang + "/") + if err != nil { + return nil, fmt.Errorf("failed to scan blog pages in b2 on '%s': %w", lang, err) + } + for _, post := range posts { + if _, ok := bts.knownBlogPages[lang][post.FileName]; !ok { + post.Lang = lang + newPages = append(newPages, post) + bts.knownBlogPages[lang][post.FileName] = post + } + } + } + return newPages, nil +} diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go index ba2202e..d6dbd17 100644 --- a/internal/mailer/mailer.go +++ b/internal/mailer/mailer.go @@ -6,12 +6,15 @@ import ( "encoding/base64" "encoding/binary" "fmt" + "html/template" "log/slog" + "slices" "strconv" "strings" "sync" "time" + "github.com/SayaAndy/saya-today-web/internal/b2" "github.com/SayaAndy/saya-today-web/internal/templatemanager" "github.com/SayaAndy/saya-today-web/locale" "github.com/dgraph-io/ristretto/v2" @@ -22,6 +25,7 @@ import ( type Mailer struct { verificationCodes *ristretto.Cache[uint64, string] + unsubscribeCodes *ristretto.Cache[uint64, []byte] db *sql.DB tm *templatemanager.TemplateManager mailClient *mail.Client @@ -56,7 +60,17 @@ func NewMailer(db *sql.DB, clientHost string, mailHost string, publicName string NumCounters: 10000, MaxCost: 1 << 20, // 1 MB BufferItems: 64, - TtlTickerDurationInSec: 3600, + TtlTickerDurationInSec: 3600, // 1 hour + }) + if err != nil { + return nil, fmt.Errorf("fail to initialize cache for verification codes: %w", err) + } + + unsubscribeCodes, err := ristretto.NewCache(&ristretto.Config[uint64, []byte]{ + NumCounters: 10000, + MaxCost: 1 << 20, // 1 MB + BufferItems: 64, + TtlTickerDurationInSec: 86400, // 1 day }) if err != nil { return nil, fmt.Errorf("fail to initialize cache for verification codes: %w", err) @@ -83,6 +97,7 @@ func NewMailer(db *sql.DB, clientHost string, mailHost string, publicName string return &Mailer{ verificationCodes: verificationCodes, + unsubscribeCodes: unsubscribeCodes, db: db, clientHost: clientHost, tm: tm, @@ -137,32 +152,45 @@ func (m *Mailer) MailIsTaken(email string) (bool, error) { return false, fmt.Errorf("failed to initialize transaction with db: %s", err) } + slog.Debug("began db transaction", slog.String("method", "MailIsTaken")) + defer func(tx *sql.Tx) { + if err = tx.Commit(); err != nil { + tx.Rollback() + } + slog.Debug("ended db transaction", slog.String("method", "MailIsTaken")) + }(tx) + var rows *sql.Rows if rows, err = tx.Query(`SELECT email FROM user_email_table WHERE email=? LIMIT 1;`, email); err != nil { tx.Rollback() + return false, fmt.Errorf("failed to query user-email settings in db: %s", err) } - defer tx.Commit() defer rows.Close() isTaken := rows.Next() return isTaken, nil } -func (m *Mailer) GetInfo(userId string) (email string, lang string, err error) { - hash := m.GetHash(userId) - +func (m *Mailer) GetInfo(userIdHash []byte) (email string, lang string, err error) { tx, err := m.db.Begin() if err != nil { return "", "", fmt.Errorf("failed to initialize transaction with db: %s", err) } + slog.Debug("began db transaction", slog.String("method", "GetInfo")) + defer func(tx *sql.Tx) { + if err = tx.Commit(); err != nil { + tx.Rollback() + } + slog.Debug("ended db transaction", slog.String("method", "GetInfo")) + }(tx) + var rows *sql.Rows - if rows, err = tx.Query(`SELECT email, lang FROM user_email_table WHERE user_id=? LIMIT 1;`, hash); err != nil { + if rows, err = tx.Query(`SELECT email, lang FROM user_email_table WHERE user_id=? LIMIT 1;`, userIdHash); err != nil { tx.Rollback() return "", "", fmt.Errorf("failed to query user-email settings in db: %s", err) } - defer tx.Commit() defer rows.Close() if !rows.Next() { @@ -175,6 +203,24 @@ func (m *Mailer) GetInfo(userId string) (email string, lang string, err error) { return } +func (m *Mailer) Unsubscribe(unsubscribeCodeString string) (clientError error, serverError error) { + unsubscribeCode, err := strconv.ParseUint(unsubscribeCodeString, 16, 64) + if err != nil { + return fmt.Errorf("invalid unsubscribe code: %s", err), nil + } + + userId, _ := m.unsubscribeCodes.Get(unsubscribeCode) + if len(userId) == 0 { + return fmt.Errorf("invalid unsubscribe code: have no information about it"), nil + } + + if err = m.Subscribe(userId, None); err != nil { + return nil, fmt.Errorf("failed to unsubscribe: %s", err) + } + + return nil, nil +} + func (m *Mailer) SendVerificationCode(userId string, address string, lang string) error { message := mail.NewMsg() @@ -271,18 +317,22 @@ func (m *Mailer) Verify(verificationCodeEncoded string, lang string) error { return fmt.Errorf("failed to initialize transaction with db: %s", err) } + slog.Debug("began db transaction", slog.String("method", "Verify")) if _, err = tx.Exec(`INSERT INTO user_email_table(user_id, email, lang) VALUES(?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET email=excluded.email, lang=excluded.lang;`, m.GetHash(string(userId)), address, lang); err != nil { tx.Rollback() + slog.Debug("ended db transaction", slog.String("method", "Verify")) return fmt.Errorf("failed to configure user-email settings in db: %s", err) } if err = tx.Commit(); err != nil { tx.Rollback() + slog.Debug("ended db transaction", slog.String("method", "Verify")) return fmt.Errorf("failed to commit transaction to db: %s", err) } + slog.Debug("ended db transaction", slog.String("method", "Verify")) m.verificationCodes.Del(verificationCode) delete(m.lostMailMap, verificationSegments[0]) @@ -295,6 +345,14 @@ func (m *Mailer) GetSubscriptions(userId string) (subscriptionType SubscriptionT return None, nil, fmt.Errorf("failed to initialize transaction with db: %s", err) } + slog.Debug("began db transaction", slog.String("method", "GetSubscriptions")) + defer func(tx *sql.Tx) { + if err = tx.Commit(); err != nil { + tx.Rollback() + } + slog.Debug("ended db transaction", slog.String("method", "GetSubscriptions")) + }(tx) + hash := m.GetHash(userId) var rows *sql.Rows @@ -302,7 +360,6 @@ func (m *Mailer) GetSubscriptions(userId string) (subscriptionType SubscriptionT tx.Rollback() return None, nil, fmt.Errorf("failed to query user-to-tags table in db for the user: %s", err) } - defer tx.Commit() defer rows.Close() if !rows.Next() { @@ -324,12 +381,15 @@ func (m *Mailer) GetSubscriptions(userId string) (subscriptionType SubscriptionT } } -func (m *Mailer) Subscribe(userId string, subscriptionType SubscriptionType, tags ...string) error { +func (m *Mailer) Subscribe(userIdHash []byte, subscriptionType SubscriptionType, tags ...string) error { tx, err := m.db.Begin() if err != nil { return fmt.Errorf("failed to initialize transaction with db: %s", err) } + slog.Debug("began db transaction", slog.String("method", "Subscribe")) + + slices.Sort(tags) tagsOutput := "" switch subscriptionType { case All: @@ -340,23 +400,145 @@ func (m *Mailer) Subscribe(userId string, subscriptionType SubscriptionType, tag tagsOutput = strings.Join(tags, ",") } - hash := m.GetHash(userId) - if _, err = tx.Exec(`INSERT INTO subscription_user_to_tags_table(user_id, tags) VALUES(?, ?) ON CONFLICT(user_id) DO UPDATE SET - tags=excluded.tags;`, hash, tagsOutput); err != nil { + tags=excluded.tags;`, userIdHash, tagsOutput); err != nil { tx.Rollback() + slog.Debug("ended db transaction", slog.String("method", "Subscribe")) return fmt.Errorf("failed to configure user-to-tags table in db for the user: %s", err) } if err = tx.Commit(); err != nil { tx.Rollback() + slog.Debug("ended db transaction", slog.String("method", "Subscribe")) return fmt.Errorf("failed to commit transaction to db: %s", err) } + slog.Debug("ended db transaction", slog.String("method", "Subscribe")) return nil } -func SendNewPost() { +func (m *Mailer) NewPost(post *b2.BlogPage) error { + tx, err := m.db.Begin() + if err != nil { + return fmt.Errorf("failed to initialize transaction with db: %s", err) + } + slog.Debug("began db transaction", slog.String("method", "NewPost")) + var rows *sql.Rows + if rows, err = tx.Query(`SELECT user_id, tags FROM subscription_user_to_tags_table;`); err != nil { + tx.Rollback() + slog.Debug("ended db transaction", slog.String("method", "NewPost")) + return fmt.Errorf("failed to query user-to-tags table in db: %s", err) + } + + var userId []byte + usersToSend := make([]struct { + userId []byte + email string + }, 0) + var tagsString string + i := -1 + +rowLoop: + for rows.Next() { + i++ + if err = rows.Scan(&userId, &tagsString); err != nil { + slog.Warn("failed to scan a row in user-to-tags table", slog.String("error", err.Error()), slog.Int("index", i), slog.String("user_id", base64.RawStdEncoding.EncodeToString(userId))) + continue + } + + if tagsString == "" { + continue + } + + if tagsString == "_all" { + usersToSend = append(usersToSend, struct { + userId []byte + email string + }{userId, ""}) + continue + } + + pageTags := strings.Split(tagsString, ",") + for _, tag := range pageTags { + if _, found := slices.BinarySearch(post.Metadata.Tags, tag); found { + usersToSend = append(usersToSend, struct { + userId []byte + email string + }{userId, ""}) + continue rowLoop + } + } + } + + tx.Commit() + rows.Close() + slog.Debug("ended db transaction", slog.String("method", "NewPost")) + + for i := range usersToSend { + email, lang, err := m.GetInfo(usersToSend[i].userId) + if err != nil { + slog.Warn("failed to get info about the user", slog.String("error", err.Error()), slog.Int("index", i), slog.String("user_id", base64.RawStdEncoding.EncodeToString(userId))) + continue + } + + if lang != post.Lang { + continue + } + + usersToSend[i].email = email + } + + messages := make([]*mail.Msg, 0, len(usersToSend)) + for _, user := range usersToSend { + if user.email == "" { + continue + } + unsubscribeCodeBytes := make([]byte, 8) + rand.Read(unsubscribeCodeBytes) + unsubscribeCode := binary.LittleEndian.Uint64(unsubscribeCodeBytes) + + unsubscribeFooter := strings.Replace(m.l[post.Lang].Mail.UnsubscribeFooter, "{}", fmt.Sprintf(`<a style="color: #273de1 !important;" href="https://%s/%s/user/unsubscribe?code=%X">`, m.clientHost, post.Lang, unsubscribeCode), 1) + unsubscribeFooter = strings.Replace(unsubscribeFooter, "{/}", "</a>", 1) + + msgBody, err := m.tm.Render("new-post", fiber.Map{ + "L": m.l[post.Lang], + "Lang": post.Lang, + "Post": post, + "ClientHost": m.clientHost, + "UnsubscribeFooter": template.HTML(unsubscribeFooter), + }) + + if err != nil { + return fmt.Errorf("failed to render message body: %w", err) + } + + message := mail.NewMsg() + + if err := message.EnvelopeFrom(m.mailAddress); err != nil { + return fmt.Errorf("failed to set ENVELOPE FROM address: %w", err) + } + if err := message.FromFormat(m.publicName, m.mailAddress); err != nil { + return fmt.Errorf("failed to set formatted FROM address: %w", err) + } + if err := message.To(user.email); err != nil { + return fmt.Errorf("failed to set TO address: %w", err) + } + + message.SetMessageID() + message.SetDate() + message.SetBulk() + message.Subject(m.l[post.Lang].Mail.NewPost.Subject) + message.SetBodyString(mail.TypeTextHTML, string(msgBody)) + + m.unsubscribeCodes.Set(unsubscribeCode, user.userId, 40) + + messages = append(messages, message) + } + + if err := m.mailClient.DialAndSend(messages...); err != nil { + return fmt.Errorf("failed to send new post notifications: %w", err) + } + return nil } diff --git a/internal/router/api-v1-email-send-verification-code.go b/internal/router/api-v1-email-send-verification-code.go index 2cde1bd..9372eb3 100644 --- a/internal/router/api-v1-email-send-verification-code.go +++ b/internal/router/api-v1-email-send-verification-code.go @@ -58,7 +58,7 @@ func Api_V1_Email_SendVerificationCode(l map[string]*locale.LocaleConfig) func(c }) } - if previousEmail, _, _ := Mailer.GetInfo(id); previousEmail == email { + if previousEmail, _, _ := Mailer.GetInfo(Mailer.GetHash(id)); previousEmail == email { return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.EmailAlreadyValidated, map[string]string{}) } diff --git a/internal/router/api-v1-general-page-body.go b/internal/router/api-v1-general-page-body.go index 66f1234..b5567b7 100644 --- a/internal/router/api-v1-general-page-body.go +++ b/internal/router/api-v1-general-page-body.go @@ -14,6 +14,7 @@ import ( "github.com/SayaAndy/saya-today-web/config" "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/internal/blogtrigger" "github.com/SayaAndy/saya-today-web/internal/factgiver" "github.com/SayaAndy/saya-today-web/internal/frontmatter" "github.com/SayaAndy/saya-today-web/internal/mailer" @@ -24,6 +25,7 @@ import ( var FactGiver *factgiver.FactGiver var Mailer *mailer.Mailer +var BlogTrigger *blogtrigger.BlogTriggerScheduler func init() { assert(0, tm.Add("general-page-body", "views/partials/general-page-body.html")) @@ -110,7 +112,7 @@ func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []config.A } else if len(pathParts) == 2 && pathParts[1] == "user" { values["Title"] = l[lang].UserProfile.Header - email, _, err := Mailer.GetInfo(c.IP()) + email, _, err := Mailer.GetInfo(Mailer.GetHash(c.IP())) if err != nil { slog.Error("get info from mailer about a client", slog.String("error", err.Error())) } diff --git a/internal/router/api-v1-subs.go b/internal/router/api-v1-subs.go index 8995ebe..72f16e0 100644 --- a/internal/router/api-v1-subs.go +++ b/internal/router/api-v1-subs.go @@ -58,7 +58,7 @@ func Api_V1_Subs_Put(l map[string]*locale.LocaleConfig) func(c *fiber.Ctx) error } specificTags := c.FormValue("tags_picked") - if err = Mailer.Subscribe(id, subscriptionTypeEnum, specificTags); err != nil { + if err = Mailer.Subscribe(Mailer.GetHash(id), subscriptionTypeEnum, specificTags); err != nil { return api_v1_email_sendStatusHtml(c, "subs-message", l, "Failed", fiber.StatusUnprocessableEntity, lang, l[lang].UserProfile.FailedToSubscribe, map[string]string{}) } diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go index d607cdd..e7cda30 100644 --- a/internal/router/client-cache.go +++ b/internal/router/client-cache.go @@ -31,10 +31,12 @@ func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) { if err != nil { return nil, fmt.Errorf("fail to init transaction with db to fill cache: %w", err) } + slog.Debug("began db transaction", slog.String("method", "NewClientCache")) rows, err := tx.Query("select * from blog_likes;") if err != nil { tx.Rollback() + slog.Debug("ended db transaction", slog.String("method", "NewClientCache")) return nil, fmt.Errorf("fail to query db for blog_likes to fill cache: %w", err) } @@ -62,6 +64,7 @@ func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) { rows, err = tx.Query("select * from blog_views;") if err != nil { tx.Rollback() + slog.Debug("ended db transaction", slog.String("method", "NewClientCache")) return nil, fmt.Errorf("fail to query db for blog_views to fill cache: %w", err) } @@ -81,8 +84,11 @@ func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) { } if err = tx.Commit(); err != nil { + tx.Rollback() + slog.Debug("ended db transaction", slog.String("method", "NewClientCache")) return nil, fmt.Errorf("fail to commit transaction in db: %w", err) } + slog.Debug("ended db transaction", slog.String("method", "NewClientCache")) return &ClientCache{ hashMap: make(map[string]string), @@ -99,18 +105,28 @@ func (c *ClientCache) Close() error { if err != nil { return fmt.Errorf("fail to init transaction with db to dump cache: %w", err) } + slog.Debug("began db transaction in ClientCache.Close") if err = batchSave(tx, "blog_likes", c.likePageMap); err != nil { tx.Rollback() + slog.Debug("ended db transaction in ClientCache.Close") return fmt.Errorf("fail to save blog_likes: %s", err) } if err = batchSave(tx, "blog_views", c.viewPageMap); err != nil { tx.Rollback() + slog.Debug("ended db transaction in ClientCache.Close") return fmt.Errorf("fail to save blog_views: %s", err) } - return tx.Commit() + if err = tx.Commit(); err != nil { + tx.Rollback() + slog.Debug("ended db transaction in ClientCache.Close") + return fmt.Errorf("fail to commit all the changes related to cache: %s", err) + } + + slog.Debug("ended db transaction in ClientCache.Close") + return nil } func (c *ClientCache) GetHash(id string) string { diff --git a/internal/router/lang-user-unsubscribe.go b/internal/router/lang-user-unsubscribe.go new file mode 100644 index 0000000..c39f5e7 --- /dev/null +++ b/internal/router/lang-user-unsubscribe.go @@ -0,0 +1,71 @@ +package router + +import ( + "fmt" + "html/template" + "log/slog" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + assert(0, tm.Add("unsubscribe-page", "views/pages/unsubscribe-page.html")) +} + +func Lang_User_Unsubscribe(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + lang := c.Params("lang") + for _, availableLang := range langs { + if availableLang.Name == lang { + goto langIsAvailable + } + } + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang)) + + langIsAvailable: + var statusEmoji, statusText, statusColor string + var status int + + unsubscribeCode := c.FormValue("code") + if unsubscribeCode == "" { + statusColor = "0, 0, 255" + statusEmoji = "(╭ರ_•́)" + statusText = l[lang].UnsubscribePage.UnsetCode + status = fiber.ErrBadRequest.Code + } else if clientError, serverError := Mailer.Unsubscribe(unsubscribeCode); clientError != nil { + slog.Info("got a client error when unsubscribing", slog.String("error", clientError.Error())) + statusColor = "255, 0, 0" + statusEmoji = "(͠≖~≖ ͡ )" + statusText = l[lang].UnsubscribePage.InvalidCode + status = fiber.ErrBadRequest.Code + } else if serverError != nil { + slog.Error("got a server error when unsubscribing", slog.String("error", serverError.Error())) + statusColor = "255, 128, 0" + statusEmoji = "( ˶°ㅁ°) !!" + statusText = l[lang].UnsubscribePage.OnServerError + status = fiber.ErrInternalServerError.Code + } else { + statusColor = "0, 255, 0" + statusEmoji = "♡⸜(˶˃ ᵕ ˂˶)⸝♡" + statusText = l[lang].UnsubscribePage.Success + status = fiber.StatusOK + } + + content, err := tm.Render("unsubscribe-page", fiber.Map{ + "Lang": lang, + "L": l[lang], + "StatusEmoji": statusEmoji, + "StatusText": statusText, + "StatusColor": template.HTML(statusColor), + }) + if err != nil { + slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/user/unsubscribe"), slog.String("error", err.Error())) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") + } + + return c.Type("html").Status(status).Send(content) + } +} |