Diffstat (limited to 'internal')
27 files changed, 1323 insertions, 338 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/factgiver/factgiver.go b/internal/factgiver/factgiver.go index 6f77403..5b418ee 100644 --- a/internal/factgiver/factgiver.go +++ b/internal/factgiver/factgiver.go @@ -14,13 +14,13 @@ import ( type FactGiver struct { b2Client *b2.B2Client cache map[string][]string - langs []string + langs []config.AvailableLanguageConfig factsFileName string nlRe *regexp.Regexp randGen *rand.Rand } -func NewFactGiver(cfg *config.FactGiverConfig, langs []string) (*FactGiver, error) { +func NewFactGiver(cfg *config.FactGiverConfig, langs []config.AvailableLanguageConfig) (*FactGiver, error) { b2Client, err := b2.NewB2Client(&cfg.Storage.Config) if err != nil { return nil, fmt.Errorf("fail to init b2 client for a new fact giver: %s", err.Error()) @@ -52,15 +52,15 @@ func (g *FactGiver) Give(lang string) [3]string { func (g *FactGiver) initCache() error { for _, lang := range g.langs { - localFacts := strings.Replace(g.factsFileName, "*", lang, 1) + localFacts := strings.Replace(g.factsFileName, "*", lang.Name, 1) factsContentBytes, err := g.b2Client.ReadAll(localFacts) if err != nil { - return fmt.Errorf("fail to read '%s' facts file: %s", lang, err.Error()) + return fmt.Errorf("fail to read '%s' facts file: %s", lang.Name, err.Error()) } factsContent := string(factsContentBytes) - g.cache[lang] = g.nlRe.Split(factsContent, -1) - if g.cache[lang][len(g.cache[lang])-1] == "" { - g.cache[lang] = g.cache[lang][:len(g.cache[lang])-1] + g.cache[lang.Name] = g.nlRe.Split(factsContent, -1) + if g.cache[lang.Name][len(g.cache[lang.Name])-1] == "" { + g.cache[lang.Name] = g.cache[lang.Name][:len(g.cache[lang.Name])-1] } } return nil diff --git a/internal/glightbox/block.go b/internal/glightbox/block.go index 696f328..e53e67b 100644 --- a/internal/glightbox/block.go +++ b/internal/glightbox/block.go @@ -15,6 +15,7 @@ type GLightboxBlock struct { type GLightboxImage struct { URL string + Tags []string Caption []byte } diff --git a/internal/glightbox/html_renderer.go b/internal/glightbox/html_renderer.go index 43d4790..7022793 100644 --- a/internal/glightbox/html_renderer.go +++ b/internal/glightbox/html_renderer.go @@ -4,11 +4,14 @@ import ( "bytes" "fmt" "math/rand" + "regexp" "strings" "time" + "github.com/SayaAndy/saya-today-web/internal/tailwind" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer" "github.com/yuin/goldmark/renderer/html" "github.com/yuin/goldmark/util" @@ -16,22 +19,36 @@ import ( type GLightboxHTMLRenderer struct { html.Config - md goldmark.Markdown + md goldmark.Markdown + anchorMatchRe *regexp.Regexp } func NewGLightboxHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { r := &GLightboxHTMLRenderer{ Config: html.NewConfig(), md: goldmark.New( - goldmark.WithRendererOptions( - html.WithHardWraps(), - html.WithXHTML(), + goldmark.WithParserOptions( + parser.WithAutoHeadingID(), + parser.WithAttribute(), + ), + goldmark.WithRenderer( + renderer.NewRenderer( + renderer.WithNodeRenderers( + util.Prioritized(tailwind.NewCustomLinkRenderer( + html.WithUnsafe(), html.WithHardWraps(), html.WithXHTML(), + ), 50), + util.Prioritized(html.NewRenderer( + html.WithUnsafe(), html.WithHardWraps(), html.WithXHTML(), + ), 100), + ), + ), ), ), } for _, opt := range opts { opt.SetHTMLOption(&r.Config) } + r.anchorMatchRe = regexp.MustCompile(`(?s)<\s*a(\s+[^<]*)(href\s*=\s*["'].*?["'])\s*([^<]*)>(.*?)<\s*\/\s*a\s*>`) return r } @@ -78,11 +95,34 @@ func (r *GLightboxHTMLRenderer) renderGLightbox(w util.BufWriter, source []byte, dataDescriptionAttribute = fmt.Sprintf("data-description=\".%s\"", glightboxDescId) } + tagClassList := make([]string, 0, len(img.Tags)) + for _, tag := range img.Tags { + switch tag { + case "2x": + tagClassList = append(tagClassList, "grid-item-2x") + } + } + + if len(img.Caption) > 0 { + tagClassList = append(tagClassList, "grid-tooltip") + } + + anchorlessCaptionHTML := r.anchorMatchRe.ReplaceAll(captionHTML, []byte("<span class=\"linklike\" $1 $3>$4</span>")) + elements = append(elements, fmt.Sprintf(` - <a href="https://f003.backblazeb2.com/file/sayana-photos/full/%s" class="glightbox-%s grid-item grid-item-%s" - data-gallery="gallery-%s" data-title="%s" %s> - <img src="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp" class="rounded-md p-1" alt="webp thumbnail" /> - </a>`, img.URL, galleryID, galleryID, galleryID, dayDate.Format("2006-01-02 15:04:05 -07:00"), dataDescriptionAttribute, imageUrlWithoutExt)) + <a href="https://f003.backblazeb2.com/file/sayana-photos/full/%s" class="glightbox grid-item %s grid-item-%s p-1" + data-gallery="gallery" data-title="%s" %s> + <picture> + <source media="(width < 800px)" srcset="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp" /> + <source media="(width < 2400px)" srcset="https://f003.backblazeb2.com/file/sayana-photos/webp-560p/%s.webp" /> + <source media="(width < 3200px)" srcset="https://f003.backblazeb2.com/file/sayana-photos/webp-800p/%s.webp" /> + <source media="(width < 4000px)" srcset="https://f003.backblazeb2.com/file/sayana-photos/webp-1200p/%s.webp" /> + <source media="(width >= 4000px)" srcset="https://f003.backblazeb2.com/file/sayana-photos/webp-1600p/%s.webp" /> + <img src="https://f003.backblazeb2.com/file/sayana-photos/webp-560p/%s.webp" /> + </picture> + <span class="grid-tooltip-text"><p>%s</p></span> + <span class="grid-item-index">%d</span> + </a>`, img.URL, strings.Join(tagClassList, " "), galleryID, dayDate.Format("2006-01-02 15:04:05 -07:00"), dataDescriptionAttribute, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, anchorlessCaptionHTML, i+1)) if glightboxDescId != "" { elements = append(elements, fmt.Sprintf(` @@ -93,31 +133,43 @@ func (r *GLightboxHTMLRenderer) renderGLightbox(w util.BufWriter, source []byte, } w.WriteString(fmt.Sprintf(` -<div class="justify-content-center display-block m-1"> - <hr class="border-t-3 border-dotted border-main-dark mt-1 mb-2 w-[80%%] ml-auto mr-auto"> - <div class="grid masonry-grid-%s"> +<div class="justify-content-center display-block"> + <hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%%] ml-auto mr-auto"> + <div class="grid masonry-grid-%s mx-auto"> <div class="grid-sizer grid-sizer-%s"></div> %s </div> - <hr class="border-t-3 border-dotted border-main-dark mt-1 mb-2 w-[80%%] ml-auto mr-auto"> + <hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%%] ml-auto mr-auto"> </div>`, galleryID, galleryID, strings.Join(elements, "\n"))) w.WriteString(fmt.Sprintf(` <script> - var lightbox_%s = GLightbox({ - selector: '.glightbox-%s' - }); - var msnry_%s = new Masonry('.masonry-grid-%s', { itemSelector: '.grid-item-%s', columnWidth: '.grid-sizer-%s', - percentPosition: true + percentPosition: false, + fitWidth: true }); - imagesLoaded('.masonry-grid-%s', function() { - msnry_%s.layout(); - }); -</script>`, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID)) + var imgLoad_%s_timer; + var imgLoad_%s = imagesLoaded('.masonry-grid-%s'); + + function initMasonryLayout_%s(tm) { + clearTimeout(imgLoad_%s_timer); + imgLoad_%s_timer = setTimeout(() => msnry_%s.layout(), 100); + } + + imgLoad_%s.on('progress', initMasonryLayout_%s); + + window.addEventListener('resize', initMasonryLayout_%s); + + document.addEventListener('popout', (e) => { + window.removeEventListener('resize', initMasonryLayout_%s); + imgLoad_%s.off('progress', initMasonryLayout_%s); + initMasonryLayout_%s = null; + imgLoad_%s = null; + }, {once: true}); +</script>`, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID)) } return ast.WalkContinue, nil diff --git a/internal/glightbox/parser.go b/internal/glightbox/parser.go index ba3f22e..6780090 100644 --- a/internal/glightbox/parser.go +++ b/internal/glightbox/parser.go @@ -4,6 +4,7 @@ import ( "bytes" "log/slog" "regexp" + "strings" "time" "github.com/yuin/goldmark/ast" @@ -60,16 +61,27 @@ func (p *GLightboxParser) Continue(node ast.Node, reader text.Reader, pc parser. gallery := node.(*GLightboxBlock) - parts := bytes.SplitN(trimmed, []byte{'|'}, 2) + parts := bytes.SplitN(trimmed, []byte{'|'}, 3) url := bytes.TrimSpace(parts[0]) caption := make([]byte, 0) - if len(parts) > 1 { + tagsRaw := "" + if len(parts) == 2 { caption = bytes.TrimSpace(parts[1]) } + if len(parts) >= 3 { + tagsRaw = string(parts[1]) + caption = bytes.TrimSpace(parts[2]) + } + + tags := strings.Split(tagsRaw, ",") + for i := range tags { + tags[i] = strings.TrimSpace(tags[i]) + } gallery.Images = append(gallery.Images, GLightboxImage{ URL: string(url), + Tags: tags, Caption: caption, }) 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 69be651..5b1d14d 100644 --- a/internal/router/api-v1-blog-search.go +++ b/internal/router/api-v1-blog-search.go @@ -10,26 +10,31 @@ import ( "strings" "time" + "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("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 []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error { +func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error { sort := c.Query("sort") lang := c.Query("lang") tz := c.Query("tz") - if !slices.Contains(langs, lang) { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang)) + for _, availableLang := range langs { + if availableLang.Name == lang { + goto langIsAvailable + } } + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang)) + langIsAvailable: loc, err := time.LoadLocation(tz) if err != nil { loc = time.UTC 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 c1a4a9f..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" @@ -11,20 +12,26 @@ import ( "strings" "time" + "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 []string, b2Client *b2.B2Client, md goldmark.Markdown) func(c *fiber.Ctx) error { +func Api_V1_GeneralPage_Body(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 { c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) @@ -38,26 +45,34 @@ func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []string, } 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) } - pathParts := strings.Split(strings.Trim(path, "/"), "/") - if len(pathParts) == 0 { - return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") + lang := "" + pathParts := strings.Split(trimmedPath, "/") + if len(pathParts) == 1 && pathParts[0] == "" { + pathParts = []string{} } - - lang := pathParts[0] - if !slices.Contains(langs, lang) { - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) + if len(pathParts) > 0 { + lang = pathParts[0] + for _, availableLang := range langs { + if availableLang.Name == lang { + goto langIsAvailable + } + } + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") } + langIsAvailable: values := fiber.Map{ "L": l[lang], "Lang": lang, + "Path": strings.Trim(path, "/"), "QueryString": string(c.Request().URI().QueryString()), } var additionalTemplates []string @@ -82,39 +97,54 @@ func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []string, 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 { @@ -149,6 +179,10 @@ func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []string, values["GifName"] = fmt.Sprintf("otter-%d.gif", rand.Int()%3+1) values["FunFacts"] = FactGiver.Give(lang) additionalTemplates = append(additionalTemplates, "views/pages/home-page.html") + } else if len(pathParts) == 0 { + values["Title"] = "Choose Your Language" + values["AvailableLanguages"] = langs + additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html") } content, err := tm.Render("general-page-body", values, additionalTemplates...) @@ -162,3 +196,48 @@ func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []string, 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 c4d9543..b408187 100644 --- a/internal/router/api-v1-general-page-bottom-embeds.go +++ b/internal/router/api-v1-general-page-bottom-embeds.go @@ -4,20 +4,20 @@ import ( "fmt" "log/slog" "net/url" - "slices" "strings" "time" + "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("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 []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error { +func Api_V1_GeneralPage_BottomEmbeds(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) @@ -37,29 +37,40 @@ func Api_V1_GeneralPage_BottomEmbeds(l map[string]*locale.LocaleConfig, langs [] return c.Status(fiber.StatusOK).Type("html").Send(val) } + lang := "" pathParts := strings.Split(strings.Trim(path, "/"), "/") - if len(pathParts) == 0 { - return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") + if len(pathParts) == 1 && pathParts[0] == "" { + pathParts = []string{} } - - lang := pathParts[0] - if !slices.Contains(langs, lang) { - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) + if len(pathParts) > 0 { + lang = pathParts[0] + for _, availableLang := range langs { + if availableLang.Name == lang { + goto langIsAvailable + } + } + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") } + langIsAvailable: values := fiber.Map{ "L": l[lang], "Lang": lang, + "Path": strings.Trim(path, "/"), "QueryString": string(c.Request().URI().QueryString()), } var additionalTemplates []string 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 { additionalTemplates = append(additionalTemplates, "views/pages/home-page.html") + } else if len(pathParts) == 0 { + additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html") } content, err := tm.Render("general-page-bottom-embeds", values, additionalTemplates...) diff --git a/internal/router/api-v1-general-page-footer.go b/internal/router/api-v1-general-page-footer.go index 003f54c..99d899a 100644 --- a/internal/router/api-v1-general-page-footer.go +++ b/internal/router/api-v1-general-page-footer.go @@ -4,19 +4,19 @@ import ( "fmt" "log/slog" "net/url" - "slices" "strings" "time" + "github.com/SayaAndy/saya-today-web/config" "github.com/SayaAndy/saya-today-web/locale" "github.com/gofiber/fiber/v2" ) 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 []string) func(c *fiber.Ctx) error { +func Api_V1_GeneralPage_Footer(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) @@ -37,29 +37,40 @@ func Api_V1_GeneralPage_Footer(l map[string]*locale.LocaleConfig, langs []string return c.Status(fiber.StatusOK).Type("html").Send(val) } + lang := "" pathParts := strings.Split(strings.Trim(path, "/"), "/") - if len(pathParts) == 0 { - return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") + if len(pathParts) == 1 && pathParts[0] == "" { + pathParts = []string{} } - - lang := pathParts[0] - if !slices.Contains(langs, lang) { - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) + if len(pathParts) > 0 { + lang = pathParts[0] + for _, availableLang := range langs { + if availableLang.Name == lang { + goto langIsAvailable + } + } + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") } + langIsAvailable: values := fiber.Map{ "L": l[lang], "Lang": lang, + "Path": strings.Trim(path, "/"), "QueryString": string(c.Request().URI().QueryString()), } var additionalTemplates []string 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 { additionalTemplates = append(additionalTemplates, "views/pages/home-page.html") + } else if len(pathParts) == 0 { + additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html") } content, err := tm.Render("general-page-footer", values, additionalTemplates...) diff --git a/internal/router/api-v1-general-page-header.go b/internal/router/api-v1-general-page-header.go index 80228f7..768417d 100644 --- a/internal/router/api-v1-general-page-header.go +++ b/internal/router/api-v1-general-page-header.go @@ -4,20 +4,20 @@ import ( "fmt" "log/slog" "net/url" - "slices" "strings" "time" + "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("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 []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error { +func Api_V1_GeneralPage_Header(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) @@ -38,19 +38,26 @@ func Api_V1_GeneralPage_Header(l map[string]*locale.LocaleConfig, langs []string return c.Status(fiber.StatusOK).Type("html").Send(val) } + lang := "" pathParts := strings.Split(strings.Trim(path, "/"), "/") - if len(pathParts) == 0 { - return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") + if len(pathParts) == 1 && pathParts[0] == "" { + pathParts = []string{} } - - lang := pathParts[0] - if !slices.Contains(langs, lang) { - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) + if len(pathParts) > 0 { + lang = pathParts[0] + for _, availableLang := range langs { + if availableLang.Name == lang { + goto langIsAvailable + } + } + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") } + langIsAvailable: values := fiber.Map{ "L": l[lang], "Lang": lang, + "Path": strings.Trim(path, "/"), "QueryString": string(c.Request().URI().QueryString()), } var additionalTemplates []string @@ -58,6 +65,9 @@ func Api_V1_GeneralPage_Header(l map[string]*locale.LocaleConfig, langs []string 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 { @@ -70,6 +80,9 @@ func Api_V1_GeneralPage_Header(l map[string]*locale.LocaleConfig, langs []string } else if len(pathParts) == 1 { values["Title"] = l[lang].HomePage.Header additionalTemplates = append(additionalTemplates, "views/pages/home-page.html") + } else if len(pathParts) == 0 { + values["Title"] = "Choose Your Language" + additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html") } content, err := tm.Render("general-page-header", values, additionalTemplates...) diff --git a/internal/router/api-v1-general-page-top-embeds.go b/internal/router/api-v1-general-page-top-embeds.go index 78021b2..f3e480b 100644 --- a/internal/router/api-v1-general-page-top-embeds.go +++ b/internal/router/api-v1-general-page-top-embeds.go @@ -4,19 +4,19 @@ import ( "fmt" "log/slog" "net/url" - "slices" "strings" "time" + "github.com/SayaAndy/saya-today-web/config" "github.com/SayaAndy/saya-today-web/locale" "github.com/gofiber/fiber/v2" ) 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 []string) func(c *fiber.Ctx) error { +func Api_V1_GeneralPage_TopEmbeds(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) @@ -37,29 +37,40 @@ func Api_V1_GeneralPage_TopEmbeds(l map[string]*locale.LocaleConfig, langs []str return c.Status(fiber.StatusOK).Type("html").Send(val) } + lang := "" pathParts := strings.Split(strings.Trim(path, "/"), "/") - if len(pathParts) == 0 { - return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") + if len(pathParts) == 1 && pathParts[0] == "" { + pathParts = []string{} } - - lang := pathParts[0] - if !slices.Contains(langs, lang) { - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) + if len(pathParts) > 0 { + lang = pathParts[0] + for _, availableLang := range langs { + if availableLang.Name == lang { + goto langIsAvailable + } + } + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") } + langIsAvailable: values := fiber.Map{ "L": l[lang], "Lang": lang, + "Path": strings.Trim(path, "/"), "QueryString": string(c.Request().URI().QueryString()), } var additionalTemplates []string 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 { additionalTemplates = append(additionalTemplates, "views/pages/home-page.html") + } else if len(pathParts) == 0 { + additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html") } content, err := tm.Render("general-page-top-embeds", values, additionalTemplates...) diff --git a/internal/router/api-v1-general-page.go b/internal/router/api-v1-general-page.go index d0ea559..c8b4362 100644 --- a/internal/router/api-v1-general-page.go +++ b/internal/router/api-v1-general-page.go @@ -3,33 +3,40 @@ package router import ( "fmt" "log/slog" - "slices" "strings" + "github.com/SayaAndy/saya-today-web/config" "github.com/SayaAndy/saya-today-web/locale" "github.com/gofiber/fiber/v2" ) 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 []string) func(c *fiber.Ctx) error { +func Api_V1_GeneralPage(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) path := c.Path() + lang := "" pathParts := strings.Split(strings.Trim(path, "/"), "/") - if len(pathParts) == 0 { - return c.Status(fiber.ErrBadRequest.Code).SendString("url path is invalid: expect format '/{lang}/...'") + if len(pathParts) == 1 && pathParts[0] == "" { + pathParts = []string{} } - - lang := pathParts[0] - if !slices.Contains(langs, lang) { - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) + if len(pathParts) > 0 { + lang = pathParts[0] + for _, availableLang := range langs { + if availableLang.Name == lang { + goto langIsAvailable + } + } + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") } - cacheKey := "general-page." + lang + langIsAvailable: + 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) @@ -38,7 +45,7 @@ func Api_V1_GeneralPage(l map[string]*locale.LocaleConfig, langs []string) func( 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 d6ca917..0000000 --- a/internal/router/lang-blog-title.go +++ /dev/null @@ -1,83 +0,0 @@ -package router - -import ( - "bytes" - "fmt" - "html/template" - "log/slog" - "slices" - "strconv" - "strings" - - "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 []string, 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") - if !slices.Contains(langs, lang) { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) - } - - 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 38a6597..0000000 --- a/internal/router/lang-blog.go +++ /dev/null @@ -1,94 +0,0 @@ -package router - -import ( - "fmt" - "log/slog" - "net/url" - "regexp" - "slices" - "strings" - - "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 []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error { - return func(c *fiber.Ctx) error { - lang := c.Params("lang") - if !slices.Contains(langs, lang) { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) - } - - 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 e94d756..619b05b 100644 --- a/internal/router/lang-map.go +++ b/internal/router/lang-map.go @@ -3,27 +3,30 @@ package router import ( "fmt" "log/slog" - "slices" "strconv" "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("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 []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error { +func Lang_Map(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") - if !slices.Contains(langs, lang) { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", 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: pages, err := b2Client.Scan(lang + "/") status := fiber.StatusOK if err != nil { 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/tailwind/transformer.go b/internal/tailwind/transformer.go index 23ba1a3..c53ded1 100644 --- a/internal/tailwind/transformer.go +++ b/internal/tailwind/transformer.go @@ -20,19 +20,19 @@ func (t *TailwindTransformer) Transform(node *ast.Document, reader text.Reader, switch node := n.(type) { case *ast.Heading: classes := map[int]string{ - 1: "font-patua text-4xl font-bold text-main-dark mb-3 tracking-[.0125rem]", - 2: "font-spectral text-3xl font-bold text-main-dark mb-1 tracking-[.0125rem]", - 3: "font-spectral text-2xl font-medium text-main-dark mb-0.8 tracking-[.0125rem]", - 4: "font-spectral text-xl font-medium text-main-medium mb-0.5 tracking-[.0125rem]", - 5: "font-spectral text-base font-medium text-main-medium mb-0.5 italic tracking-[.0125rem]", - 6: "font-spectral text-base font-medium text-secondary mb-0.5", + 1: "font-andika text-4xl font-bold text-main-hard mb-3 tracking-[.0125rem]", + 2: "font-andika text-3xl font-bold text-main-hard mb-1 tracking-[.0125rem]", + 3: "font-andika text-2xl font-medium text-main-hard mb-0.8 tracking-[.0125rem]", + 4: "font-andika text-xl font-medium text-main-medium mb-0.5 tracking-[.0125rem]", + 5: "font-andika text-base font-medium text-main-medium mb-0.5 italic tracking-[.0125rem]", + 6: "font-andika text-base font-medium text-secondary mb-0.5", } if class, ok := classes[node.Level]; ok { node.SetAttribute([]byte("class"), []byte(class)) } case *ast.Paragraph: - node.SetAttribute([]byte("class"), []byte("text-base/[2] font-spectral tracking-[.0125rem] -indent-8 ml-4 mb-8")) + node.SetAttribute([]byte("class"), []byte("text-base/[2] font-andika tracking-[.0125rem] -indent-8 ml-4 mb-8")) case *ast.List: if node.IsOrdered() { @@ -42,7 +42,7 @@ func (t *TailwindTransformer) Transform(node *ast.Document, reader text.Reader, } case *ast.ListItem: - node.SetAttribute([]byte("class"), []byte("text-base/[2] font-spectral tracking-[.0125rem]")) + node.SetAttribute([]byte("class"), []byte("text-base/[2] font-andika tracking-[.0125rem]")) case *ast.Blockquote: node.SetAttribute([]byte("class"), []byte("border-l-2 border-main-medium bg-background-dark p-1 mb-2 italic")) @@ -58,7 +58,7 @@ func (t *TailwindTransformer) Transform(node *ast.Document, reader text.Reader, onclickAttr := fmt.Appendf(make([]byte, 0, 21+len(node.Destination)), "return changeUrl('%s');", node.Destination) node.SetAttribute([]byte("onclick"), onclickAttr) } - node.SetAttribute([]byte("class"), []byte("text-secondary hover:text-main-dark underline")) + node.SetAttribute([]byte("class"), []byte("text-secondary hover:text-main-hard underline")) case *ast.Image: node.SetAttribute([]byte("class"), []byte("max-w-full h-auto rounded-lg shadow-lg mb-4")) 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) { |