summaryrefslogtreecommitdiff
path: root/internal
diff refs
from: back
to: back
| flip
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/b2/client.go7
-rw-r--r--internal/blogtrigger/blogtrigger.go72
-rw-r--r--internal/mailer/mailer.go279
-rw-r--r--internal/router/api-v1-email-send-verification-code.go2
-rw-r--r--internal/router/api-v1-email-verify.go2
-rw-r--r--internal/router/api-v1-general-page-body.go87
-rw-r--r--internal/router/api-v1-subs.go67
-rw-r--r--internal/router/client-cache.go18
-rw-r--r--internal/router/lang-user-unsubscribe.go71
9 files changed, 568 insertions, 37 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 450b818..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
@@ -43,12 +47,30 @@ type Mailer struct {
l map[string]*locale.LocaleConfig
}
+type SubscriptionType int
+
+const (
+ All SubscriptionType = iota
+ None
+ Specific
+)
+
func NewMailer(db *sql.DB, clientHost string, mailHost string, publicName string, mailAddress string, username string, password string, salt []byte, localization map[string]*locale.LocaleConfig) (*Mailer, error) {
verificationCodes, err := ristretto.NewCache(&ristretto.Config[uint64, 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)
@@ -75,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,
@@ -129,31 +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()
- return rows.Next(), nil
+ 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() {
@@ -166,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()
@@ -262,20 +317,228 @@ 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])
return nil
}
+
+func (m *Mailer) GetSubscriptions(userId string) (subscriptionType SubscriptionType, tags []string, err error) {
+ tx, err := m.db.Begin()
+ if err != nil {
+ 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
+ if rows, err = tx.Query(`SELECT tags FROM subscription_user_to_tags_table WHERE user_id=? LIMIT 1;`, hash); err != nil {
+ tx.Rollback()
+ return None, nil, fmt.Errorf("failed to query user-to-tags table in db for the user: %s", err)
+ }
+ defer rows.Close()
+
+ if !rows.Next() {
+ return None, nil, nil
+ }
+
+ tagsString := ""
+ if err = rows.Scan(&tagsString); err != nil {
+ return None, nil, fmt.Errorf("failed to scan the result from user-to-tags query: %s", err)
+ }
+
+ switch tagsString {
+ case "":
+ return None, nil, nil
+ case "_all":
+ return All, nil, nil
+ default:
+ return Specific, strings.Split(tagsString, ","), nil
+ }
+}
+
+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:
+ tagsOutput = "_all"
+ case None:
+ tagsOutput = ""
+ case Specific:
+ tagsOutput = strings.Join(tags, ",")
+ }
+
+ 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;`, 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 (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-email-verify.go b/internal/router/api-v1-email-verify.go
index fad174b..7fbfdec 100644
--- a/internal/router/api-v1-email-verify.go
+++ b/internal/router/api-v1-email-verify.go
@@ -45,7 +45,7 @@ func Api_V1_Email_Verify(l map[string]*locale.LocaleConfig) func(c *fiber.Ctx) e
slog.Error("verification code is invalid", slog.String("verification_code", verificationCode), slog.String("error", err.Error()))
return api_v1_email_sendStatusHtml(c, "verification-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.VerificationFailed, map[string]string{})
}
- return api_v1_email_sendStatusHtml(c, "verification-message", l, "OK", fiber.StatusOK, lang, l[lang].UserProfile.VerificationSuccess, map[string]string{
+ return api_v1_email_sendStatusHtml(c, "verification-message", l, "OK", fiber.StatusOK, lang, l[lang].UserProfile.VerificationSuccess+"\n\n"+l[lang].UserProfile.RefreshPage, map[string]string{
"hide-verification-panel": "true",
})
}
diff --git a/internal/router/api-v1-general-page-body.go b/internal/router/api-v1-general-page-body.go
index 6f856d3..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"))
@@ -95,33 +97,12 @@ func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []config.A
queryTags = append(queryTags, string(match[1]))
}
- pages, err := b2Client.Scan(lang + "/")
+ tagsArray, err := getTags(b2Client, lang)
if err != nil {
- slog.Warn("failed to scan pages via b2", slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString(fmt.Sprintf("failed to scan pages via b2: %s", slog.String("error", err.Error())))
+ slog.Warn("failed to get the available tags", slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to gather available tags")
}
- tagsMap := make(map[string]int)
- for _, page := range pages {
- for _, tag := range page.Metadata.Tags {
- tagsMap[tag]++
- }
- }
- slog.Debug("enlist pages for catalogue", slog.Int("tag_count", len(tagsMap)), slog.Int("page_count", len(pages)), slog.String("path", c.Path()))
-
- type Tag struct {
- Name string `json:"Name" yaml:"name"`
- Count int `json:"Count" yaml:"count"`
- }
-
- tagsArray := make([]Tag, 0, len(tagsMap))
- for tag, count := range tagsMap {
- tagsArray = append(tagsArray, Tag{tag, count})
- }
- slices.SortFunc(tagsArray, func(a Tag, b Tag) int {
- return strings.Compare(a.Name, b.Name)
- })
-
values["Tags"] = tagsArray
values["QuerySort"] = querySort
values["QueryTags"] = strings.Join(queryTags, ",")
@@ -131,12 +112,37 @@ 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()))
}
+
+ tagsArray, err := getTags(b2Client, lang)
+ if err != nil {
+ slog.Warn("failed to get the available tags", slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to gather available tags")
+ }
+
+ subscriptionType, tags, err := Mailer.GetSubscriptions(c.IP())
+ if err != nil {
+ slog.Warn("failed to get the user subscriptions", slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to get the user subscriptions")
+ }
+
+ switch subscriptionType {
+ case mailer.None:
+ values["TagsPicked"] = "none"
+ case mailer.All:
+ values["TagsPicked"] = "all"
+ case mailer.Specific:
+ values["TagsPicked"] = "specific"
+ }
+
+ values["TagsPickedList"] = tags
+
values["Email"] = email
values["EmailCode"] = c.Query("email_code")
+ values["ExistingTags"] = tagsArray
additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
} else if len(pathParts) == 3 && pathParts[1] == "blog" {
@@ -204,3 +210,34 @@ func readBlogPost(md goldmark.Markdown, b2Client *b2.B2Client, sourceName string
return metadata, buf.String(), nil
}
+
+type Tag struct {
+ Name string `json:"Name" yaml:"name"`
+ Count int `json:"Count" yaml:"count"`
+}
+
+func getTags(b2Client *b2.B2Client, lang string) (tags []Tag, err error) {
+ pages, err := b2Client.Scan(lang + "/")
+ if err != nil {
+ slog.Warn("failed to scan pages via b2", slog.String("error", err.Error()))
+ return nil, fmt.Errorf("failed to scan pages via b2: %w", err)
+ }
+
+ tagsMap := make(map[string]int)
+ for _, page := range pages {
+ for _, tag := range page.Metadata.Tags {
+ tagsMap[tag]++
+ }
+ }
+ slog.Debug("enlist pages for catalogue", slog.Int("tag_count", len(tagsMap)), slog.Int("page_count", len(pages)), slog.String("lang", lang))
+
+ tagsArray := make([]Tag, 0, len(tagsMap))
+ for tag, count := range tagsMap {
+ tagsArray = append(tagsArray, Tag{tag, count})
+ }
+ slices.SortFunc(tagsArray, func(a Tag, b Tag) int {
+ return strings.Compare(a.Name, b.Name)
+ })
+
+ return tagsArray, nil
+}
diff --git a/internal/router/api-v1-subs.go b/internal/router/api-v1-subs.go
new file mode 100644
index 0000000..72f16e0
--- /dev/null
+++ b/internal/router/api-v1-subs.go
@@ -0,0 +1,67 @@
+package router
+
+import (
+ "net/url"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/internal/mailer"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ assert(0, tm.Add("personal-page-status", "views/partials/personal-page-status.html"))
+}
+
+func Api_V1_Subs_Put(l map[string]*locale.LocaleConfig) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ id := c.IP()
+ lang := "en"
+
+ var referer, path string
+ var pathParts []string
+ var urlStruct *url.URL
+ var err error
+
+ referer = c.Get("Referer", "")
+ if referer == "" {
+ goto skipFetchingLang
+ }
+
+ urlStruct, err = url.ParseRequestURI(referer)
+ if err != nil {
+ goto skipFetchingLang
+ }
+
+ path = urlStruct.EscapedPath()
+ pathParts = strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 0 {
+ goto skipFetchingLang
+ }
+
+ lang = pathParts[0]
+
+ skipFetchingLang:
+ subscriptionType := c.FormValue("tags")
+ var subscriptionTypeEnum mailer.SubscriptionType
+ switch subscriptionType {
+ case "all":
+ subscriptionTypeEnum = mailer.All
+ case "none":
+ subscriptionTypeEnum = mailer.None
+ case "specific":
+ subscriptionTypeEnum = mailer.Specific
+ default:
+ return api_v1_email_sendStatusHtml(c, "subs-message", l, "Failed", fiber.StatusUnprocessableEntity, lang, l[lang].UserProfile.SubscribeInvalidType, map[string]string{})
+ }
+
+ specificTags := c.FormValue("tags_picked")
+ 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{})
+ }
+
+ return api_v1_email_sendStatusHtml(c, "subs-message", l, "OK", fiber.StatusOK, lang, l[lang].UserProfile.SubscribedSuccessfully, 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)
+ }
+}