summaryrefslogtreecommitdiff
path: root/internal
diff refs
from: back
to: back
| flip
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/b2/client.go8
-rw-r--r--internal/blogtrigger/blogtrigger.go72
-rw-r--r--internal/factgiver/factgiver.go67
-rw-r--r--internal/frontmatter/parser.go3
-rw-r--r--internal/glightbox/block.go32
-rw-r--r--internal/glightbox/extension.go (renamed from internal/lightgallery/extension.go)14
-rw-r--r--internal/glightbox/html_renderer.go186
-rw-r--r--internal/glightbox/parser.go (renamed from internal/lightgallery/parser.go)42
-rw-r--r--internal/lightgallery/block.go31
-rw-r--r--internal/lightgallery/html_renderer.go147
-rw-r--r--internal/mailer/mailer.go544
-rw-r--r--internal/router/api-v1-blog-search.go128
-rw-r--r--internal/router/api-v1-email-is-in-verification.go58
-rw-r--r--internal/router/api-v1-email-send-verification-code.go98
-rw-r--r--internal/router/api-v1-email-verify.go52
-rw-r--r--internal/router/api-v1-general-page-body.go243
-rw-r--r--internal/router/api-v1-general-page-bottom-embeds.go86
-rw-r--r--internal/router/api-v1-general-page-footer.go86
-rw-r--r--internal/router/api-v1-general-page-header.go98
-rw-r--r--internal/router/api-v1-general-page-top-embeds.go86
-rw-r--r--internal/router/api-v1-general-page.go59
-rw-r--r--internal/router/api-v1-like.go122
-rw-r--r--internal/router/api-v1-subs.go67
-rw-r--r--internal/router/api-v1-tz.go37
-rw-r--r--internal/router/client-cache.go320
-rw-r--r--internal/router/lang-map.go88
-rw-r--r--internal/router/lang-user-unsubscribe.go71
-rw-r--r--internal/router/page-cache.go5
-rw-r--r--internal/router/tm.go18
-rw-r--r--internal/tailwind/link_renderer.go49
-rw-r--r--internal/tailwind/transformer.go35
-rw-r--r--internal/templatemanager/templatemanager.go57
32 files changed, 2786 insertions, 223 deletions
diff --git a/internal/b2/client.go b/internal/b2/client.go
index 5322b84..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,8 @@ 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
new file mode 100644
index 0000000..5b418ee
--- /dev/null
+++ b/internal/factgiver/factgiver.go
@@ -0,0 +1,67 @@
+package factgiver
+
+import (
+ "fmt"
+ "math/rand"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+)
+
+type FactGiver struct {
+ b2Client *b2.B2Client
+ cache map[string][]string
+ langs []config.AvailableLanguageConfig
+ factsFileName string
+ nlRe *regexp.Regexp
+ randGen *rand.Rand
+}
+
+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())
+ }
+
+ factGiver := &FactGiver{
+ b2Client: b2Client,
+ cache: make(map[string][]string, len(langs)),
+ langs: langs,
+ factsFileName: cfg.FactsFileName,
+ nlRe: regexp.MustCompile(`\r?\n`),
+ randGen: rand.New(rand.NewSource(time.Now().UnixNano())),
+ }
+ if err = factGiver.initCache(); err != nil {
+ return nil, fmt.Errorf("fail to init cache for a new fact giver: %s", err.Error())
+ }
+
+ return factGiver, nil
+}
+
+func (g *FactGiver) Give(lang string) [3]string {
+ factSlice := make([]string, len(g.cache[lang]))
+ copy(factSlice, g.cache[lang])
+ g.randGen.Shuffle(len(factSlice), func(i, j int) {
+ factSlice[i], factSlice[j] = factSlice[j], factSlice[i]
+ })
+ return [3]string{factSlice[0], factSlice[1], factSlice[2]}
+}
+
+func (g *FactGiver) initCache() error {
+ for _, lang := range g.langs {
+ 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.Name, err.Error())
+ }
+ factsContent := string(factsContentBytes)
+ 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/frontmatter/parser.go b/internal/frontmatter/parser.go
index 3c00a7f..83312dc 100644
--- a/internal/frontmatter/parser.go
+++ b/internal/frontmatter/parser.go
@@ -16,7 +16,8 @@ type Metadata struct {
Thumbnail string `yaml:"thumbnail"`
Tags []string `yaml:"tags"`
Geolocation string `yaml:"geolocation"`
- Timezone string `yaml:"timezone"`
+ Medley string `yaml:"medley"`
+ MedleyPart int `yaml:"medleyPart"`
}
func ParseFrontmatter(content []byte) (metadata *Metadata, markdown []byte, err error) {
diff --git a/internal/glightbox/block.go b/internal/glightbox/block.go
new file mode 100644
index 0000000..e53e67b
--- /dev/null
+++ b/internal/glightbox/block.go
@@ -0,0 +1,32 @@
+package glightbox
+
+import (
+ "time"
+
+ "github.com/yuin/goldmark/ast"
+)
+
+// GLightboxBlock represents a light gallery block in the AST
+type GLightboxBlock struct {
+ ast.BaseBlock
+ Images []GLightboxImage
+ Location *time.Location
+}
+
+type GLightboxImage struct {
+ URL string
+ Tags []string
+ Caption []byte
+}
+
+var KindGLightboxBlock = ast.NewNodeKind("GLightboxBlock")
+
+// Dump implements ast.Node.Dump
+func (n *GLightboxBlock) Dump(source []byte, level int) {
+ ast.DumpHelper(n, source, level, nil, nil)
+}
+
+// Kind implements ast.Node.Kind
+func (n *GLightboxBlock) Kind() ast.NodeKind {
+ return KindGLightboxBlock
+}
diff --git a/internal/lightgallery/extension.go b/internal/glightbox/extension.go
index 761dc05..7fd17b1 100644
--- a/internal/lightgallery/extension.go
+++ b/internal/glightbox/extension.go
@@ -1,4 +1,4 @@
-package lightgallery
+package glightbox
import (
"github.com/yuin/goldmark"
@@ -8,21 +8,21 @@ import (
)
// Extension that combines parser and renderer
-type LightGalleryExtension struct{}
+type GLightboxExtension struct{}
-func NewLightGalleryExtension() goldmark.Extender {
- return &LightGalleryExtension{}
+func NewGLightboxExtension() goldmark.Extender {
+ return &GLightboxExtension{}
}
-func (e *LightGalleryExtension) Extend(m goldmark.Markdown) {
+func (e *GLightboxExtension) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(
parser.WithBlockParsers(
- util.Prioritized(NewLightGalleryParser(), 500),
+ util.Prioritized(NewGLightboxParser(), 500),
),
)
m.Renderer().AddOptions(
renderer.WithNodeRenderers(
- util.Prioritized(NewLightGalleryHTMLRenderer(), 500),
+ util.Prioritized(NewGLightboxHTMLRenderer(), 500),
),
)
}
diff --git a/internal/glightbox/html_renderer.go b/internal/glightbox/html_renderer.go
new file mode 100644
index 0000000..7022793
--- /dev/null
+++ b/internal/glightbox/html_renderer.go
@@ -0,0 +1,186 @@
+package glightbox
+
+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"
+)
+
+type GLightboxHTMLRenderer struct {
+ html.Config
+ md goldmark.Markdown
+ anchorMatchRe *regexp.Regexp
+}
+
+func NewGLightboxHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
+ r := &GLightboxHTMLRenderer{
+ Config: html.NewConfig(),
+ md: goldmark.New(
+ 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
+}
+
+func (r *GLightboxHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+ reg.Register(KindGLightboxBlock, r.renderGLightbox)
+}
+
+func (r *GLightboxHTMLRenderer) renderGLightbox(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
+ if entering {
+ gallery := n.(*GLightboxBlock)
+
+ if len(gallery.Images) == 0 {
+ return ast.WalkContinue, nil
+ }
+
+ galleryID := generateDivId(8)
+
+ var elements []string
+ for i, img := range gallery.Images {
+ imageUrlSegments := strings.Split(img.URL, ".")
+ imageUrlWithoutExt := strings.Join(imageUrlSegments[:len(imageUrlSegments)-1], ".")
+ imageUrlParts := strings.Split(imageUrlWithoutExt, "/")
+ imageNameParts := strings.Split(imageUrlParts[len(imageUrlParts)-1], "-")
+
+ var captionBuf bytes.Buffer
+ captionHTML := img.Caption
+ if err := r.md.Convert(img.Caption, &captionBuf); err == nil {
+ captionHTML = captionBuf.Bytes()
+ captionHTML = bytes.TrimPrefix(captionHTML, []byte("<p>"))
+ captionHTML = bytes.TrimSuffix(captionHTML, []byte("</p>\n"))
+ captionHTML = bytes.TrimSuffix(captionHTML, []byte("</p>"))
+ }
+
+ dayDate, _ := time.Parse("20060102 150405", imageNameParts[len(imageNameParts)-2]+" "+imageNameParts[len(imageNameParts)-1])
+ dayDate = dayDate.In(gallery.Location)
+
+ glightboxDescId := ""
+ if len(captionHTML) != 0 {
+ glightboxDescId = fmt.Sprintf("glightbox-desc-%s-%d", galleryID, i)
+ }
+
+ dataDescriptionAttribute := ""
+ if glightboxDescId != "" {
+ 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 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(`
+ <div class="glightbox-desc rounded-4 %s">
+ <p>%s</p>
+ </div>`, glightboxDescId, captionHTML))
+ }
+ }
+
+ w.WriteString(fmt.Sprintf(`
+<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-hard mt-1 mb-2 w-[80%%] ml-auto mr-auto">
+</div>`, galleryID, galleryID, strings.Join(elements, "\n")))
+
+ w.WriteString(fmt.Sprintf(`
+<script>
+ var msnry_%s = new Masonry('.masonry-grid-%s', {
+ itemSelector: '.grid-item-%s',
+ columnWidth: '.grid-sizer-%s',
+ percentPosition: false,
+ fitWidth: true
+ });
+
+ 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
+}
+
+func generateDivId(length int) string {
+ const charset = "abcdefghijklmnopqrstuvwxyz"
+ seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) // Seed with current time
+ b := make([]byte, length)
+ for i := range b {
+ b[i] = charset[seededRand.Intn(len(charset))]
+ }
+ return string(b)
+}
diff --git a/internal/lightgallery/parser.go b/internal/glightbox/parser.go
index daec797..6780090 100644
--- a/internal/lightgallery/parser.go
+++ b/internal/glightbox/parser.go
@@ -1,9 +1,10 @@
-package lightgallery
+package glightbox
import (
"bytes"
"log/slog"
"regexp"
+ "strings"
"time"
"github.com/yuin/goldmark/ast"
@@ -11,17 +12,17 @@ import (
"github.com/yuin/goldmark/text"
)
-type LightGalleryParser struct{}
+type GLightboxParser struct{}
-func NewLightGalleryParser() parser.BlockParser {
- return &LightGalleryParser{}
+func NewGLightboxParser() parser.BlockParser {
+ return &GLightboxParser{}
}
-func (p *LightGalleryParser) Trigger() []byte {
+func (p *GLightboxParser) Trigger() []byte {
return []byte{'{'}
}
-func (p *LightGalleryParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
+func (p *GLightboxParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
line, _ := reader.PeekLine()
if !bytes.HasPrefix(line, []byte("{Gallery:")) {
@@ -43,10 +44,10 @@ func (p *LightGalleryParser) Open(parent ast.Node, reader text.Reader, pc parser
return nil, parser.NoChildren
}
- return &LightGalleryBlock{Location: loc}, parser.NoChildren
+ return &GLightboxBlock{Location: loc}, parser.NoChildren
}
-func (p *LightGalleryParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State {
+func (p *GLightboxParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State {
line, segment := reader.PeekLine()
if len(line) == 0 || segment.Len() == 0 {
return parser.Continue | parser.NoChildren
@@ -58,31 +59,42 @@ func (p *LightGalleryParser) Continue(node ast.Node, reader text.Reader, pc pars
return parser.Close
}
- gallery := node.(*LightGalleryBlock)
+ 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, LightGalleryImage{
+ gallery.Images = append(gallery.Images, GLightboxImage{
URL: string(url),
+ Tags: tags,
Caption: caption,
})
return parser.Continue | parser.NoChildren
}
-func (p *LightGalleryParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {
+func (p *GLightboxParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {
}
-func (p *LightGalleryParser) CanInterruptParagraph() bool {
+func (p *GLightboxParser) CanInterruptParagraph() bool {
return true
}
-func (p *LightGalleryParser) CanAcceptIndentedLine() bool {
+func (p *GLightboxParser) CanAcceptIndentedLine() bool {
return false
}
diff --git a/internal/lightgallery/block.go b/internal/lightgallery/block.go
deleted file mode 100644
index 8eb7c9e..0000000
--- a/internal/lightgallery/block.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package lightgallery
-
-import (
- "time"
-
- "github.com/yuin/goldmark/ast"
-)
-
-// LightGalleryBlock represents a light gallery block in the AST
-type LightGalleryBlock struct {
- ast.BaseBlock
- Images []LightGalleryImage
- Location *time.Location
-}
-
-type LightGalleryImage struct {
- URL string
- Caption []byte
-}
-
-var KindLightGalleryBlock = ast.NewNodeKind("LightGalleryBlock")
-
-// Dump implements ast.Node.Dump
-func (n *LightGalleryBlock) Dump(source []byte, level int) {
- ast.DumpHelper(n, source, level, nil, nil)
-}
-
-// Kind implements ast.Node.Kind
-func (n *LightGalleryBlock) Kind() ast.NodeKind {
- return KindLightGalleryBlock
-}
diff --git a/internal/lightgallery/html_renderer.go b/internal/lightgallery/html_renderer.go
deleted file mode 100644
index 850cd95..0000000
--- a/internal/lightgallery/html_renderer.go
+++ /dev/null
@@ -1,147 +0,0 @@
-package lightgallery
-
-import (
- "bytes"
- "fmt"
- "math/rand"
- "strings"
- "time"
-
- "github.com/yuin/goldmark"
- "github.com/yuin/goldmark/ast"
- "github.com/yuin/goldmark/renderer"
- "github.com/yuin/goldmark/renderer/html"
- "github.com/yuin/goldmark/util"
-)
-
-type LightGalleryHTMLRenderer struct {
- html.Config
- md goldmark.Markdown
-}
-
-func NewLightGalleryHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
- r := &LightGalleryHTMLRenderer{
- Config: html.NewConfig(),
- md: goldmark.New(
- goldmark.WithRendererOptions(
- html.WithHardWraps(),
- html.WithXHTML(),
- ),
- ),
- }
- for _, opt := range opts {
- opt.SetHTMLOption(&r.Config)
- }
- return r
-}
-
-func (r *LightGalleryHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
- reg.Register(KindLightGalleryBlock, r.renderLightGallery)
-}
-
-func (r *LightGalleryHTMLRenderer) renderLightGallery(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
- if entering {
- gallery := n.(*LightGalleryBlock)
-
- if len(gallery.Images) == 0 {
- return ast.WalkContinue, nil
- }
-
- galleryID := generateDivId(8)
-
- w.WriteString(fmt.Sprintf(`
-<div class="justify-content-center display-block m-2">
- <hr class="border-t-4 border-dotted border-main-dark mt-[0.8vmin] mb-[0.8vmin] w-[80%%] ml-auto mr-auto">
- <div id="lg-%s" class="inline-gallery-container relative ml-auto mr-auto"></div>
- <hr class="border-t-4 border-dotted border-main-dark mt-[0.8vmin] mb-[0.8vmin] w-[80%%] ml-auto mr-auto">
-</div>`, galleryID))
-
- var dynamicElements []string
- for _, img := range gallery.Images {
- imageUrlSegments := strings.Split(img.URL, ".")
- imageUrlWithoutExt := strings.Join(imageUrlSegments[:len(imageUrlSegments)-1], ".")
- imageNameParts := strings.Split(imageUrlWithoutExt, "-")
-
- var captionBuf bytes.Buffer
- captionHTML := img.Caption
- if err := r.md.Convert(img.Caption, &captionBuf); err == nil {
- captionHTML = captionBuf.Bytes()
- captionHTML = bytes.TrimPrefix(captionHTML, []byte("<p>"))
- captionHTML = bytes.TrimSuffix(captionHTML, []byte("</p>\n"))
- captionHTML = bytes.TrimSuffix(captionHTML, []byte("</p>"))
- }
-
- dayDate, _ := time.Parse("20060102 150405", imageNameParts[len(imageNameParts)-2]+" "+imageNameParts[len(imageNameParts)-1])
- dayDate = dayDate.In(gallery.Location)
- dynamicElements = append(dynamicElements, fmt.Sprintf(`{
- src:
- "https://f003.backblazeb2.com/file/sayana-photos/full/%s",
- downloadUrl:
- "https://f003.backblazeb2.com/file/sayana-photos/full/%s",
- alt: "%s",
- sources: [{
- srcset: "https://f003.backblazeb2.com/file/sayana-photos/thumbnails/%s.webp",
- media: "(max-width: 800px)"
- }],
- thumb:
- "https://f003.backblazeb2.com/file/sayana-photos/thumbnails/%s.webp",
- subHtml: `+"`"+`<div class="flex flex-row light-gallery-captions">
- <p class="grow !text-[1vmax]/[0.9] text-left font-spectral text-main-dark">%s</p>
- <p class="!text-[1vmax]/[0.9] text-right font-spectral text-secondary">%s</p>
- </div>`+"`"+`
- }`, img.URL, img.URL, util.EscapeHTML(captionHTML), imageUrlWithoutExt, imageUrlWithoutExt, captionHTML, dayDate.Format("2006-01-02 15:04:05 -07:00")))
- }
-
- w.WriteString(fmt.Sprintf(`
-<script>
-function createLightLibrary%s() {
- const $lgContainer = document.getElementById('lg-%s');
- const inlineGallery = lightGallery($lgContainer, {
- container: $lgContainer,
- dynamic: true,
- dynamicEl: [%s],
- width: "100%%",
- height: "50vmin",
- hash: false,
- closable: false,
- showMaximizeIcon: true,
- appendSubHtmlTo: ".lg-sub-html",
- isMobile: () => false,
- slideDelay: 0,
- plugins: [lgZoom, lgThumbnail],
- thumbWidth: 160,
- thumbHeight: "10vmin",
- thumbMargin: 4
- });
-
- setTimeout(() => {
- inlineGallery.openGallery();
- }, 200);
-}
-
-document.addEventListener('DOMContentLoaded', createLightLibrary%s);
-
-let resizeTimer%s;
-window.addEventListener("resize", () => {
- clearTimeout(resizeTimer%s);
- resizeTimer%s = setTimeout(() => {
- const $lgContainer = document.getElementById('lg-%s');
- $lgContainer.innerHTML = "";
- createLightLibrary%s();
- }, 1000);
-});
-</script>`, galleryID, galleryID, strings.Join(dynamicElements, ","), galleryID, galleryID, galleryID, galleryID, galleryID, galleryID))
- }
-
- return ast.WalkContinue, nil
-}
-
-func generateDivId(length int) string {
- const charset = "abcdefghijklmnopqrstuvwxyz"
- seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) // Seed with current time
- b := make([]byte, length)
- for i := range b {
- b[i] = charset[seededRand.Intn(len(charset))]
- }
- return string(b)
-}
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
new file mode 100644
index 0000000..5b1d14d
--- /dev/null
+++ b/internal/router/api-v1-blog-search.go
@@ -0,0 +1,128 @@
+package router
+
+import (
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/url"
+ "regexp"
+ "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() {
+ 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 {
+ return func(c *fiber.Ctx) error {
+ sort := c.Query("sort")
+ lang := c.Query("lang")
+ tz := c.Query("tz")
+
+ 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
+ slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz))
+ }
+
+ cacheKey := "blog-search." + lang + ".pages-list"
+ var pages []*b2.BlogPage
+ if pagesBytes, ok := PCache.Get(cacheKey); pagesBytes != nil || ok {
+ json.Unmarshal(pagesBytes, &pages)
+ } else {
+ pages, err = b2Client.Scan(lang + "/")
+ if err != nil {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString(fmt.Sprintf("failed to scan pages for '%s' lang: %v", lang, err))
+ }
+ pagesBytes, _ := json.Marshal(pages)
+ PCache.SetWithTTL(cacheKey, pagesBytes, int64(len(pagesBytes)), 5*time.Minute)
+ }
+
+ 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)
+
+ tags := make([]string, 0, len(matches))
+ for _, match := range matches {
+ tags = append(tags, string(match[1]))
+ }
+
+ pageMeta := make([]fiber.Map, 0, len(pages))
+ for _, page := range pages {
+ for _, tag := range page.Metadata.Tags {
+ if len(tags) == 0 || slices.Contains(tags, tag) {
+ pageMeta = append(pageMeta, fiber.Map{
+ "Link": page.Link,
+ "ArticleLink": "/" + lang + "/blog/" + page.FileName,
+ "Title": page.Metadata.Title,
+ "PublishedTime": page.Metadata.PublishedTime.In(loc).Format("2006-01-02 15:04:05 -07:00"),
+ "ActionDate": page.Metadata.ActionDate,
+ "ShortDescription": page.Metadata.ShortDescription,
+ "Thumbnail": page.Metadata.Thumbnail,
+ "Tags": page.Metadata.Tags,
+ "LikeCount": CCache.GetLikeCount(page.FileName),
+ "ViewCount": CCache.GetViewCount(page.FileName),
+ })
+ break
+ }
+ }
+ }
+
+ slices.SortFunc(pageMeta, func(a, b fiber.Map) int {
+ switch sort {
+ case "titleAsc":
+ return strings.Compare(a["Title"].(string), b["Title"].(string))
+ case "titleDesc":
+ return strings.Compare(b["Title"].(string), a["Title"].(string))
+ case "actionDateAsc":
+ return strings.Compare(a["ActionDate"].(string), b["ActionDate"].(string))
+ case "actionDateDesc":
+ return strings.Compare(b["ActionDate"].(string), a["ActionDate"].(string))
+ case "publicationDateAsc":
+ publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string))
+ publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string))
+ return publishedTimeA.Compare(publishedTimeB)
+ case "publicationDateDesc":
+ publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string))
+ publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string))
+ return publishedTimeB.Compare(publishedTimeA)
+ }
+ return 0
+ })
+
+ content, err := tm.Render("catalogue-blog-cards", fiber.Map{
+ "BlogPages": pageMeta,
+ "L": l[lang],
+ })
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("page", "/"+lang+"/blog"), slog.String("error", err.Error()))
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ return c.Type("html").Send(content)
+ }
+}
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
new file mode 100644
index 0000000..b5567b7
--- /dev/null
+++ b/internal/router/api-v1-general-page-body.go
@@ -0,0 +1,243 @@
+package router
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "math/rand"
+ "net/url"
+ "regexp"
+ "slices"
+ "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() {
+ 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 {
+ 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()
+ trimmedPath := strings.Trim(path, "/")
+
+ 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(trimmedPath, "/")
+ if len(pathParts) == 1 && pathParts[0] == "" {
+ pathParts = []string{}
+ }
+ 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" {
+ 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()))
+ 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]))
+ }
+
+ 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")
+ }
+
+ 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()))
+ }
+
+ tagsArray, err := getTags(b2Client, lang)
+ if err != nil {
+ slog.Warn("failed to get the available tags", slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to gather available tags")
+ }
+
+ subscriptionType, tags, err := Mailer.GetSubscriptions(c.IP())
+ if err != nil {
+ slog.Warn("failed to get the user subscriptions", slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to get the user subscriptions")
+ }
+
+ switch subscriptionType {
+ case mailer.None:
+ values["TagsPicked"] = "none"
+ case mailer.All:
+ values["TagsPicked"] = "all"
+ case mailer.Specific:
+ values["TagsPicked"] = "specific"
+ }
+
+ values["TagsPickedList"] = tags
+
+ values["Email"] = email
+ values["EmailCode"] = c.Query("email_code")
+ values["ExistingTags"] = tagsArray
+
+ additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ metadata, parsedMarkdown, err := readBlogPost(md, b2Client, lang+"/"+pathParts[2])
+ if err != nil {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("failed to find '%s' post", pathParts[2]))
+ }
+
+ 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]
+ }
+
+ values["MapLocationX"] = x
+ values["MapLocationY"] = y
+ values["MapLocationAreaMeters"] = areaError
+ values["Title"] = metadata.Title
+ values["ParsedMarkdown"] = template.HTML(parsedMarkdown)
+ values["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00")
+ values["ActionDate"] = metadata.ActionDate
+
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+
+ go CCache.View(c.IP(), pathParts[2])
+ } else if len(pathParts) == 1 {
+ values["Title"] = l[lang].HomePage.Header
+ values["FilledHeartCount"] = uint(40)
+ values["OutlineHeartCount"] = uint(40)
+ 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...)
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ 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
new file mode 100644
index 0000000..b408187
--- /dev/null
+++ b/internal/router/api-v1-general-page-bottom-embeds.go
@@ -0,0 +1,86 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "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() {
+ 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 {
+ 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()
+ cacheKey := fmt.Sprintf("bottom-embeds.%s", path)
+ if val, ok := PCache.Get(cacheKey); 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, "/"), "/")
+ if len(pathParts) == 1 && pathParts[0] == "" {
+ pathParts = []string{}
+ }
+ 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...)
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page-footer.go b/internal/router/api-v1-general-page-footer.go
new file mode 100644
index 0000000..99d899a
--- /dev/null
+++ b/internal/router/api-v1-general-page-footer.go
@@ -0,0 +1,86 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "strings"
+ "time"
+
+ "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("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 {
+ 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()
+
+ cacheKey := fmt.Sprintf("footer.%s", path)
+ if val, ok := PCache.Get(cacheKey); 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, "/"), "/")
+ if len(pathParts) == 1 && pathParts[0] == "" {
+ pathParts = []string{}
+ }
+ 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...)
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page-header.go b/internal/router/api-v1-general-page-header.go
new file mode 100644
index 0000000..768417d
--- /dev/null
+++ b/internal/router/api-v1-general-page-header.go
@@ -0,0 +1,98 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "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() {
+ 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 {
+ 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()
+
+ cacheKey := fmt.Sprintf("header.%s", path)
+ if val, ok := PCache.Get(cacheKey); 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, "/"), "/")
+ if len(pathParts) == 1 && pathParts[0] == "" {
+ pathParts = []string{}
+ }
+ 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" {
+ 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 {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("could not read '%s' for content", path))
+ }
+ values["Title"] = metadata.Title
+ values["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00")
+ values["ActionDate"] = metadata.ActionDate
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+ } 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...)
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page-top-embeds.go b/internal/router/api-v1-general-page-top-embeds.go
new file mode 100644
index 0000000..f3e480b
--- /dev/null
+++ b/internal/router/api-v1-general-page-top-embeds.go
@@ -0,0 +1,86 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "strings"
+ "time"
+
+ "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("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 {
+ 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()
+
+ cacheKey := fmt.Sprintf("top-embeds.%s", path)
+ if val, ok := PCache.Get(cacheKey); 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, "/"), "/")
+ if len(pathParts) == 1 && pathParts[0] == "" {
+ pathParts = []string{}
+ }
+ 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...)
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page.go b/internal/router/api-v1-general-page.go
new file mode 100644
index 0000000..c8b4362
--- /dev/null
+++ b/internal/router/api-v1-general-page.go
@@ -0,0 +1,59 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "strings"
+
+ "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("general-page", "views/layouts/general-page.html"))
+}
+
+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) == 1 && pathParts[0] == "" {
+ pathParts = []string{}
+ }
+ 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:
+ 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)
+ }
+
+ content, err := tm.Render("general-page", fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "QueryString": queryString,
+ })
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ go PCache.Set(cacheKey, content, int64(len(content)))
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-like.go b/internal/router/api-v1-like.go
new file mode 100644
index 0000000..323c457
--- /dev/null
+++ b/internal/router/api-v1-like.go
@@ -0,0 +1,122 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ 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 {
+ 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) != 3 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/blog/{page}'")
+ }
+
+ lang, page := pathParts[0], pathParts[2]
+
+ pageLink := lang + "/" + page + ".md"
+ if pages, _ := b2.Scan(pageLink); len(pages) == 0 {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server did not find '%s' article", pageLink))
+ }
+
+ newLikeStatus, err := strconv.ParseBool(c.FormValue("like", "true"))
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("invalid 'like' value")
+ }
+
+ ip := c.IP()
+ if newLikeStatus {
+ CCache.LikeOn(ip, page)
+ } else {
+ CCache.LikeOff(ip, page)
+ }
+
+ slog.Debug("someone pressed the like button!", slog.String("ip", ip), slog.String("page", page), slog.String("new_like_status", fmt.Sprint(newLikeStatus)))
+ if c.Get("HX-Request", "false") == "true" {
+ content, err := tm.Render("blog-page-like-button", fiber.Map{
+ "L": l[lang],
+ "Liked": newLikeStatus,
+ "LikedCount": CCache.GetLikeCount(page),
+ })
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Send(content)
+ }
+
+ return c.Status(fiber.StatusOK).SendString(fmt.Sprint(newLikeStatus))
+ }
+}
+
+func Api_V1_Like_Get(l map[string]*locale.LocaleConfig, b2 *b2.B2Client) 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) != 3 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/blog/{page}'")
+ }
+
+ lang, page := pathParts[0], pathParts[2]
+
+ pageLink := lang + "/" + page + ".md"
+ if pages, _ := b2.Scan(pageLink); len(pages) == 0 {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server did not find '%s' article", pageLink))
+ }
+
+ ip := c.IP()
+ likeStatus := CCache.GetLikeStatus(ip, page)
+
+ slog.Debug("someone requested the like status!", slog.String("ip", ip), slog.String("page", page), slog.Bool("like_status", likeStatus))
+ if c.Get("HX-Request", "false") == "true" {
+ content, err := tm.Render("blog-page-like-button", fiber.Map{
+ "L": l[lang],
+ "Liked": likeStatus,
+ "LikedCount": CCache.GetLikeCount(page),
+ })
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Send(content)
+ }
+
+ return c.Status(fiber.StatusOK).SendString(fmt.Sprint(likeStatus))
+ }
+}
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/api-v1-tz.go b/internal/router/api-v1-tz.go
new file mode 100644
index 0000000..4e4e462
--- /dev/null
+++ b/internal/router/api-v1-tz.go
@@ -0,0 +1,37 @@
+package router
+
+import (
+ "log/slog"
+ "time"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func Api_V1_TZ() func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ timestampString := c.Query("timestamp")
+ tz := c.Query("tz")
+
+ loc, err := time.LoadLocation(tz)
+ if err != nil {
+ loc = time.UTC
+ slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz))
+ }
+
+ var format string
+ var timestamp time.Time
+ for _, format = range []string{"2006-01-02 15:04:05 -07:00", time.RFC3339} {
+ if timestamp, err = time.Parse(format, timestampString); err == nil {
+ break
+ }
+ }
+
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).SendString("invalid timestamp format")
+ }
+
+ return c.Status(fiber.StatusOK).SendString(timestamp.In(loc).Format(format))
+ }
+}
diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go
new file mode 100644
index 0000000..e7cda30
--- /dev/null
+++ b/internal/router/client-cache.go
@@ -0,0 +1,320 @@
+package router
+
+import (
+ "database/sql"
+ "encoding/base64"
+ "fmt"
+ "log/slog"
+ "strings"
+ "sync"
+
+ "golang.org/x/crypto/argon2"
+)
+
+type ClientCache struct {
+ hashMap map[string]string
+ hashMapMutex sync.RWMutex
+
+ likePageMap map[string]map[string]struct{}
+ viewPageMap map[string]map[string]struct{}
+ pageMutexMap map[string]*sync.RWMutex
+ pageMutexMapMutex sync.Mutex
+
+ salt []byte
+ db *sql.DB
+}
+
+var CCache *ClientCache
+
+func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) {
+ tx, err := db.Begin()
+ 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)
+ }
+
+ likePageMap := make(map[string]map[string]struct{})
+ viewPageMap := make(map[string]map[string]struct{})
+ pageMutexMap := make(map[string]*sync.RWMutex)
+
+ for rows.Next() {
+ var pageRef string
+ var userId []byte
+ if err = rows.Scan(&pageRef, &userId); err != nil {
+ tx.Rollback()
+ return nil, fmt.Errorf("fail scanning blog_likes to fill cache: %w", err)
+ }
+ userIdString := base64.RawStdEncoding.EncodeToString(userId)
+ if _, ok := likePageMap[pageRef]; !ok {
+ likePageMap[pageRef] = make(map[string]struct{})
+ viewPageMap[pageRef] = make(map[string]struct{})
+ pageMutexMap[pageRef] = &sync.RWMutex{}
+ }
+ likePageMap[pageRef][userIdString] = struct{}{}
+ viewPageMap[pageRef][userIdString] = struct{}{}
+ }
+
+ 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)
+ }
+
+ for rows.Next() {
+ var pageRef string
+ var userId []byte
+ if err = rows.Scan(&pageRef, &userId); err != nil {
+ tx.Rollback()
+ return nil, fmt.Errorf("fail scanning blog_views to fill cache: %w", err)
+ }
+ userIdString := base64.RawStdEncoding.EncodeToString(userId)
+ if _, ok := viewPageMap[pageRef]; !ok {
+ viewPageMap[pageRef] = make(map[string]struct{})
+ pageMutexMap[pageRef] = &sync.RWMutex{}
+ }
+ viewPageMap[pageRef][userIdString] = struct{}{}
+ }
+
+ 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),
+ likePageMap: likePageMap,
+ viewPageMap: viewPageMap,
+ pageMutexMap: pageMutexMap,
+ salt: salt,
+ db: db,
+ }, nil
+}
+
+func (c *ClientCache) Close() error {
+ tx, err := c.db.Begin()
+ 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)
+ }
+
+ 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 {
+ c.hashMapMutex.RLock()
+ if val, ok := c.hashMap[id]; ok {
+ c.hashMapMutex.RUnlock()
+ slog.Debug("gave an old hash", slog.String("hash", val))
+ return val
+ }
+ c.hashMapMutex.RUnlock()
+
+ c.hashMapMutex.Lock()
+ defer c.hashMapMutex.Unlock()
+
+ if val, ok := c.hashMap[id]; ok {
+ slog.Debug("gave a newly generated hash", slog.String("hash", val))
+ return val
+ }
+
+ c.hashMap[id] = base64.RawStdEncoding.EncodeToString(argon2.IDKey([]byte(id), c.salt, 1, 64*1024, 4, 32))
+ slog.Debug("generated hash", slog.String("hash", c.hashMap[id]))
+ return c.hashMap[id]
+}
+
+func (c *ClientCache) getPageMutex(page string) *sync.RWMutex {
+ c.pageMutexMapMutex.Lock()
+ defer c.pageMutexMapMutex.Unlock()
+
+ if mutex, ok := c.pageMutexMap[page]; ok {
+ return mutex
+ }
+
+ c.pageMutexMap[page] = &sync.RWMutex{}
+ return c.pageMutexMap[page]
+}
+
+func (c *ClientCache) GetLikeStatus(id string, page string) bool {
+ page = strings.Clone(page)
+
+ mutex := c.getPageMutex(page)
+ mutex.RLock()
+ defer mutex.RUnlock()
+
+ if _, ok := c.likePageMap[page]; !ok {
+ return false
+ }
+ _, ok := c.likePageMap[page][c.GetHash(id)]
+ return ok
+}
+
+func (c *ClientCache) GetLikeCount(page string) int {
+ page = strings.Clone(page)
+
+ mutex := c.getPageMutex(page)
+ mutex.RLock()
+ defer mutex.RUnlock()
+
+ if userSet, ok := c.likePageMap[page]; ok {
+ return len(userSet)
+ }
+ return 0
+}
+
+func (c *ClientCache) LikeOn(id string, page string) (alreadyLiked bool) {
+ page = strings.Clone(page)
+ hash := c.GetHash(id)
+
+ mutex := c.getPageMutex(page)
+ mutex.Lock()
+ defer mutex.Unlock()
+
+ if userSet, ok := c.likePageMap[page]; ok {
+ _, alreadyLiked = userSet[hash]
+ c.likePageMap[page][hash] = struct{}{}
+ return
+ }
+
+ c.likePageMap[page] = make(map[string]struct{})
+ c.likePageMap[page][hash] = struct{}{}
+ return
+}
+
+func (c *ClientCache) LikeOff(id string, page string) (alreadyUnliked bool) {
+ page = strings.Clone(page)
+ hash := c.GetHash(id)
+
+ mutex := c.getPageMutex(page)
+ mutex.Lock()
+ defer mutex.Unlock()
+
+ if _, ok := c.likePageMap[page]; !ok {
+ return true
+ }
+ if _, ok := c.likePageMap[page][hash]; !ok {
+ return true
+ }
+
+ delete(c.likePageMap[page], hash)
+ return false
+}
+
+func (c *ClientCache) GetViewStatus(id string, page string) bool {
+ page = strings.Clone(page)
+
+ mutex := c.getPageMutex(page)
+ mutex.RLock()
+ defer mutex.RUnlock()
+
+ if _, ok := c.viewPageMap[page]; !ok {
+ return false
+ }
+ _, ok := c.viewPageMap[page][c.GetHash(id)]
+ return ok
+}
+
+func (c *ClientCache) GetViewCount(page string) int {
+ page = strings.Clone(page)
+
+ mutex := c.getPageMutex(page)
+ mutex.RLock()
+ defer mutex.RUnlock()
+
+ if userSet, ok := c.viewPageMap[page]; ok {
+ return len(userSet)
+ }
+ return 0
+}
+
+func (c *ClientCache) View(id string, page string) {
+ page = strings.Clone(page)
+ hash := c.GetHash(id)
+
+ mutex := c.getPageMutex(page)
+ mutex.Lock()
+ defer mutex.Unlock()
+
+ if _, ok := c.viewPageMap[page]; !ok {
+ c.viewPageMap[page] = make(map[string]struct{})
+ }
+ c.viewPageMap[page][hash] = struct{}{}
+}
+
+func batchSave(tx *sql.Tx, table string, pageMap map[string]map[string]struct{}) (err error) {
+ if _, err = tx.Exec(fmt.Sprintf("delete from %s;", table)); err != nil {
+ return fmt.Errorf("fail to truncate table %s: %w", table, err)
+ }
+
+ userIdBytes := make(map[string][]byte)
+
+ sqlStatement := fmt.Sprintf(`
+ INSERT OR IGNORE INTO %s (page_ref, user_id)
+ VALUES %s(?, ?);
+ `, table, strings.Repeat("(?, ?), ", 99))
+ sqlStatementVars := make([]any, 0, 200)
+
+ for pageRef, userSet := range pageMap {
+ for userId := range userSet {
+ if _, ok := userIdBytes[userId]; !ok {
+ userIdBytes[userId], err = base64.RawStdEncoding.DecodeString(userId)
+ if err != nil {
+ slog.Warn("couldn't parse one of user hashes into bytes back", slog.String("hash", userId), slog.String("error", err.Error()))
+ continue
+ }
+ }
+
+ sqlStatementVars = append(sqlStatementVars, any(pageRef), any(userIdBytes[userId]))
+ if len(sqlStatementVars) < 200 {
+ continue
+ }
+
+ if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil {
+ slog.Warn("couldn't insert blog stat pairs into db", slog.String("table", table), slog.String("error", err.Error()))
+ }
+
+ sqlStatementVars = make([]any, 0, 200)
+ }
+ }
+
+ if len(sqlStatementVars) > 0 {
+ sqlStatement = fmt.Sprintf(`
+ INSERT OR IGNORE INTO %s (page_ref, user_id)
+ VALUES %s(?, ?);
+ `, table, strings.Repeat("(?, ?), ", len(sqlStatementVars)/2-1))
+
+ if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil {
+ slog.Warn("couldn't insert blog stat pairs into db", slog.String("table", table), slog.String("error", err.Error()))
+ }
+ }
+
+ return nil
+}
diff --git a/internal/router/lang-map.go b/internal/router/lang-map.go
new file mode 100644
index 0000000..619b05b
--- /dev/null
+++ b/internal/router/lang-map.go
@@ -0,0 +1,88 @@
+package router
+
+import (
+ "fmt"
+ "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/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ 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 {
+ 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:
+ pages, err := b2Client.Scan(lang + "/")
+ status := fiber.StatusOK
+ if err != nil {
+ status = fiber.StatusPartialContent
+ pages = []*b2.BlogPage{}
+ }
+
+ type MapMarker struct {
+ Title string `json:"Title"`
+ PageLink string `json:"PageLink"`
+ Lat float64 `json:"Lat"`
+ Long float64 `json:"Long"`
+ AccuracyMeters int64 `json:"AccuracyMeters"`
+ Thumbnail string `json:"Thumbnail"`
+ }
+
+ mapMarkers := make([]*MapMarker, 0, len(pages))
+ for _, page := range pages {
+ geolocationParts := strings.Split(page.Metadata.Geolocation, " ")
+ if len(geolocationParts) < 2 {
+ continue
+ }
+
+ var x, y float64
+ var areaError int64
+ if len(geolocationParts) >= 2 {
+ x, _ = strconv.ParseFloat(geolocationParts[0], 64)
+ y, _ = strconv.ParseFloat(geolocationParts[1], 64)
+ }
+ if len(geolocationParts) >= 3 {
+ areaError, _ = strconv.ParseInt(geolocationParts[2], 10, 64)
+ }
+
+ mapMarkers = append(mapMarkers, &MapMarker{
+ Title: page.Metadata.Title,
+ PageLink: fmt.Sprintf("/%s/blog/%s", lang, page.FileName),
+ Lat: x,
+ Long: y,
+ AccuracyMeters: areaError,
+ Thumbnail: page.Metadata.Thumbnail,
+ })
+ }
+
+ content, err := tm.Render("global-map", fiber.Map{
+ "Lang": lang,
+ "L": l[lang],
+ "MapLocationLat": 45.4507,
+ "MapLocationLong": 68.8319,
+ "MapMarkers": mapMarkers,
+ })
+ if err != nil {
+ slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/map"), 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-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/page-cache.go b/internal/router/page-cache.go
new file mode 100644
index 0000000..01e4792
--- /dev/null
+++ b/internal/router/page-cache.go
@@ -0,0 +1,5 @@
+package router
+
+import "github.com/dgraph-io/ristretto/v2"
+
+var PCache *ristretto.Cache[string, []byte]
diff --git a/internal/router/tm.go b/internal/router/tm.go
new file mode 100644
index 0000000..e8095ee
--- /dev/null
+++ b/internal/router/tm.go
@@ -0,0 +1,18 @@
+package router
+
+import (
+ "log/slog"
+ "os"
+
+ "github.com/SayaAndy/saya-today-web/internal/templatemanager"
+)
+
+var tm = assert(templatemanager.NewTemplateManager())
+
+func assert[T any](t T, err error) T {
+ if err != nil {
+ slog.Error("fail to initialize template manager", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+ return t
+}
diff --git a/internal/tailwind/link_renderer.go b/internal/tailwind/link_renderer.go
new file mode 100644
index 0000000..ff3d437
--- /dev/null
+++ b/internal/tailwind/link_renderer.go
@@ -0,0 +1,49 @@
+package tailwind
+
+import (
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/renderer"
+ "github.com/yuin/goldmark/renderer/html"
+ "github.com/yuin/goldmark/util"
+)
+
+type CustomLinkRenderer struct {
+ html.Config
+}
+
+func NewCustomLinkRenderer(opts ...html.Option) renderer.NodeRenderer {
+ r := &CustomLinkRenderer{
+ Config: html.NewConfig(),
+ }
+ for _, opt := range opts {
+ opt.SetHTMLOption(&r.Config)
+ }
+ return r
+}
+
+func (r *CustomLinkRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ n := node.(*ast.Link)
+ if entering {
+ _, _ = w.WriteString("<a href=\"")
+ if r.Unsafe || !html.IsDangerousURL(n.Destination) {
+ _, _ = w.Write(util.EscapeHTML(util.URLEscape(n.Destination, true)))
+ }
+ _, _ = w.WriteString(`"`)
+ if n.Title != nil {
+ _, _ = w.WriteString(` title="`)
+ _, _ = w.Write(util.EscapeHTML(n.Title))
+ _, _ = w.WriteString(`"`)
+ }
+ if n.Attributes() != nil {
+ html.RenderAttributes(w, n, nil)
+ }
+ _, _ = w.WriteString(">")
+ } else {
+ _, _ = w.WriteString("</a>")
+ }
+ return ast.WalkContinue, nil
+}
+
+func (r *CustomLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+ reg.Register(ast.KindLink, r.renderLink)
+}
diff --git a/internal/tailwind/transformer.go b/internal/tailwind/transformer.go
index 2f052b9..c53ded1 100644
--- a/internal/tailwind/transformer.go
+++ b/internal/tailwind/transformer.go
@@ -1,6 +1,9 @@
package tailwind
import (
+ "bytes"
+ "fmt"
+
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
@@ -17,44 +20,48 @@ 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-[2.5vmax] font-bold text-main-dark mb-[1.2vmin] tracking-[0.04vmin]",
- 2: "font-spectral text-[2vmax] font-bold text-main-dark mb-[0.8vmin] tracking-[0.04vmin]",
- 3: "font-spectral text-[1.5vmax] font-medium text-main-dark mb-[0.6vmin] tracking-[0.04vmin]",
- 4: "font-spectral text-[1.2vmax] font-medium text-main-medium mb-[0.4vmin] tracking-[0.04vmin]",
- 5: "font-spectral text-[1vmax] font-medium text-main-medium mb-[0.4vmin] italic tracking-[0.04vmin]",
- 6: "font-spectral text-[1vmax] font-medium text-secondary mb-[0.4vmin]",
+ 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-[1vmax]/[2] font-spectral tracking-[0.04vmin] -indent-[2vmax] ml-[2vmax] mb-[1.6vmin]"))
+ node.SetAttribute([]byte("class"), []byte("text-base/[2] font-andika tracking-[.0125rem] -indent-8 ml-4 mb-8"))
case *ast.List:
if node.IsOrdered() {
- node.SetAttribute([]byte("class"), []byte("list-decimal list-inside space-y-[0.8vmin] mb-[1.6vmin] pl-[2vmax]"))
+ node.SetAttribute([]byte("class"), []byte("list-decimal list-inside space-y-2 mb-4 pl-8"))
} else {
- node.SetAttribute([]byte("class"), []byte("list-disc list-inside space-y-[0.8vmin] mb-[1.6vmin] pl-[2vmax]"))
+ node.SetAttribute([]byte("class"), []byte("list-disc list-inside space-y-2 mb-4 pl-8"))
}
case *ast.ListItem:
- node.SetAttribute([]byte("class"), []byte("text-[1vmax]/[2] font-spectral tracking-[0.04vmin]"))
+ node.SetAttribute([]byte("class"), []byte("text-base/[2] font-andika tracking-[.0125rem]"))
case *ast.Blockquote:
- node.SetAttribute([]byte("class"), []byte("border-l-[0.4vmin] border-main-medium bg-background-dark p-[0.8vmin] mb-[0.8vmin] italic"))
+ node.SetAttribute([]byte("class"), []byte("border-l-2 border-main-medium bg-background-dark p-1 mb-2 italic"))
case *ast.CodeSpan:
node.SetAttribute([]byte("class"), []byte("bg-background-dark"))
case *ast.CodeBlock, *ast.FencedCodeBlock:
- node.SetAttribute([]byte("class"), []byte("bg-background-dark p-[0.8vmin] rounded-lg overflow-x-auto mb-[1.6vmin]"))
+ node.SetAttribute([]byte("class"), []byte("bg-background-dark p-1 rounded-lg overflow-x-auto mb-4"))
case *ast.Link:
- node.SetAttribute([]byte("class"), []byte("text-secondary hover:text-main-dark underline"))
+ if bytes.HasPrefix(node.Destination, []byte{'.', '/'}) {
+ 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-hard underline"))
case *ast.Image:
- node.SetAttribute([]byte("class"), []byte("max-w-full h-auto rounded-lg shadow-lg mb-[1.6vmin]"))
+ node.SetAttribute([]byte("class"), []byte("max-w-full h-auto rounded-lg shadow-lg mb-4"))
case *ast.Emphasis:
switch node.Level {
diff --git a/internal/templatemanager/templatemanager.go b/internal/templatemanager/templatemanager.go
index 5effcb3..bafdee5 100644
--- a/internal/templatemanager/templatemanager.go
+++ b/internal/templatemanager/templatemanager.go
@@ -22,13 +22,23 @@ type TemplateManagerTemplates struct {
Files []string
}
-func NewTemplateManager(templates []TemplateManagerTemplates) (*TemplateManager, error) {
+var templateFuncMap = template.FuncMap{
+ "contains": strings.Contains,
+ "iterate": func(count uint) []uint {
+ items := make([]uint, count)
+ for i := range count {
+ items[i] = i
+ }
+ return items
+ },
+ "replace": strings.ReplaceAll,
+}
+
+func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager, error) {
templateMap := make(map[string]templateManagerRender)
for _, tmplStruct := range templates {
- tmpl := template.New("").Funcs(template.FuncMap{
- "contains": strings.Contains,
- })
+ tmpl := template.New(tmplStruct.Name).Funcs(templateFuncMap)
tmpl, err := tmpl.ParseFiles(tmplStruct.Files...)
if err != nil {
return nil, err
@@ -37,7 +47,6 @@ func NewTemplateManager(templates []TemplateManagerTemplates) (*TemplateManager,
Main: filepath.Base(tmplStruct.Files[0]),
Tmpl: tmpl,
}
-
}
return &TemplateManager{
@@ -45,13 +54,47 @@ func NewTemplateManager(templates []TemplateManagerTemplates) (*TemplateManager,
}, nil
}
-func (tm *TemplateManager) Render(name string, data interface{}) ([]byte, error) {
+func (tm *TemplateManager) Render(name string, data any, files ...string) ([]byte, error) {
tmpl, exists := tm.templates[name]
if !exists {
return nil, fmt.Errorf("template %s is not found", name)
}
+ var err error
+ var tempTmpl *template.Template
+ if len(files) == 0 {
+ tempTmpl = tmpl.Tmpl
+ } else {
+ tempTmpl, err = tmpl.Tmpl.Clone()
+ if err != nil {
+ return nil, fmt.Errorf("couldn't clone existing template for rendering: %w", err)
+ }
+ tempTmpl, err = tempTmpl.ParseFiles(files...)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't include additional files in template rendering: %w", err)
+ }
+ }
+
var buf bytes.Buffer
- err := tmpl.Tmpl.ExecuteTemplate(&buf, tmpl.Main, data)
+ err = tempTmpl.ExecuteTemplate(&buf, tmpl.Main, data)
return buf.Bytes(), err
}
+
+func (tm *TemplateManager) Add(name string, files ...string) error {
+ if len(files) == 0 {
+ return fmt.Errorf("you can't add template without any files")
+ }
+
+ tmpl := template.New(name).Funcs(templateFuncMap)
+
+ tmpl, err := tmpl.ParseFiles(files...)
+ if err != nil {
+ return fmt.Errorf("failed to add template into manager: %w", err)
+ }
+
+ tm.templates[name] = templateManagerRender{
+ Main: filepath.Base(files[0]),
+ Tmpl: tmpl,
+ }
+ return nil
+}