summaryrefslogtreecommitdiff
path: root/internal
diff options
from:
to:
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.go544
-rw-r--r--internal/router/api-v1-blog-search.go2
-rw-r--r--internal/router/api-v1-email-is-in-verification.go58
-rw-r--r--internal/router/api-v1-email-send-verification-code.go98
-rw-r--r--internal/router/api-v1-email-verify.go52
-rw-r--r--internal/router/api-v1-general-page-body.go121
-rw-r--r--internal/router/api-v1-general-page-bottom-embeds.go4
-rw-r--r--internal/router/api-v1-general-page-footer.go4
-rw-r--r--internal/router/api-v1-general-page-header.go5
-rw-r--r--internal/router/api-v1-general-page-top-embeds.go4
-rw-r--r--internal/router/api-v1-general-page.go8
-rw-r--r--internal/router/api-v1-like.go2
-rw-r--r--internal/router/api-v1-subs.go67
-rw-r--r--internal/router/client-cache.go18
-rw-r--r--internal/router/lang-blog-title.go86
-rw-r--r--internal/router/lang-blog.go98
-rw-r--r--internal/router/lang-map.go2
-rw-r--r--internal/router/lang-user-unsubscribe.go71
-rw-r--r--internal/router/root.go27
-rw-r--r--internal/templatemanager/templatemanager.go1
22 files changed, 1101 insertions, 250 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
new file mode 100644
index 0000000..d6dbd17
--- /dev/null
+++ b/internal/mailer/mailer.go
@@ -0,0 +1,544 @@
+package mailer
+
+import (
+ "crypto/rand"
+ "database/sql"
+ "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"
+ "github.com/gofiber/fiber/v2"
+ "github.com/wneessen/go-mail"
+ "golang.org/x/crypto/argon2"
+)
+
+type Mailer struct {
+ verificationCodes *ristretto.Cache[uint64, string]
+ unsubscribeCodes *ristretto.Cache[uint64, []byte]
+ db *sql.DB
+ tm *templatemanager.TemplateManager
+ mailClient *mail.Client
+ clientHost string
+ mailAddress string
+ publicName string
+ salt []byte
+
+ lostMailMap map[string]struct {
+ Dur time.Duration
+ End time.Time
+ CodeExpiry time.Time
+ }
+ lostMailMapMutex sync.RWMutex
+
+ hashMap map[string][]byte
+ hashMapMutex sync.RWMutex
+
+ 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, // 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)
+ }
+
+ tm, err := templatemanager.NewTemplateManager(templatemanager.TemplateManagerTemplates{
+ Name: "new-post",
+ Files: []string{"views/layouts/general-mail.html", "views/messages/new-post.html"},
+ }, templatemanager.TemplateManagerTemplates{
+ Name: "verify-email",
+ Files: []string{"views/layouts/general-mail.html", "views/messages/verify-email.html"},
+ })
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize template manager for message templating: %w", err)
+ }
+
+ mailClient, err := mail.NewClient(mailHost,
+ mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), mail.WithTLSPortPolicy(mail.TLSMandatory),
+ mail.WithUsername(username), mail.WithPassword(password),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize mail client: %w", err)
+ }
+
+ return &Mailer{
+ verificationCodes: verificationCodes,
+ unsubscribeCodes: unsubscribeCodes,
+ db: db,
+ clientHost: clientHost,
+ tm: tm,
+ mailClient: mailClient,
+ mailAddress: mailAddress,
+ publicName: publicName,
+ salt: salt,
+ hashMap: make(map[string][]byte),
+ lostMailMap: make(map[string]struct {
+ Dur time.Duration
+ End time.Time
+ CodeExpiry time.Time
+ }, 0),
+ l: localization}, nil
+}
+
+func (m *Mailer) GetHash(id string) []byte {
+ m.hashMapMutex.RLock()
+ if val, ok := m.hashMap[id]; ok {
+ m.hashMapMutex.RUnlock()
+ slog.Debug("gave an old hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(val)))
+ return val
+ }
+ m.hashMapMutex.RUnlock()
+
+ m.hashMapMutex.Lock()
+ defer m.hashMapMutex.Unlock()
+
+ if val, ok := m.hashMap[id]; ok {
+ slog.Debug("gave a newly generated hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(val)))
+ return val
+ }
+
+ m.hashMap[id] = argon2.IDKey([]byte(id), m.salt, 1, 64*1024, 4, 32)
+ slog.Debug("generated hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(m.hashMap[id])))
+ return m.hashMap[id]
+}
+
+func (m *Mailer) IsAllowedToRetryVerification(userId string) (retryAllowed bool, whenAllowed time.Time, codeExpiry time.Time) {
+ m.lostMailMapMutex.RLock()
+ defer m.lostMailMapMutex.RUnlock()
+ previous, ok := m.lostMailMap[userId]
+ if ok && previous.End.After(time.Now()) {
+ return false, previous.End, previous.CodeExpiry
+ }
+ return true, time.Time{}, previous.CodeExpiry
+}
+
+func (m *Mailer) MailIsTaken(email string) (bool, error) {
+ tx, err := m.db.Begin()
+ if err != nil {
+ 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 rows.Close()
+
+ isTaken := rows.Next()
+ return isTaken, nil
+}
+
+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;`, userIdHash); err != nil {
+ tx.Rollback()
+ return "", "", fmt.Errorf("failed to query user-email settings in db: %s", err)
+ }
+ defer rows.Close()
+
+ if !rows.Next() {
+ return "", "", nil
+ }
+
+ if err = rows.Scan(&email, &lang); err != nil {
+ return "", "", fmt.Errorf("failed to scan the result from user-email settings query: %s", err)
+ }
+ 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()
+
+ 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(address); err != nil {
+ return fmt.Errorf("failed to set TO address: %w", err)
+ }
+
+ message.SetMessageID()
+ message.SetDate()
+ message.SetBulk()
+
+ dur := 1 * time.Minute
+ m.lostMailMapMutex.Lock()
+ if previous, ok := m.lostMailMap[userId]; ok {
+ if time.Now().Before(previous.End) {
+ m.lostMailMapMutex.Unlock()
+ return fmt.Errorf("user is not allowed to send another verification code until %s", previous.End)
+ }
+ dur = 2 * m.lostMailMap[userId].Dur
+ }
+ m.lostMailMap[userId] = struct {
+ Dur time.Duration
+ End time.Time
+ CodeExpiry time.Time
+ }{Dur: dur, End: time.Now().Add(dur), CodeExpiry: time.Now().Add(time.Hour)}
+ m.lostMailMapMutex.Unlock()
+
+ verificationCodeBytes := make([]byte, 8)
+ rand.Read(verificationCodeBytes)
+ verificationCode := binary.LittleEndian.Uint64(verificationCodeBytes)
+
+ verificationInfo := fmt.Sprintf("%s.%s", base64.RawStdEncoding.EncodeToString([]byte(userId)), base64.RawStdEncoding.EncodeToString([]byte(address)))
+ m.verificationCodes.Set(verificationCode, verificationInfo, int64(len(verificationInfo)+8))
+
+ message.Subject(m.l[lang].Mail.VerifyEmail.Subject)
+
+ msg, err := m.tm.Render("verify-email", fiber.Map{
+ "L": m.l[lang],
+ "Lang": lang,
+ "VerificationCode": fmt.Sprintf("%X", verificationCode),
+ "ClientHost": m.clientHost,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to render message body: %w", err)
+ }
+
+ message.SetBodyString(mail.TypeTextHTML, string(msg))
+ if err := m.mailClient.DialAndSend(message); err != nil {
+ return fmt.Errorf("failed to send verification code message: %w", err)
+ }
+ slog.Debug("verification code message successfully delivered", slog.String("address", address), slog.String("user_id", userId))
+ return nil
+}
+
+func (m *Mailer) Verify(verificationCodeEncoded string, lang string) error {
+ verificationCode, err := strconv.ParseUint(verificationCodeEncoded, 16, 64)
+ if err != nil {
+ return fmt.Errorf("failed to decode verification code from 8-byte hex: %w", err)
+ }
+
+ verificationInfo, _ := m.verificationCodes.Get(verificationCode)
+ if verificationInfo == "" {
+ return fmt.Errorf("failed to get verification info by its code (might be absent, might be empty)")
+ }
+
+ verificationSegments := strings.Split(verificationInfo, ".")
+ if len(verificationSegments) != 2 {
+ m.verificationCodes.Del(verificationCode)
+ return fmt.Errorf("invalid format of verification info: expected %d segments, got %d", 2, len(verificationSegments))
+ }
+
+ userId, err := base64.RawStdEncoding.DecodeString(verificationSegments[0])
+ if err != nil {
+ m.verificationCodes.Del(verificationCode)
+ delete(m.lostMailMap, verificationSegments[0])
+ return fmt.Errorf("could not decode user id: %s", err)
+ }
+
+ address, err := base64.RawStdEncoding.DecodeString(verificationSegments[1])
+ if err != nil {
+ m.verificationCodes.Del(verificationCode)
+ delete(m.lostMailMap, verificationSegments[0])
+ return fmt.Errorf("could not decode address: %s", err)
+ }
+
+ 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", "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-blog-search.go b/internal/router/api-v1-blog-search.go
index ab5425c..5b1d14d 100644
--- a/internal/router/api-v1-blog-search.go
+++ b/internal/router/api-v1-blog-search.go
@@ -17,7 +17,7 @@ import (
)
func init() {
- tm.Add("catalogue-blog-cards", "views/partials/catalogue-blog-cards.html", "views/partials/catalogue-blog-card-tags.html")
+ assert(0, tm.Add("catalogue-blog-cards", "views/partials/catalogue-blog-cards.html", "views/partials/catalogue-blog-card-tags.html"))
}
func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
diff --git a/internal/router/api-v1-email-is-in-verification.go b/internal/router/api-v1-email-is-in-verification.go
new file mode 100644
index 0000000..83c783c
--- /dev/null
+++ b/internal/router/api-v1-email-is-in-verification.go
@@ -0,0 +1,58 @@
+package router
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+
+ "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_Email_IsInVerification(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:
+ isAllowed, whenAllowed, codeExpiry := Mailer.IsAllowedToRetryVerification(id)
+ if !isAllowed {
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "Neutral", fiber.StatusOK, lang, strings.ReplaceAll(l[lang].UserProfile.DelayTilVerification, "{}", whenAllowed.Format("2006-01-02 15:04:05 MST")), map[string]string{
+ "striked-end-time": fmt.Sprint(whenAllowed.UnixMilli()),
+ "code-expiry-time": fmt.Sprint(codeExpiry.UnixMilli()),
+ })
+ }
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "OK", fiber.StatusOK, lang, "", map[string]string{
+ "code-expiry-time": fmt.Sprint(codeExpiry.UnixMilli()),
+ })
+ }
+}
diff --git a/internal/router/api-v1-email-send-verification-code.go b/internal/router/api-v1-email-send-verification-code.go
new file mode 100644
index 0000000..9372eb3
--- /dev/null
+++ b/internal/router/api-v1-email-send-verification-code.go
@@ -0,0 +1,98 @@
+package router
+
+import (
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net/url"
+ "strings"
+
+ "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_Email_SendVerificationCode(l map[string]*locale.LocaleConfig) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ referer := c.Get("Referer", "")
+ if referer == "" {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
+ }
+
+ urlStruct, err := url.ParseRequestURI(referer)
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
+ }
+
+ path := urlStruct.EscapedPath()
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) != 2 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/user'")
+ }
+
+ lang := pathParts[0]
+ id := c.IP()
+
+ email := c.FormValue("email")
+ if email == "" {
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.EmailEmpty, map[string]string{})
+ }
+
+ isTaken, err := Mailer.MailIsTaken(email)
+ if err != nil {
+ slog.Error("failed to check if address is already taken", slog.String("error", err.Error()))
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.VerificationCodeSendingError, map[string]string{})
+ }
+ if isTaken {
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.EmailTaken, map[string]string{})
+ }
+
+ if isAllowed, whenAllowed, _ := Mailer.IsAllowedToRetryVerification(id); !isAllowed {
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, strings.ReplaceAll(l[lang].UserProfile.DelayTilVerification, "{}", whenAllowed.Format("2006-01-02 15:04:05 MST")), map[string]string{
+ "striked-end-time": fmt.Sprint(whenAllowed.UnixMilli()),
+ })
+ }
+
+ 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{})
+ }
+
+ if err = Mailer.SendVerificationCode(id, email, lang); err != nil {
+ slog.Error("failed to send a verification code", slog.String("error", err.Error()))
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.VerificationCodeSendingError, map[string]string{})
+ }
+
+ _, endTime, codeExpiry := Mailer.IsAllowedToRetryVerification(id)
+ return api_v1_email_sendStatusHtml(c, "email-message", l, "OK", fiber.StatusOK, lang, strings.ReplaceAll(l[lang].UserProfile.VerificationCodeSent, "{}", endTime.Format("2006-01-02 15:04:05 MST")), map[string]string{
+ "striked-end-time": fmt.Sprint(endTime.UnixMilli()),
+ "code-expiry-time": fmt.Sprint(codeExpiry.UnixMilli()),
+ })
+ }
+}
+
+func api_v1_email_sendStatusHtml(c *fiber.Ctx, divId string, l map[string]*locale.LocaleConfig, status string, code int, lang string, message string, dataAttributes map[string]string) error {
+ sterileDataset := make(map[string]interface{})
+ for k, v := range dataAttributes {
+ sterileDataset[k] = template.HTMLAttr(fmt.Sprintf("data-%s=\"%s\"", k, template.HTMLEscapeString(v)))
+ }
+
+ content, err := tm.Render("personal-page-status", fiber.Map{
+ "L": l[lang],
+ "Status": status,
+ "Message": message,
+ "StatusId": divId,
+ "DataAttributes": sterileDataset,
+ })
+ if err != nil {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ slog.Error("failed to render the email status message", slog.String("error", err.Error()), slog.String("div_id", divId), slog.String("message", message))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("Failed to render the email status message, please ask administrator for a more detailed cause")
+ }
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(code).Send(content)
+}
diff --git a/internal/router/api-v1-email-verify.go b/internal/router/api-v1-email-verify.go
new file mode 100644
index 0000000..7fbfdec
--- /dev/null
+++ b/internal/router/api-v1-email-verify.go
@@ -0,0 +1,52 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "strings"
+
+ "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_Email_Verify(l map[string]*locale.LocaleConfig) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ referer := c.Get("Referer", "")
+ if referer == "" {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
+ }
+
+ urlStruct, err := url.ParseRequestURI(referer)
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
+ }
+
+ path := urlStruct.EscapedPath()
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) != 2 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/user'")
+ }
+
+ lang := pathParts[0]
+
+ verificationCode := c.FormValue("email_code")
+ if verificationCode == "" {
+ return api_v1_email_sendStatusHtml(c, "verification-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.VerificationEmpty, map[string]string{})
+ }
+
+ if err = Mailer.Verify(verificationCode, lang); err != nil {
+ 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+"\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 fe21957..b5567b7 100644
--- a/internal/router/api-v1-general-page-body.go
+++ b/internal/router/api-v1-general-page-body.go
@@ -1,6 +1,7 @@
package router
import (
+ "bytes"
"fmt"
"html/template"
"log/slog"
@@ -13,16 +14,21 @@ 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"
"github.com/SayaAndy/saya-today-web/locale"
"github.com/gofiber/fiber/v2"
"github.com/yuin/goldmark"
)
var FactGiver *factgiver.FactGiver
+var Mailer *mailer.Mailer
+var BlogTrigger *blogtrigger.BlogTriggerScheduler
func init() {
- tm.Add("general-page-body", "views/partials/general-page-body.html")
+ assert(0, tm.Add("general-page-body", "views/partials/general-page-body.html"))
}
func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client, md goldmark.Markdown) func(c *fiber.Ctx) error {
@@ -39,15 +45,16 @@ func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []config.A
}
path := urlStruct.EscapedPath()
+ trimmedPath := strings.Trim(path, "/")
- cacheKey := fmt.Sprintf("body.%s", path)
- if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ cacheKey := fmt.Sprintf("body.%s", trimmedPath)
+ if val, ok := PCache.Get(cacheKey); !strings.HasSuffix(trimmedPath, "user") && val != nil && ok {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
return c.Status(fiber.StatusOK).Type("html").Send(val)
}
lang := ""
- pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ pathParts := strings.Split(trimmedPath, "/")
if len(pathParts) == 1 && pathParts[0] == "" {
pathParts = []string{}
}
@@ -90,39 +97,54 @@ 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]++
- }
+ values["Tags"] = tagsArray
+ values["QuerySort"] = querySort
+ values["QueryTags"] = strings.Join(queryTags, ",")
+ values["Title"] = l[lang].BlogSearch.Header
+
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 2 && pathParts[1] == "user" {
+ values["Title"] = l[lang].UserProfile.Header
+
+ 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()))
}
- 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, 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")
}
- tagsArray := make([]Tag, 0, len(tagsMap))
- for tag, count := range tagsMap {
- tagsArray = append(tagsArray, Tag{tag, count})
+ 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")
}
- 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, ",")
- values["Title"] = l[lang].BlogSearch.Header
+ switch subscriptionType {
+ case mailer.None:
+ values["TagsPicked"] = "none"
+ case mailer.All:
+ values["TagsPicked"] = "all"
+ case mailer.Specific:
+ values["TagsPicked"] = "specific"
+ }
- additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ 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" {
metadata, parsedMarkdown, err := readBlogPost(md, b2Client, lang+"/"+pathParts[2])
if err != nil {
@@ -174,3 +196,48 @@ func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []config.A
return c.Status(fiber.StatusOK).Type("html").Send(content)
}
}
+
+func readBlogPost(md goldmark.Markdown, b2Client *b2.B2Client, sourceName string) (metadata *frontmatter.Metadata, html string, err error) {
+ metadata, markdown, err := b2Client.ReadFrontmatter(sourceName + ".md")
+ if err != nil {
+ return nil, "", fmt.Errorf("failed to read a frontmatter file: %w", err)
+ }
+
+ var buf bytes.Buffer
+ if err := md.Convert(markdown, &buf); err != nil {
+ return nil, "", fmt.Errorf("convert source context from md to html: %w", err)
+ }
+
+ 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-general-page-bottom-embeds.go b/internal/router/api-v1-general-page-bottom-embeds.go
index 0a2030f..b408187 100644
--- a/internal/router/api-v1-general-page-bottom-embeds.go
+++ b/internal/router/api-v1-general-page-bottom-embeds.go
@@ -14,7 +14,7 @@ import (
)
func init() {
- tm.Add("general-page-bottom-embeds", "views/partials/general-page-bottom-embeds.html")
+ assert(0, tm.Add("general-page-bottom-embeds", "views/partials/general-page-bottom-embeds.html"))
}
func Api_V1_GeneralPage_BottomEmbeds(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
@@ -63,6 +63,8 @@ func Api_V1_GeneralPage_BottomEmbeds(l map[string]*locale.LocaleConfig, langs []
if len(pathParts) == 2 && pathParts[1] == "blog" {
additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 2 && pathParts[1] == "user" {
+ additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
} else if len(pathParts) == 3 && pathParts[1] == "blog" {
additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
} else if len(pathParts) == 1 {
diff --git a/internal/router/api-v1-general-page-footer.go b/internal/router/api-v1-general-page-footer.go
index 2030b3b..99d899a 100644
--- a/internal/router/api-v1-general-page-footer.go
+++ b/internal/router/api-v1-general-page-footer.go
@@ -13,7 +13,7 @@ import (
)
func init() {
- tm.Add("general-page-footer", "views/partials/general-page-footer.html")
+ assert(0, tm.Add("general-page-footer", "views/partials/general-page-footer.html"))
}
func Api_V1_GeneralPage_Footer(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) func(c *fiber.Ctx) error {
@@ -63,6 +63,8 @@ func Api_V1_GeneralPage_Footer(l map[string]*locale.LocaleConfig, langs []config
if len(pathParts) == 2 && pathParts[1] == "blog" {
additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 2 && pathParts[1] == "user" {
+ additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
} else if len(pathParts) == 3 && pathParts[1] == "blog" {
additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
} else if len(pathParts) == 1 {
diff --git a/internal/router/api-v1-general-page-header.go b/internal/router/api-v1-general-page-header.go
index a2c832b..768417d 100644
--- a/internal/router/api-v1-general-page-header.go
+++ b/internal/router/api-v1-general-page-header.go
@@ -14,7 +14,7 @@ import (
)
func init() {
- tm.Add("general-page-header", "views/partials/general-page-header.html")
+ assert(0, tm.Add("general-page-header", "views/partials/general-page-header.html"))
}
func Api_V1_GeneralPage_Header(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
@@ -65,6 +65,9 @@ func Api_V1_GeneralPage_Header(l map[string]*locale.LocaleConfig, langs []config
if len(pathParts) == 2 && pathParts[1] == "blog" {
values["Title"] = l[lang].BlogSearch.Header
additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 2 && pathParts[1] == "user" {
+ values["Title"] = l[lang].UserProfile.Header
+ additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
} else if len(pathParts) == 3 && pathParts[1] == "blog" {
metadata, _, err := b2Client.ReadFrontmatter(lang + "/" + pathParts[2] + ".md")
if err != nil {
diff --git a/internal/router/api-v1-general-page-top-embeds.go b/internal/router/api-v1-general-page-top-embeds.go
index 544e3a4..f3e480b 100644
--- a/internal/router/api-v1-general-page-top-embeds.go
+++ b/internal/router/api-v1-general-page-top-embeds.go
@@ -13,7 +13,7 @@ import (
)
func init() {
- tm.Add("general-page-top-embeds", "views/partials/general-page-top-embeds.html")
+ assert(0, tm.Add("general-page-top-embeds", "views/partials/general-page-top-embeds.html"))
}
func Api_V1_GeneralPage_TopEmbeds(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) func(c *fiber.Ctx) error {
@@ -63,6 +63,8 @@ func Api_V1_GeneralPage_TopEmbeds(l map[string]*locale.LocaleConfig, langs []con
if len(pathParts) == 2 && pathParts[1] == "blog" {
additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 2 && pathParts[1] == "user" {
+ additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
} else if len(pathParts) == 3 && pathParts[1] == "blog" {
additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
} else if len(pathParts) == 1 {
diff --git a/internal/router/api-v1-general-page.go b/internal/router/api-v1-general-page.go
index a77fc5e..c8b4362 100644
--- a/internal/router/api-v1-general-page.go
+++ b/internal/router/api-v1-general-page.go
@@ -1,6 +1,7 @@
package router
import (
+ "fmt"
"log/slog"
"strings"
@@ -10,7 +11,7 @@ import (
)
func init() {
- tm.Add("general-page", "views/layouts/general-page.html")
+ assert(0, tm.Add("general-page", "views/layouts/general-page.html"))
}
func Api_V1_GeneralPage(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) func(c *fiber.Ctx) error {
@@ -34,7 +35,8 @@ func Api_V1_GeneralPage(l map[string]*locale.LocaleConfig, langs []config.Availa
}
langIsAvailable:
- cacheKey := "general-page." + lang
+ queryString := string(c.Request().URI().QueryString())
+ cacheKey := fmt.Sprintf("general-page.%s?%s", lang, queryString)
if val, ok := PCache.Get(cacheKey); val != nil && ok {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
return c.Status(fiber.StatusOK).Type("html").Send(val)
@@ -43,7 +45,7 @@ func Api_V1_GeneralPage(l map[string]*locale.LocaleConfig, langs []config.Availa
content, err := tm.Render("general-page", fiber.Map{
"L": l[lang],
"Lang": lang,
- "QueryString": string(c.Request().URI().QueryString()),
+ "QueryString": queryString,
})
if err != nil {
slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
diff --git a/internal/router/api-v1-like.go b/internal/router/api-v1-like.go
index 327dafa..323c457 100644
--- a/internal/router/api-v1-like.go
+++ b/internal/router/api-v1-like.go
@@ -13,7 +13,7 @@ import (
)
func init() {
- tm.Add("blog-page-like-button", "views/partials/blog-page-like-button.html")
+ assert(0, tm.Add("blog-page-like-button", "views/partials/blog-page-like-button.html"))
}
func Api_V1_Like_Put(l map[string]*locale.LocaleConfig, b2 *b2.B2Client) func(c *fiber.Ctx) error {
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-blog-title.go b/internal/router/lang-blog-title.go
deleted file mode 100644
index 6e80671..0000000
--- a/internal/router/lang-blog-title.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package router
-
-import (
- "bytes"
- "fmt"
- "html/template"
- "log/slog"
- "strconv"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/config"
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/internal/frontmatter"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
- "github.com/yuin/goldmark"
-)
-
-func init() {
- tm.Add("blog-page", "views/layouts/general-page.html", "views/pages/blog-page.html")
-}
-
-func Lang_Blog_Title(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client, md goldmark.Markdown) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- ip := c.IP()
- slog.Debug("client entering blog page", slog.String("ip", ip), slog.String("page", c.Path()))
-
- 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:
- metadata, parsedMarkdown, err := readBlogPost(md, b2Client, lang+"/"+c.Params("title"))
- if err != nil {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("failed to find '%s' post", c.Params("title")))
- }
-
- geolocationParts := strings.Split(metadata.Geolocation, " ")
- var x, y, areaError string
- if len(geolocationParts) >= 2 {
- x = geolocationParts[0]
- y = geolocationParts[1]
- }
- if len(geolocationParts) >= 3 {
- areaError = geolocationParts[2]
- }
-
- content, err := tm.Render("blog-page", fiber.Map{
- "Title": metadata.Title,
- "PublishedDate": metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00"),
- "PublishedYear": strconv.Itoa(metadata.PublishedTime.Year()),
- "ParsedMarkdown": template.HTML(parsedMarkdown),
- "MapLocationX": x,
- "MapLocationY": y,
- "MapLocationAreaMeters": areaError,
- "Lang": lang,
- "QueryString": string(c.Request().URI().QueryString()),
- })
- if err != nil {
- slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/blog/"+c.Params("title")), 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").Send(content)
- }
-}
-
-func readBlogPost(md goldmark.Markdown, b2Client *b2.B2Client, sourceName string) (metadata *frontmatter.Metadata, html string, err error) {
- metadata, markdown, err := b2Client.ReadFrontmatter(sourceName + ".md")
- if err != nil {
- return nil, "", fmt.Errorf("failed to read a frontmatter file: %w", err)
- }
-
- var buf bytes.Buffer
- if err := md.Convert(markdown, &buf); err != nil {
- return nil, "", fmt.Errorf("convert source context from md to html: %w", err)
- }
-
- return metadata, buf.String(), nil
-}
diff --git a/internal/router/lang-blog.go b/internal/router/lang-blog.go
deleted file mode 100644
index 960ac3d..0000000
--- a/internal/router/lang-blog.go
+++ /dev/null
@@ -1,98 +0,0 @@
-package router
-
-import (
- "fmt"
- "log/slog"
- "net/url"
- "regexp"
- "slices"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/config"
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- tm.Add("blog-catalogue", "views/layouts/general-page.html", "views/pages/blog-catalogue.html")
-}
-
-func Lang_Blog(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) 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:
- querySort := c.Query("sort")
- if querySort == "" {
- querySort = "publicationDateDesc"
- }
-
- encodedQuery := c.Request().URI().QueryString()
- re, err := regexp.Compile(`tags\[\]=([\w]+)`)
- if err != nil {
- slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error()))
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering")
- }
- decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
- matches := re.FindAllStringSubmatch(decodedQuery, -1)
-
- queryTags := make([]string, 0, len(matches))
- for _, match := range matches {
- queryTags = append(queryTags, string(match[1]))
- }
-
- pages, err := b2Client.Scan(lang + "/")
- status := fiber.StatusOK
- if err != nil {
- status = fiber.StatusPartialContent
- pages = []*b2.BlogPage{}
- }
-
- 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)
- })
-
- content, err := tm.Render("blog-catalogue", fiber.Map{
- "QuerySort": querySort,
- "QueryTags": strings.Join(queryTags, ","),
- "QueryString": string(c.Request().URI().QueryString()),
- "Tags": tagsArray,
- "Lang": lang,
- "L": l[lang],
- "PublishedYear": "2025",
- "Title": l[lang].BlogSearch.Header,
- })
- if err != nil {
- slog.Warn("failed to generate page", slog.String("path", c.Path()), 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/internal/router/lang-map.go b/internal/router/lang-map.go
index a744f02..619b05b 100644
--- a/internal/router/lang-map.go
+++ b/internal/router/lang-map.go
@@ -13,7 +13,7 @@ import (
)
func init() {
- tm.Add("global-map", "views/pages/global-map.html")
+ assert(0, tm.Add("global-map", "views/pages/global-map.html"))
}
func Lang_Map(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
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/internal/router/root.go b/internal/router/root.go
deleted file mode 100644
index f4a0692..0000000
--- a/internal/router/root.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package router
-
-import (
- "log/slog"
-
- "github.com/SayaAndy/saya-today-web/config"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- tm.Add("index", "views/index.html")
-}
-
-func Root(localeCfg []config.AvailableLanguageConfig) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- content, err := tm.Render("index", fiber.Map{
- "AvailableLanguages": localeCfg,
- })
- if err != nil {
- slog.Warn("failed to generate page", slog.String("page", "/"), 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").Send(content)
- }
-}
diff --git a/internal/templatemanager/templatemanager.go b/internal/templatemanager/templatemanager.go
index eebc297..bafdee5 100644
--- a/internal/templatemanager/templatemanager.go
+++ b/internal/templatemanager/templatemanager.go
@@ -31,6 +31,7 @@ var templateFuncMap = template.FuncMap{
}
return items
},
+ "replace": strings.ReplaceAll,
}
func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager, error) {