diff options
| author | 2025-10-23 01:15:26 +0700 | |
|---|---|---|
| committer | 2025-10-23 01:15:26 +0700 | |
| commit | 0d261d2f7d080b610f50102576073e334a89e8cb (patch) | |
| tree | 3f86877a48cdb02486d6b5cb771a420c82c7eacf /internal | |
| parent | 695889f42896c1e3f1290a588ac1c5483d0124f0 (diff) | |
| download | web-0d261d2f7d080b610f50102576073e334a89e8cb.tar.gz web-0d261d2f7d080b610f50102576073e334a89e8cb.zip | |
feat: email linking
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/mailer/mailer.go | 281 | ||||
| -rw-r--r-- | internal/router/api-v1-blog-search.go | 2 | ||||
| -rw-r--r-- | internal/router/api-v1-email-is-in-verification.go | 58 | ||||
| -rw-r--r-- | internal/router/api-v1-email-send-verification-code.go | 98 | ||||
| -rw-r--r-- | internal/router/api-v1-email-verify.go | 52 | ||||
| -rw-r--r-- | internal/router/api-v1-general-page-body.go | 38 | ||||
| -rw-r--r-- | internal/router/api-v1-general-page-bottom-embeds.go | 4 | ||||
| -rw-r--r-- | internal/router/api-v1-general-page-footer.go | 4 | ||||
| -rw-r--r-- | internal/router/api-v1-general-page-header.go | 5 | ||||
| -rw-r--r-- | internal/router/api-v1-general-page-top-embeds.go | 4 | ||||
| -rw-r--r-- | internal/router/api-v1-general-page.go | 8 | ||||
| -rw-r--r-- | internal/router/api-v1-like.go | 2 | ||||
| -rw-r--r-- | internal/router/lang-blog-title.go | 86 | ||||
| -rw-r--r-- | internal/router/lang-blog.go | 98 | ||||
| -rw-r--r-- | internal/router/lang-map.go | 2 | ||||
| -rw-r--r-- | internal/router/root.go | 27 | ||||
| -rw-r--r-- | internal/templatemanager/templatemanager.go | 1 |
17 files changed, 545 insertions, 225 deletions
diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go new file mode 100644 index 0000000..450b818 --- /dev/null +++ b/internal/mailer/mailer.go @@ -0,0 +1,281 @@ +package mailer + +import ( + "crypto/rand" + "database/sql" + "encoding/base64" + "encoding/binary" + "fmt" + "log/slog" + "strconv" + "strings" + "sync" + "time" + + "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] + 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 +} + +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, + }) + 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, + 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) + } + + 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 +} + +func (m *Mailer) GetInfo(userId string) (email string, lang string, err error) { + hash := m.GetHash(userId) + + tx, err := m.db.Begin() + if err != nil { + return "", "", fmt.Errorf("failed to initialize transaction with db: %s", err) + } + + var rows *sql.Rows + if rows, err = tx.Query(`SELECT email, lang FROM user_email_table WHERE user_id=? LIMIT 1;`, hash); 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() { + 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) 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) + } + + 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() + return fmt.Errorf("failed to configure user-email settings in db: %s", err) + } + + if err = tx.Commit(); err != nil { + tx.Rollback() + return fmt.Errorf("failed to commit transaction to db: %s", err) + } + + m.verificationCodes.Del(verificationCode) + delete(m.lostMailMap, verificationSegments[0]) + 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..2cde1bd --- /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(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..fad174b --- /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, 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..6f856d3 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" @@ -14,15 +15,18 @@ import ( "github.com/SayaAndy/saya-today-web/config" "github.com/SayaAndy/saya-today-web/internal/b2" "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 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 +43,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{} } @@ -123,6 +128,17 @@ func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []config.A 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(c.IP()) + if err != nil { + slog.Error("get info from mailer about a client", slog.String("error", err.Error())) + } + values["Email"] = email + values["EmailCode"] = c.Query("email_code") + + 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 +190,17 @@ 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 +} 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/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/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) { |