| -rw-r--r-- | config/config.go | 19 | ||||
| -rw-r--r-- | config/config.local.yaml | 5 | ||||
| -rw-r--r-- | config/config.prod.yaml | 4 | ||||
| -rw-r--r-- | config/config.stage.yaml | 4 | ||||
| -rw-r--r-- | internal/blogtrigger/blogtrigger.go | 8 | ||||
| -rw-r--r-- | internal/mailer/mailer.go | 145 | ||||
| -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 | ||||
| -rw-r--r-- | locale/localization.en.yaml | 8 | ||||
| -rw-r--r-- | locale/localization.go | 23 | ||||
| -rw-r--r-- | locale/localization.ru.yaml | 8 | ||||
| -rw-r--r-- | main.go | 6 | ||||
| -rw-r--r-- | views/messages/new-post.html | 4 | ||||
| -rw-r--r-- | views/pages/blog-catalogue.html | 8 | ||||
| -rw-r--r-- | views/pages/unsubscribe-page.html | 30 |
16 files changed, 305 insertions, 58 deletions
diff --git a/config/config.go b/config/config.go index d5dab2d..1393dd8 100644 --- a/config/config.go +++ b/config/config.go @@ -62,13 +62,18 @@ type Sqlite3Config struct { } type MailConfig struct { - ClientHost string `json:"ClientHost" yaml:"clientHost" validate:"required"` - MailHost string `json:"MailHost" yaml:"mailHost" validate:"required"` - PublicName string `json:"PublicName" yaml:"publicName" validate:"required"` - MailAddress string `json:"MailAddress" yaml:"mailAddress" validate:"required"` - Username string `json:"Username" yaml:"username" validate:"required"` - Password string `json:"Password" yaml:"password" validate:"required"` - Salt string `json:"Salt" yaml:"salt" validate:"required"` + ClientHost string `json:"ClientHost" yaml:"clientHost" validate:"required"` + MailHost string `json:"MailHost" yaml:"mailHost" validate:"required"` + PublicName string `json:"PublicName" yaml:"publicName" validate:"required"` + MailAddress string `json:"MailAddress" yaml:"mailAddress" validate:"required"` + Username string `json:"Username" yaml:"username" validate:"required"` + Password string `json:"Password" yaml:"password" validate:"required"` + Salt string `json:"Salt" yaml:"salt" validate:"required"` + Trigger TriggerConfig `json:"Trigger" yaml:"trigger" validate:"required"` +} + +type TriggerConfig struct { + OnNewPost string `json:"OnNewPost" yaml:"onNewPost" validate:"cron,required"` } func LoadConfig(path string, config *Config) error { diff --git a/config/config.local.yaml b/config/config.local.yaml index 008fa93..e23c808 100644 --- a/config/config.local.yaml +++ b/config/config.local.yaml @@ -32,7 +32,8 @@ auth: db: type: sqlite3 config: - dsn: 'file:/tmp/auth.db?cache=shared&mode=rwc' + # dsn: 'file:/tmp/auth.db?cache=shared&mode=rwc&_journal_mode=WAL' + dsn: 'file:/tmp/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2&_journal_mode=WAL' salt: '123' mail: clientHost: '127.0.0.1:3000' @@ -42,3 +43,5 @@ mail: username: '${MAIL_USERNAME}' password: '${MAIL_PASSWORD}' salt: '${MAIL_SALT}' + trigger: + onNewPost: "* * * * *" diff --git a/config/config.prod.yaml b/config/config.prod.yaml index 4c0f425..0bee79d 100644 --- a/config/config.prod.yaml +++ b/config/config.prod.yaml @@ -32,7 +32,7 @@ auth: db: type: sqlite3 config: - dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2' + dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2&_journal_mode=WAL' salt: '${AUTH_SALT}' mail: clientHost: '${FQDN}' @@ -42,3 +42,5 @@ mail: username: '${MAIL_USERNAME}' password: '${MAIL_PASSWORD}' salt: '${MAIL_SALT}' + trigger: + onNewPost: "0/5 * * * *" diff --git a/config/config.stage.yaml b/config/config.stage.yaml index 003ec05..1c15dae 100644 --- a/config/config.stage.yaml +++ b/config/config.stage.yaml @@ -32,7 +32,7 @@ auth: db: type: sqlite3 config: - dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2' + dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2&_journal_mode=WAL' salt: '${AUTH_SALT}' mail: clientHost: '${FQDN}' @@ -42,3 +42,5 @@ mail: username: '${MAIL_USERNAME}' password: '${MAIL_PASSWORD}' salt: '${MAIL_SALT}' + trigger: + onNewPost: "0/3 * * * *" diff --git a/internal/blogtrigger/blogtrigger.go b/internal/blogtrigger/blogtrigger.go index ab66981..c5b3097 100644 --- a/internal/blogtrigger/blogtrigger.go +++ b/internal/blogtrigger/blogtrigger.go @@ -16,7 +16,7 @@ type BlogTriggerScheduler struct { onTrigger func([]*b2.BlogPage) error } -func NewBlogTriggerScheduler(b2Client *b2.B2Client, availableLanguages []config.AvailableLanguageConfig, onTrigger func([]*b2.BlogPage) error) (*BlogTriggerScheduler, 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) @@ -30,7 +30,7 @@ func NewBlogTriggerScheduler(b2Client *b2.B2Client, availableLanguages []config. bts := &BlogTriggerScheduler{s, knownBlogPages, b2Client, onTrigger} defer bts.s.Start() - bts.s.NewJob(gocron.CronJob("0/5 * * * *", false), gocron.NewTask(func(bts *BlogTriggerScheduler) { + 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())) @@ -49,6 +49,10 @@ func NewBlogTriggerScheduler(b2Client *b2.B2Client, availableLanguages []config. 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 { diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go index f51186e..d6dbd17 100644 --- a/internal/mailer/mailer.go +++ b/internal/mailer/mailer.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/binary" "fmt" + "html/template" "log/slog" "slices" "strconv" @@ -24,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 @@ -58,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) @@ -85,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, @@ -139,12 +152,20 @@ 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() @@ -157,12 +178,19 @@ func (m *Mailer) GetInfo(userIdHash []byte) (email string, lang string, err erro 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;`, 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(userIdHash []byte) (email string, lang string, err erro 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,14 @@ 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 { @@ -341,19 +400,20 @@ 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 } @@ -364,14 +424,19 @@ func (m *Mailer) NewPost(post *b2.BlogPage) error { 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([]string, 0) + usersToSend := make([]struct { + userId []byte + email string + }, 0) var tagsString string i := -1 @@ -387,25 +452,21 @@ rowLoop: continue } - email, lang, err := m.GetInfo(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 - } - if tagsString == "_all" { - usersToSend = append(usersToSend, email) + 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, email) + usersToSend = append(usersToSend, struct { + userId []byte + email string + }{userId, ""}) continue rowLoop } } @@ -413,16 +474,42 @@ rowLoop: tx.Commit() rows.Close() + slog.Debug("ended db transaction", slog.String("method", "NewPost")) - msgBody, err := m.tm.Render("new-post", fiber.Map{ - "L": m.l[post.Lang], - "Lang": post.Lang, - "Post": post, - "ClientHost": m.clientHost, - }) + 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) } @@ -435,7 +522,7 @@ rowLoop: 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); err != nil { + if err := message.To(user.email); err != nil { return fmt.Errorf("failed to set TO address: %w", err) } @@ -445,6 +532,8 @@ rowLoop: 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) } 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) + } +} diff --git a/locale/localization.en.yaml b/locale/localization.en.yaml index 8981c49..c7f2df8 100644 --- a/locale/localization.en.yaml +++ b/locale/localization.en.yaml @@ -22,11 +22,17 @@ HomePage: Hymn2: "We've been expecting you!" Hymn3: 'You bring such joy in Sayasite!' Hymn4: 'No matter where you roam, know our love is true!~' +UnsubscribePage: + Header: 'Cancelling the Subscription' + UnsetCode: 'Your cancel code is not set. You just having a walk around the site?' + InvalidCode: "Your cancel code could not be verified. Maybe, the code expired after a day. Or maybe, you've already unsubscribed." + OnServerError: "We couldn't unsubscribe you, the problem is on our side! We will solve this problem as soon as possible." + Success: 'Cancelled your subscription successfully! We do hope we will engage you again someday.' Metadata: Published: 'Published' Action: 'Took place on' Mail: - UnsubscribeFooter: 'If this letter got you in a bad mood, you can unsubscribe from my blog by this link.' + UnsubscribeFooter: 'If this letter got you in a bad mood, you can unsubscribe from my blog by {}this link{/}.' VerifyEmail: Subject: 'Verification Link for SAYA.TODAY is waiting for you!' Welcome: 'Greetings!' diff --git a/locale/localization.go b/locale/localization.go index b3a1613..c6467c4 100644 --- a/locale/localization.go +++ b/locale/localization.go @@ -8,13 +8,14 @@ import ( ) type LocaleConfig struct { - TagsLabel string `yaml:"TagsLabel" json:"TagsLabel"` - BlogSearch BlogSearchConfig `yaml:"BlogSearch" json:"BlogSearch"` - GlobalMap GlobalMapConfig `yaml:"GlobalMap" json:"GlobalMap"` - HomePage HomePageConfig `yaml:"HomePage" json:"HomePage"` - Metadata MetadataConfig `yaml:"Metadata" json:"Metadata"` - Mail MailConfig `yaml:"Mail" json:"Mail"` - UserProfile UserProfileConfig `yaml:"UserProfile" json:"UserProfile"` + TagsLabel string `yaml:"TagsLabel" json:"TagsLabel"` + BlogSearch BlogSearchConfig `yaml:"BlogSearch" json:"BlogSearch"` + GlobalMap GlobalMapConfig `yaml:"GlobalMap" json:"GlobalMap"` + HomePage HomePageConfig `yaml:"HomePage" json:"HomePage"` + UnsubscribePage UnsubscribePageConfig `yaml:"UnsubscribePage" json:"UnsubscribePage"` + Metadata MetadataConfig `yaml:"Metadata" json:"Metadata"` + Mail MailConfig `yaml:"Mail" json:"Mail"` + UserProfile UserProfileConfig `yaml:"UserProfile" json:"UserProfile"` } type BlogSearchConfig struct { @@ -46,6 +47,14 @@ type HomePageConfig struct { Hymn4 string `yaml:"Hymn4" json:"Hymn4"` } +type UnsubscribePageConfig struct { + Header string `yaml:"Header" json:"Header"` + UnsetCode string `yaml:"UnsetCode" json:"UnsetCode"` + InvalidCode string `yaml:"InvalidCode" json:"InvalidCode"` + OnServerError string `yaml:"OnServerError" json:"OnServerError"` + Success string `yaml:"Success" json:"Success"` +} + type MetadataConfig struct { Action string `yaml:"Action" json:"Action"` Published string `yaml:"Published" json:"Published"` diff --git a/locale/localization.ru.yaml b/locale/localization.ru.yaml index c09ad16..5df82da 100644 --- a/locale/localization.ru.yaml +++ b/locale/localization.ru.yaml @@ -22,11 +22,17 @@ HomePage: Hymn2: 'Мы Вас столько ждали!' Hymn3: 'Столько радости от Вас на Саясайте!' Hymn4: 'Где бы Вы не были, знайте, -- наша любовь крепка!' +UnsubscribePage: + Header: 'Отписка' + UnsetCode: 'Код для отписки не проставлен. Вы случайно не заблудились?' + InvalidCode: 'Код для отписки не подтверждён. Может, истекло время в сутки для кода. А может, вы уже успели отписаться.' + OnServerError: 'Мы не смогли вас отписать, проблема на нашей стороне! В скором времени займёмся проблемой.' + Success: 'Вы отписались от рассылки! Надеюсь, мы ещё сможем вас заинтересовать.' Metadata: Published: 'Опубликовано' Action: 'Время действия' Mail: - UnsubscribeFooter: 'Если данное письмо пришло вам случайно, либо вы хотите отписаться, можете перейти по этой ссылке.' + UnsubscribeFooter: 'Если данное письмо пришло вам случайно, либо вы хотите отписаться, можете перейти по {}этой ссылке{/}.' VerifyEmail: Subject: 'Ссылка для верификации для подтверждения вашей почты SAYA.TODAY' Welcome: 'Приветствую!' @@ -153,7 +153,7 @@ func main() { os.Exit(1) } - router.BlogTrigger, err = blogtrigger.NewBlogTriggerScheduler(b2Client, cfg.AvailableLanguages, func(bp []*b2.BlogPage) error { + router.BlogTrigger, err = blogtrigger.NewBlogTriggerScheduler(b2Client, cfg.AvailableLanguages, cfg.Mail.Trigger.OnNewPost, func(bp []*b2.BlogPage) error { for _, post := range bp { if err := router.Mailer.NewPost(post); err != nil { return err @@ -170,6 +170,7 @@ func main() { app.Get("/:lang<len(2)>", router.Api_V1_GeneralPage(localization, availableLanguages)) app.Get("/:lang/map", router.Lang_Map(localization, availableLanguages, b2Client)) app.Get("/:lang/user", router.Api_V1_GeneralPage(localization, availableLanguages)) + app.Get("/:lang/user/unsubscribe", router.Lang_User_Unsubscribe(localization, availableLanguages)) app.Get("/:lang/blog", router.Api_V1_GeneralPage(localization, availableLanguages)) app.Get("/:lang/blog/:title", router.Api_V1_GeneralPage(localization, availableLanguages)) @@ -201,6 +202,9 @@ func main() { <-sigChan slog.Info("gracefully shutting down...") + if err = router.BlogTrigger.Close(); err != nil { + slog.Error("fail to shutdown blog trigger scheduler", slog.String("error", err.Error())) + } if err = app.Shutdown(); err != nil { slog.Error("fail to shutdown fiber server", slog.String("error", err.Error())) } diff --git a/views/messages/new-post.html b/views/messages/new-post.html index 7b3082a..f1338f7 100644 --- a/views/messages/new-post.html +++ b/views/messages/new-post.html @@ -3,7 +3,7 @@ <table> <tr> <td rowspan="3"><a href="https://{{ .ClientHost }}/{{ .Lang }}/blog/{{ .Post.FileName }}"><img src="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/{{ .Post.Metadata.Thumbnail }}.webp"></a></td> - <td class="darkened" style="font-size: 24px"><a href="https://{{ .ClientHost }}/{{ .Lang }}/blog/{{ .Post.FileName }}">{{ .Post.Metadata.Title }}</a></td> + <td class="darkened" style="font-size: 24px"><a style="color: #273de1 !important;" href="https://{{ .ClientHost }}/{{ .Lang }}/blog/{{ .Post.FileName }}">{{ .Post.Metadata.Title }}</a></td> </tr> <tr> <td class="darkened" style="font-size: 16px">{{ .Post.Metadata.ShortDescription }}</td> @@ -15,5 +15,5 @@ {{ end }} {{ define "footer" }} -<p>{{ .L.Mail.UnsubscribeFooter }}</p> +<p>{{ .UnsubscribeFooter }}</p> {{ end }} diff --git a/views/pages/blog-catalogue.html b/views/pages/blog-catalogue.html index 8e91798..d209205 100644 --- a/views/pages/blog-catalogue.html +++ b/views/pages/blog-catalogue.html @@ -1,10 +1,10 @@ {{ define "body" }} -<div class="flex flex-row ar-lt-0.8:flex-col text-base h-[100%]"> +<div class="flex flex-row ar-lt-0.8:flex-col text-base h-full"> <form hx-get="/api/v1/blog-search" hx-vals='{"lang": "{{ .Lang }}"}' hx-target=".blog-cards" hx-swap="innerHTML" - class="tags-list ar-gt-0.8:min-w-[10dvh] ar-gt-0.8:max-w-[20dvh] ar-gt-0.8:w-fit ar-lt-0.8:w-[100%] ar-lt-0.8:max-h-[30%] flex shrink-0 flex-col ar-gt-0.8:mr-6 bg-background-light inset-shadow-[0_0_0.4rem_black]"> + class="tags-list ar-gt-0.8:min-w-[10dvh] ar-gt-0.8:max-w-[20dvh] ar-gt-0.8:w-fit ar-lt-0.8:w-full ar-lt-0.8:max-h-[30%] flex shrink-0 flex-col ar-gt-0.8:mr-6 bg-background-light inset-shadow-[0_0_0.4rem_black]"> <div class="flex flex-col overflow-y-auto"> <fieldset> - <legend class="font-bold w-[100%] text-center">{{ .L.BlogSearch.TagsHeader }}</legend> + <legend class="font-bold w-full text-center">{{ .L.BlogSearch.TagsHeader }}</legend> <div class="flex flex-col ar-lt-0.8:grid grid-cols-3 xs:grid-cols-4 sm:grid-cols-5 grid-flow-row-dense"> <div class="m-1"> <label class="font-bold"><input type="checkbox" id="tagsAllCheckbox" onclick="selectAll();"> {{ .L.BlogSearch.ChooseAllTags }}</label> @@ -17,7 +17,7 @@ </div> </fieldset> <fieldset class="mt-1"> - <legend class="font-bold w-[100%] text-center">{{ .L.BlogSearch.OrderByHeader }}</legend> + <legend class="font-bold w-full text-center">{{ .L.BlogSearch.OrderByHeader }}</legend> <div class="flex flex-col ar-lt-0.8:grid grid-cols-3 grid-rows-2 grid-flow-col"> <div class="m-1"> <label><input type="radio" id="sortTitleAsc" name="sort" value="titleAsc" {{ if eq .QuerySort "titleAsc" }}checked{{ end }}> {{ .L.BlogSearch.TitleOrdered }} <i class="fas fa-arrow-down-a-z"></i></label> diff --git a/views/pages/unsubscribe-page.html b/views/pages/unsubscribe-page.html new file mode 100644 index 0000000..3876818 --- /dev/null +++ b/views/pages/unsubscribe-page.html @@ -0,0 +1,30 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{ .L.UnsubscribePage.Header }} // SAYA TODAY</title> +<style> + html { + --noise-image: url(data:image/png;base64,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); + } +</style> +</head> + +<body style="width: 100dvw; height: 100dvh; display: flex; flex-direction: column; position: fixed"> + + <div style='flex: 3 0 0; background-image: var(--noise-image); background-color: rgba({{ .StatusColor }}, 0.1)'></div> + + <div style="flex: 1 0 0; background-image: var(--noise-image); align-content: center;"> + <p style="position: relative; width: 100%; margin-inline: auto; text-align: center; font-size: 2rem;">{{ .StatusEmoji }}</p> + </div> + + <div style="flex: 1 0 0; background-image: var(--noise-image); align-content: center;"> + <p style="position: relative; width: 100%; margin-inline: auto; text-align: center; font-size: 1.5rem;">{{ .StatusText }}</p> + </div> + + <div style='flex: 3 0 0; background-image: var(--noise-image); background-color: rgba({{ .StatusColor }}, 0.2)'></div> + +</body> + +</html> |