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.go131
-rw-r--r--internal/blogtrigger/blogtrigger.go72
-rw-r--r--internal/factgiver/factgiver.go67
-rw-r--r--internal/frontmatter/parser.go40
-rw-r--r--internal/glightbox/block.go32
-rw-r--r--internal/glightbox/extension.go28
-rw-r--r--internal/glightbox/html_renderer.go186
-rw-r--r--internal/glightbox/parser.go100
-rw-r--r--internal/mailer/mailer.go544
-rw-r--r--internal/router/basic-handler.go62
-rw-r--r--internal/router/client-cache.go318
-rw-r--r--internal/router/handlers/api-v1-blog-search.go126
-rw-r--r--internal/router/handlers/api-v1-email-is-in-verification.go59
-rw-r--r--internal/router/handlers/api-v1-email-send-verification-code.go97
-rw-r--r--internal/router/handlers/api-v1-email-verify.go62
-rw-r--r--internal/router/handlers/api-v1-like-get.go63
-rw-r--r--internal/router/handlers/api-v1-like-put.go74
-rw-r--r--internal/router/handlers/api-v1-subs-put.go65
-rw-r--r--internal/router/handlers/api-v1-tz-get.go65
-rw-r--r--internal/router/handlers/lang-blog-title.go113
-rw-r--r--internal/router/handlers/lang-blog.go112
-rw-r--r--internal/router/handlers/lang-map.go95
-rw-r--r--internal/router/handlers/lang-user-unsubscribe.go68
-rw-r--r--internal/router/handlers/lang-user.go78
-rw-r--r--internal/router/handlers/lang.go56
-rw-r--r--internal/router/handlers/root.go47
-rw-r--r--internal/router/path-matcher.go48
-rw-r--r--internal/router/router.go559
-rw-r--r--internal/tailwind/extension.go21
-rw-r--r--internal/tailwind/link_renderer.go49
-rw-r--r--internal/tailwind/transformer.go79
-rw-r--r--internal/templatemanager/templatemanager.go120
32 files changed, 0 insertions, 3636 deletions
diff --git a/internal/b2/client.go b/internal/b2/client.go
deleted file mode 100644
index d712736..0000000
--- a/internal/b2/client.go
+++ /dev/null
@@ -1,131 +0,0 @@
-package b2
-
-import (
- "context"
- "fmt"
- "slices"
- "strings"
- "time"
-
- "github.com/Backblaze/blazer/b2"
- "github.com/SayaAndy/saya-today-web/config"
- "github.com/SayaAndy/saya-today-web/internal/frontmatter"
-)
-
-type B2Client struct {
- prefix string
- bucket *b2.Bucket
- b2cl *b2.Client
-}
-
-func NewB2Client(cfg *config.B2Config) (*B2Client, error) {
- b2cl, err := b2.NewClient(context.Background(), cfg.KeyID, cfg.ApplicationKey)
- if err != nil {
- return nil, err
- }
-
- bucket, err := b2cl.Bucket(context.Background(), cfg.BucketName)
- if err != nil {
- return nil, err
- }
-
- return &B2Client{b2cl: b2cl, bucket: bucket, prefix: cfg.Prefix}, nil
-}
-
-type BlogPage struct {
- Link string
- FileName string
- Lang string
- Metadata *frontmatter.Metadata
-}
-
-func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) {
- filePaths := []*BlogPage{}
-
- iter := c.bucket.List(context.Background(), b2.ListPrefix(c.prefix+prefix))
-
- for iter.Next() {
- obj := iter.Object()
- if obj == nil {
- return nil, fmt.Errorf("failed to reference object in B2 bucket")
- }
-
- attrs, err := obj.Attrs(context.Background())
- if err != nil {
- return nil, fmt.Errorf("get attributes for object: %w", err)
- }
-
- if attrs.Status != b2.Uploaded {
- continue
- }
-
- if !strings.Contains(attrs.ContentType, "text/markdown") {
- continue
- }
-
- if _, ok := attrs.Info["title"]; !ok {
- continue
- }
-
- publishedTime, err := time.Parse(time.RFC3339, attrs.Info["published-time"])
- if err != nil {
- return nil, fmt.Errorf("failed to parse published time metadata field: %w", err)
- }
-
- linkParts := strings.Split(obj.Name(), "/")
- 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,
- Metadata: &frontmatter.Metadata{
- Title: attrs.Info["title"],
- ShortDescription: attrs.Info["short-description"],
- ActionDate: attrs.Info["action-date"],
- PublishedTime: publishedTime,
- Thumbnail: attrs.Info["thumbnail"],
- Tags: tags,
- Geolocation: attrs.Info["geolocation"],
- },
- })
- }
-
- if err := iter.Err(); err != nil {
- return nil, fmt.Errorf("iterate over B2 objects: %w", err)
- }
-
- return filePaths, nil
-}
-
-func (c *B2Client) ReadAll(path string) ([]byte, error) {
- obj := c.bucket.Object(c.prefix + path)
- if obj == nil {
- return nil, fmt.Errorf("failed to reference object in B2 bucket")
- }
- attrs, err := obj.Attrs(context.Background())
- if err != nil {
- return nil, fmt.Errorf("error getting attributes of an object: %w", err)
- }
-
- content := make([]byte, attrs.Size)
- reader := obj.NewReader(context.Background())
-
- if _, err = reader.Read(content); err != nil {
- return nil, fmt.Errorf("failed to read file content: %w", err)
- }
-
- return content, nil
-}
-
-func (c *B2Client) ReadFrontmatter(path string) (metadata *frontmatter.Metadata, markdown []byte, err error) {
- contentBytes, err := c.ReadAll(path)
- if err != nil {
- return nil, nil, fmt.Errorf("failed to read file for frontmatter parsing: %w", err)
- }
-
- return frontmatter.ParseFrontmatter(contentBytes)
-}
diff --git a/internal/blogtrigger/blogtrigger.go b/internal/blogtrigger/blogtrigger.go
deleted file mode 100644
index c5b3097..0000000
--- a/internal/blogtrigger/blogtrigger.go
+++ /dev/null
@@ -1,72 +0,0 @@
-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
deleted file mode 100644
index 5b418ee..0000000
--- a/internal/factgiver/factgiver.go
+++ /dev/null
@@ -1,67 +0,0 @@
-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
deleted file mode 100644
index 83312dc..0000000
--- a/internal/frontmatter/parser.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package frontmatter
-
-import (
- "fmt"
- "regexp"
- "time"
-
- "gopkg.in/yaml.v3"
-)
-
-type Metadata struct {
- Title string `yaml:"title"`
- ShortDescription string `yaml:"shortDescription"`
- ActionDate string `yaml:"actionDate"`
- PublishedTime time.Time `yaml:"publishedTime"`
- Thumbnail string `yaml:"thumbnail"`
- Tags []string `yaml:"tags"`
- Geolocation string `yaml:"geolocation"`
- Medley string `yaml:"medley"`
- MedleyPart int `yaml:"medleyPart"`
-}
-
-func ParseFrontmatter(content []byte) (metadata *Metadata, markdown []byte, err error) {
- frontmatterRegex := regexp.MustCompile(`^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n([\s\S]*)$`)
- matches := frontmatterRegex.FindSubmatch(content)
-
- if len(matches) != 3 {
- return nil, content, nil
- }
-
- yamlContent := matches[1]
- markdownContent := matches[2]
-
- metadata = &Metadata{}
- if err := yaml.Unmarshal([]byte(yamlContent), &metadata); err != nil {
- return nil, nil, fmt.Errorf("failed to parse YAML frontmatter: %w", err)
- }
-
- return metadata, markdownContent, nil
-}
diff --git a/internal/glightbox/block.go b/internal/glightbox/block.go
deleted file mode 100644
index e53e67b..0000000
--- a/internal/glightbox/block.go
+++ /dev/null
@@ -1,32 +0,0 @@
-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/glightbox/extension.go b/internal/glightbox/extension.go
deleted file mode 100644
index 7fd17b1..0000000
--- a/internal/glightbox/extension.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package glightbox
-
-import (
- "github.com/yuin/goldmark"
- "github.com/yuin/goldmark/parser"
- "github.com/yuin/goldmark/renderer"
- "github.com/yuin/goldmark/util"
-)
-
-// Extension that combines parser and renderer
-type GLightboxExtension struct{}
-
-func NewGLightboxExtension() goldmark.Extender {
- return &GLightboxExtension{}
-}
-
-func (e *GLightboxExtension) Extend(m goldmark.Markdown) {
- m.Parser().AddOptions(
- parser.WithBlockParsers(
- util.Prioritized(NewGLightboxParser(), 500),
- ),
- )
- m.Renderer().AddOptions(
- renderer.WithNodeRenderers(
- util.Prioritized(NewGLightboxHTMLRenderer(), 500),
- ),
- )
-}
diff --git a/internal/glightbox/html_renderer.go b/internal/glightbox/html_renderer.go
deleted file mode 100644
index 7022793..0000000
--- a/internal/glightbox/html_renderer.go
+++ /dev/null
@@ -1,186 +0,0 @@
-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/glightbox/parser.go b/internal/glightbox/parser.go
deleted file mode 100644
index 6780090..0000000
--- a/internal/glightbox/parser.go
+++ /dev/null
@@ -1,100 +0,0 @@
-package glightbox
-
-import (
- "bytes"
- "log/slog"
- "regexp"
- "strings"
- "time"
-
- "github.com/yuin/goldmark/ast"
- "github.com/yuin/goldmark/parser"
- "github.com/yuin/goldmark/text"
-)
-
-type GLightboxParser struct{}
-
-func NewGLightboxParser() parser.BlockParser {
- return &GLightboxParser{}
-}
-
-func (p *GLightboxParser) Trigger() []byte {
- return []byte{'{'}
-}
-
-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:")) {
- return nil, parser.NoChildren
- }
-
- r := regexp.MustCompile(`^\{Gallery:([A-Za-z0-9\+\-/]+)\}$`)
-
- trimmed := bytes.TrimSpace(line)
- parts := r.FindSubmatch(trimmed)
- if len(parts) < 2 {
- slog.Warn("invalid gallery header format", slog.String("line", string(trimmed)), slog.Int("submatch_count", len(parts)))
- return nil, parser.NoChildren
- }
-
- loc, err := time.LoadLocation(string(parts[1]))
- if err != nil {
- slog.Warn("invalid location specified for a gallery", slog.String("error", err.Error()), slog.String("line", string(trimmed)), slog.String("location", string(parts[1])))
- return nil, parser.NoChildren
- }
-
- return &GLightboxBlock{Location: loc}, parser.NoChildren
-}
-
-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
- }
-
- trimmed := bytes.TrimSpace(line)
- if bytes.Equal(trimmed, []byte("{Gallery}")) || bytes.Equal(trimmed, []byte("{/Gallery}")) {
- reader.AdvanceLine()
- return parser.Close
- }
-
- gallery := node.(*GLightboxBlock)
-
- parts := bytes.SplitN(trimmed, []byte{'|'}, 3)
- url := bytes.TrimSpace(parts[0])
-
- caption := make([]byte, 0)
- tagsRaw := ""
- if len(parts) == 2 {
- caption = bytes.TrimSpace(parts[1])
- }
- if len(parts) >= 3 {
- tagsRaw = string(parts[1])
- caption = bytes.TrimSpace(parts[2])
- }
-
- tags := strings.Split(tagsRaw, ",")
- for i := range tags {
- tags[i] = strings.TrimSpace(tags[i])
- }
-
- gallery.Images = append(gallery.Images, GLightboxImage{
- URL: string(url),
- Tags: tags,
- Caption: caption,
- })
-
- return parser.Continue | parser.NoChildren
-}
-
-func (p *GLightboxParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {
-}
-
-func (p *GLightboxParser) CanInterruptParagraph() bool {
- return true
-}
-
-func (p *GLightboxParser) CanAcceptIndentedLine() bool {
- return false
-}
diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go
deleted file mode 100644
index d6dbd17..0000000
--- a/internal/mailer/mailer.go
+++ /dev/null
@@ -1,544 +0,0 @@
-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/basic-handler.go b/internal/router/basic-handler.go
deleted file mode 100644
index a859d10..0000000
--- a/internal/router/basic-handler.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package router
-
-import (
- "time"
-
- "github.com/gofiber/fiber/v2"
-)
-
-type BasicHandler struct{}
-
-var _ Route = &BasicHandler{}
-
-func (r *BasicHandler) Filter() (method string, path string) {
- panic("handler did not implement Filter method")
-}
-
-func (r *BasicHandler) IsTemplated() bool {
- return false
-}
-
-func (r *BasicHandler) ToCache() CacheSetting {
- return Disabled
-}
-
-func (r *BasicHandler) CacheDuration() time.Duration {
- return 5 * time.Minute
-}
-
-func (r *BasicHandler) ToValidateLang() LangSetting {
- return NotRequired
-}
-
-func (r *BasicHandler) TemplatesToInject() []string {
- return []string{}
-}
-
-func (r *BasicHandler) Render(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- if !r.IsTemplated() {
- panic("handler did not implement Render method (while being non-templated)")
- }
- return fiber.StatusNoContent, nil
-}
-
-func (r *BasicHandler) RenderHeader(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- return fiber.StatusNoContent, nil
-}
-
-func (r *BasicHandler) RenderBody(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- return fiber.StatusNoContent, nil
-}
-
-func (r *BasicHandler) RenderFooter(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- return fiber.StatusNoContent, nil
-}
-
-func (r *BasicHandler) RenderTopEmbeds(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- return fiber.StatusNoContent, nil
-}
-
-func (r *BasicHandler) RenderBottomEmbeds(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- return fiber.StatusNoContent, nil
-}
diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go
deleted file mode 100644
index 7c28b9e..0000000
--- a/internal/router/client-cache.go
+++ /dev/null
@@ -1,318 +0,0 @@
-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
-}
-
-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/handlers/api-v1-blog-search.go b/internal/router/handlers/api-v1-blog-search.go
deleted file mode 100644
index a0316e2..0000000
--- a/internal/router/handlers/api-v1-blog-search.go
+++ /dev/null
@@ -1,126 +0,0 @@
-package handlers
-
-import (
- "encoding/json"
- "fmt"
- "log/slog"
- "net/url"
- "regexp"
- "slices"
- "strings"
- "time"
-
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type BlogSearchHandler struct {
- router.BasicHandler
- getTagsQuery *regexp.Regexp
-}
-
-func init() {
- router.Routes = append(router.Routes, &BlogSearchHandler{getTagsQuery: regexp.MustCompile(`tags\[\]=([\w]+)`)})
-}
-
-func (r *BlogSearchHandler) Filter() (method string, path string) {
- return "GET", "/api/v1/blog-search"
-}
-
-func (r *BlogSearchHandler) IsTemplated() bool {
- return false
-}
-
-func (r *BlogSearchHandler) TemplatesToInject() []string {
- return []string{"views/partials/catalogue-blog-cards.html", "views/partials/catalogue-blog-card-tags.html"}
-}
-
-func (r *BlogSearchHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *BlogSearchHandler) ToValidateLang() router.LangSetting {
- return router.InForm
-}
-
-func (r *BlogSearchHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- sort := c.Query("sort")
- 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))
- }
-
- cacheKey := "blog-search." + lang + ".pages-list"
-
- var pages []*b2.BlogPage
- if pagesBytes, ok := supplements.PageCache.Get(cacheKey); pagesBytes != nil || ok {
- json.Unmarshal(pagesBytes, &pages)
- } else {
- pages, err = supplements.B2Client.Scan(lang + "/")
- if err != nil {
- return fiber.StatusInternalServerError, fmt.Errorf("failed to scan pages for '%s' lang: %w", lang, err)
- }
- pagesBytes, _ := json.Marshal(pages)
- supplements.PageCache.SetWithTTL(cacheKey, pagesBytes, int64(len(pagesBytes)), r.CacheDuration())
- }
-
- encodedQuery := c.Request().URI().QueryString()
- decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
- matches := r.getTagsQuery.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": supplements.ClientCache.GetLikeCount(page.FileName),
- "ViewCount": supplements.ClientCache.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
- })
-
- templateMap["BlogPages"] = pageMeta
-
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/api-v1-email-is-in-verification.go b/internal/router/handlers/api-v1-email-is-in-verification.go
deleted file mode 100644
index 4f05f48..0000000
--- a/internal/router/handlers/api-v1-email-is-in-verification.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "html/template"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type OngoingVerificationHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &OngoingVerificationHandler{})
-}
-
-func (r *OngoingVerificationHandler) Filter() (method string, path string) {
- return "GET", "/api/v1/email/is-in-verification"
-}
-
-func (r *OngoingVerificationHandler) IsTemplated() bool {
- return false
-}
-
-func (r *OngoingVerificationHandler) TemplatesToInject() []string {
- return []string{"views/partials/personal-page-status.html"}
-}
-
-func (r *OngoingVerificationHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *OngoingVerificationHandler) ToValidateLang() router.LangSetting {
- return router.InReferer
-}
-
-func (r *OngoingVerificationHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- isAllowed, whenAllowed, codeExpiry := supplements.Mailer.IsAllowedToRetryVerification(c.IP())
-
- sterileDataset := make(map[string]any)
- sterileDataset["code-expiry-time"] = template.HTMLAttr(fmt.Sprintf("data-code-expiry-time=\"%d\"", codeExpiry.UnixMilli()))
-
- templateMap["StatusId"] = "email-message"
- templateMap["DataAttributes"] = sterileDataset
-
- if !isAllowed {
- sterileDataset["striked-end-time"] = template.HTMLAttr(fmt.Sprintf("data-striked-end-time=\"%d\"", whenAllowed.UnixMilli()))
- templateMap["Status"] = "Neutral"
- templateMap["Message"] = strings.ReplaceAll(supplements.Localization[lang].UserProfile.DelayTilVerification, "{}", whenAllowed.Format("2006-01-02 15:04:05 MST"))
- } else {
- templateMap["Status"] = "OK"
- templateMap["Message"] = ""
- }
-
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/api-v1-email-send-verification-code.go b/internal/router/handlers/api-v1-email-send-verification-code.go
deleted file mode 100644
index 20c5fb6..0000000
--- a/internal/router/handlers/api-v1-email-send-verification-code.go
+++ /dev/null
@@ -1,97 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "html/template"
- "log/slog"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type SendVerificationCodeHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &SendVerificationCodeHandler{})
-}
-
-func (r *SendVerificationCodeHandler) Filter() (method string, path string) {
- return "POST", "/api/v1/email/send-verification-code"
-}
-
-func (r *SendVerificationCodeHandler) IsTemplated() bool {
- return false
-}
-
-func (r *SendVerificationCodeHandler) TemplatesToInject() []string {
- return []string{"views/partials/personal-page-status.html"}
-}
-
-func (r *SendVerificationCodeHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *SendVerificationCodeHandler) ToValidateLang() router.LangSetting {
- return router.InReferer
-}
-
-func (r *SendVerificationCodeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- id := c.IP()
- templateMap["StatusId"] = "email-message"
-
- email := c.FormValue("email")
- if email == "" {
- templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.EmailEmpty
- return fiber.StatusUnprocessableEntity, nil
- }
-
- isTaken, err := supplements.Mailer.MailIsTaken(email)
- if err != nil {
- templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationCodeSendingError
- slog.Error("failed to check if address is already taken", slog.String("error", err.Error()))
- return fiber.StatusUnprocessableEntity, nil
- }
-
- if isTaken {
- templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.EmailTaken
- return fiber.StatusUnprocessableEntity, nil
- }
-
- if isAllowed, whenAllowed, _ := supplements.Mailer.IsAllowedToRetryVerification(id); !isAllowed {
- templateMap["Status"] = "Failed"
- templateMap["Message"] = strings.ReplaceAll(supplements.Localization[lang].UserProfile.DelayTilVerification, "{}", whenAllowed.Format("2006-01-02 15:04:05 MST"))
- templateMap["DataAttributes"] = map[string]any{
- "striked-end-time": template.HTMLAttr(fmt.Sprintf("data-striked-end-time=\"%d\"", whenAllowed.UnixMilli())),
- }
- return fiber.StatusUnprocessableEntity, nil
- }
-
- if previousEmail, _, _ := supplements.Mailer.GetInfo(supplements.Mailer.GetHash(id)); previousEmail == email {
- templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.EmailAlreadyValidated
- return fiber.StatusUnprocessableEntity, nil
- }
-
- if err = supplements.Mailer.SendVerificationCode(id, email, lang); err != nil {
- templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationCodeSendingError
- slog.Error("failed to send a verification code", slog.String("error", err.Error()))
- return fiber.StatusUnprocessableEntity, nil
- }
-
- _, endTime, codeExpiry := supplements.Mailer.IsAllowedToRetryVerification(id)
- templateMap["Status"] = "OK"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationCodeSent
- templateMap["DataAttributes"] = map[string]any{
- "striked-end-time": template.HTMLAttr(fmt.Sprintf("data-striked-end-time=\"%d\"", endTime.UnixMilli())),
- "code-expiry-time": template.HTMLAttr(fmt.Sprintf("data-code-expiry-time=\"%d\"", codeExpiry.UnixMilli())),
- }
-
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/api-v1-email-verify.go b/internal/router/handlers/api-v1-email-verify.go
deleted file mode 100644
index c9ec969..0000000
--- a/internal/router/handlers/api-v1-email-verify.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package handlers
-
-import (
- "html/template"
- "log/slog"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type VerifyCodeHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &VerifyCodeHandler{})
-}
-
-func (r *VerifyCodeHandler) Filter() (method string, path string) {
- return "POST", "/api/v1/email/verify"
-}
-
-func (r *VerifyCodeHandler) IsTemplated() bool {
- return false
-}
-
-func (r *VerifyCodeHandler) TemplatesToInject() []string {
- return []string{"views/partials/personal-page-status.html"}
-}
-
-func (r *VerifyCodeHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *VerifyCodeHandler) ToValidateLang() router.LangSetting {
- return router.InReferer
-}
-
-func (r *VerifyCodeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- verificationCode := c.FormValue("email_code")
- templateMap["StatusId"] = "verification-message"
-
- if verificationCode == "" {
- templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationEmpty
- return fiber.StatusUnprocessableEntity, nil
- }
-
- if err = supplements.Mailer.Verify(verificationCode, lang); err != nil {
- slog.Warn("verification code is invalid", slog.String("verification_code", verificationCode), slog.String("error", err.Error()))
- templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationFailed
- return fiber.StatusUnprocessableEntity, nil
- }
-
- templateMap["Status"] = "OK"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationSuccess
- templateMap["DataAttributes"] = map[string]any{
- "hide-verification-panel": template.HTMLAttr("data-code-expiry-time=\"true\""),
- }
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/api-v1-like-get.go b/internal/router/handlers/api-v1-like-get.go
deleted file mode 100644
index e9c176e..0000000
--- a/internal/router/handlers/api-v1-like-get.go
+++ /dev/null
@@ -1,63 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "log/slog"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type GetLikeHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &GetLikeHandler{})
-}
-
-func (r *GetLikeHandler) Filter() (method string, path string) {
- return "GET", "/api/v1/like"
-}
-
-func (r *GetLikeHandler) IsTemplated() bool {
- return false
-}
-
-func (r *GetLikeHandler) TemplatesToInject() []string {
- return []string{"views/partials/blog-page-like-button.html"}
-}
-
-func (r *GetLikeHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *GetLikeHandler) ToValidateLang() router.LangSetting {
- return router.InReferer
-}
-
-func (r *GetLikeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- path, pathParts, _, err := router.GetPathFromReferer(c)
- if err != nil {
- return fiber.StatusBadRequest, fmt.Errorf("error getting path from referer: %w", err)
- }
-
- if len(pathParts) != 3 && pathParts[1] != "blog" {
- return fiber.StatusBadRequest, fmt.Errorf("invalid path format: expected '/:lang/blog/:page', got '%s'", path)
- }
-
- page := pathParts[2]
- pageLink := lang + "/" + page + ".md"
- if pages, _ := supplements.B2Client.Scan(pageLink); len(pages) == 0 {
- return fiber.StatusNotFound, fmt.Errorf("server did not find '%s' article", pageLink)
- }
-
- ip := c.IP()
- likeStatus := supplements.ClientCache.GetLikeStatus(ip, page)
-
- slog.Debug("someone requested the like status!", slog.String("ip", ip), slog.String("page", page), slog.Bool("like_status", likeStatus))
- templateMap["Liked"] = likeStatus
- templateMap["LikedCount"] = supplements.ClientCache.GetLikeCount(page)
-
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/api-v1-like-put.go b/internal/router/handlers/api-v1-like-put.go
deleted file mode 100644
index ea4ef4b..0000000
--- a/internal/router/handlers/api-v1-like-put.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "log/slog"
- "strconv"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type PutLikeHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &PutLikeHandler{})
-}
-
-func (r *PutLikeHandler) Filter() (method string, path string) {
- return "PUT", "/api/v1/like"
-}
-
-func (r *PutLikeHandler) IsTemplated() bool {
- return false
-}
-
-func (r *PutLikeHandler) TemplatesToInject() []string {
- return []string{"views/partials/blog-page-like-button.html"}
-}
-
-func (r *PutLikeHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *PutLikeHandler) ToValidateLang() router.LangSetting {
- return router.InReferer
-}
-
-func (r *PutLikeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- path, pathParts, _, err := router.GetPathFromReferer(c)
- if err != nil {
- return fiber.StatusBadRequest, fmt.Errorf("error getting path from referer: %w", err)
- }
-
- if len(pathParts) != 3 && pathParts[1] != "blog" {
- return fiber.StatusBadRequest, fmt.Errorf("invalid path format: expected '/:lang/blog/:page', got '%s'", path)
- }
-
- page := pathParts[2]
- pageLink := lang + "/" + page + ".md"
- if pages, _ := supplements.B2Client.Scan(pageLink); len(pages) == 0 {
- return fiber.StatusNotFound, fmt.Errorf("server did not find '%s' article", pageLink)
- }
-
- newLikeStatus, err := strconv.ParseBool(c.FormValue("like", "true"))
- if err != nil {
- return fiber.StatusBadRequest, fmt.Errorf("invalid like value '%s'", c.FormValue("like"))
- }
-
- ip := c.IP()
- if newLikeStatus {
- supplements.ClientCache.LikeOn(ip, page)
- } else {
- supplements.ClientCache.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)))
- templateMap["Liked"] = newLikeStatus
- templateMap["LikedCount"] = supplements.ClientCache.GetLikeCount(page)
- templateMap["StatusId"] = "email-message"
-
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/api-v1-subs-put.go b/internal/router/handlers/api-v1-subs-put.go
deleted file mode 100644
index 144abe5..0000000
--- a/internal/router/handlers/api-v1-subs-put.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package handlers
-
-import (
- "github.com/SayaAndy/saya-today-web/internal/mailer"
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type PutSubsHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &PutSubsHandler{})
-}
-
-func (r *PutSubsHandler) Filter() (method string, path string) {
- return "PUT", "/api/v1/subs"
-}
-
-func (r *PutSubsHandler) IsTemplated() bool {
- return false
-}
-
-func (r *PutSubsHandler) TemplatesToInject() []string {
- return []string{"views/partials/personal-page-status.html"}
-}
-
-func (r *PutSubsHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *PutSubsHandler) ToValidateLang() router.LangSetting {
- return router.InReferer
-}
-
-func (r *PutSubsHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["StatusId"] = "subs-message"
-
- 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:
- templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.SubscribeInvalidType
- return fiber.StatusUnprocessableEntity, nil
- }
-
- specificTags := c.FormValue("tags_picked")
- if err = supplements.Mailer.Subscribe(supplements.Mailer.GetHash(c.IP()), subscriptionTypeEnum, specificTags); err != nil {
- templateMap["Status"] = "Failed"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.FailedToSubscribe
- return fiber.StatusUnprocessableEntity, nil
- }
-
- templateMap["Status"] = "OK"
- templateMap["Message"] = supplements.Localization[lang].UserProfile.SubscribedSuccessfully
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/api-v1-tz-get.go b/internal/router/handlers/api-v1-tz-get.go
deleted file mode 100644
index 8b08113..0000000
--- a/internal/router/handlers/api-v1-tz-get.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "log/slog"
- "time"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type GetTimezoneHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &GetTimezoneHandler{})
-}
-
-func (r *GetTimezoneHandler) Filter() (method string, path string) {
- return "GET", "/api/v1/tz"
-}
-
-func (r *GetTimezoneHandler) IsTemplated() bool {
- return false
-}
-
-func (r *GetTimezoneHandler) TemplatesToInject() []string {
- return []string{}
-}
-
-func (r *GetTimezoneHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *GetTimezoneHandler) ToValidateLang() router.LangSetting {
- return router.NotRequired
-}
-
-func (r *GetTimezoneHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err 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
- }
- }
-
- if err != nil {
- return fiber.StatusBadRequest, fmt.Errorf("invalid timestamp format for '%s'", timestampString)
- }
-
- templateMap["Output"] = []byte(timestamp.In(loc).Format("2006-01-02 15:04:05 -07:00"))
-
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/lang-blog-title.go b/internal/router/handlers/lang-blog-title.go
deleted file mode 100644
index f1be2cd..0000000
--- a/internal/router/handlers/lang-blog-title.go
+++ /dev/null
@@ -1,113 +0,0 @@
-package handlers
-
-import (
- "bytes"
- "fmt"
- "html/template"
- "strings"
- "time"
-
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/internal/frontmatter"
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
- "github.com/yuin/goldmark"
-)
-
-func init() {
- router.Routes = append(router.Routes, &BlogPageHandler{})
-}
-
-type BlogPageHandler struct {
- router.BasicHandler
-}
-
-func (r *BlogPageHandler) Filter() (method string, path string) {
- return "GET", "/:lang/blog/:title"
-}
-
-func (r *BlogPageHandler) IsTemplated() bool {
- return true
-}
-
-func (r *BlogPageHandler) TemplatesToInject() []string {
- return []string{"views/pages/blog-page.html"}
-}
-
-func (r *BlogPageHandler) ToCache() router.CacheSetting {
- return router.ByUrlOnly
-}
-
-func (r *BlogPageHandler) CacheDuration() time.Duration {
- return 15 * time.Minute
-}
-
-func (r *BlogPageHandler) ToValidateLang() router.LangSetting {
- return router.InPath
-}
-
-func (r *BlogPageHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- _, pathParts, _, err := router.GetPathFromReferer(c)
- if err != nil {
- return fiber.StatusBadRequest, fmt.Errorf("failed to get path from referer: %w", err)
- }
-
- metadata, parsedMarkdown, err := readBlogPost(supplements.MarkdownRenderer, supplements.B2Client, lang+"/"+pathParts[2])
- if err != nil {
- return fiber.StatusNotFound, fmt.Errorf("failed to find '%s' post: %w", pathParts[2], err)
- }
-
- 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]
- }
-
- templateMap["MapLocationX"] = x
- templateMap["MapLocationY"] = y
- templateMap["MapLocationAreaMeters"] = areaError
- templateMap["Title"] = metadata.Title
- templateMap["ParsedMarkdown"] = template.HTML(parsedMarkdown)
- templateMap["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00")
- templateMap["ActionDate"] = metadata.ActionDate
-
- go supplements.ClientCache.View(c.IP(), pathParts[2])
-
- return fiber.StatusOK, nil
-}
-
-func (r *BlogPageHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- path, pathParts, _, err := router.GetPathFromReferer(c)
- if err != nil {
- return fiber.StatusBadRequest, fmt.Errorf("failed to get path from referer: %w", err)
- }
-
- metadata, _, err := supplements.B2Client.ReadFrontmatter(lang + "/" + pathParts[2] + ".md")
- if err != nil {
- return fiber.StatusNotFound, fmt.Errorf("could not read '%s' for metadata: %w", path, err)
- }
-
- templateMap["Title"] = metadata.Title
- templateMap["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00")
- templateMap["ActionDate"] = metadata.ActionDate
-
- return fiber.StatusOK, nil
-}
-
-func readBlogPost(md goldmark.Markdown, b2Client *b2.B2Client, sourceName string) (metadata *frontmatter.Metadata, html string, err error) {
- metadata, markdown, err := b2Client.ReadFrontmatter(sourceName + ".md")
- if err != nil {
- return nil, "", fmt.Errorf("failed to read a frontmatter file: %w", err)
- }
-
- var buf bytes.Buffer
- if err := md.Convert(markdown, &buf); err != nil {
- return nil, "", fmt.Errorf("convert source context from md to html: %w", err)
- }
-
- return metadata, buf.String(), nil
-}
diff --git a/internal/router/handlers/lang-blog.go b/internal/router/handlers/lang-blog.go
deleted file mode 100644
index e4c20c6..0000000
--- a/internal/router/handlers/lang-blog.go
+++ /dev/null
@@ -1,112 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "log/slog"
- "net/url"
- "regexp"
- "slices"
- "strings"
- "time"
-
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- router.Routes = append(router.Routes, &CatalogueHandler{getTagsQuery: regexp.MustCompile(`tags\[\]=([\w]+)`)})
-}
-
-type CatalogueHandler struct {
- router.BasicHandler
- getTagsQuery *regexp.Regexp
-}
-
-func (r *CatalogueHandler) Filter() (method string, path string) {
- return "GET", "/:lang/blog"
-}
-
-func (r *CatalogueHandler) IsTemplated() bool {
- return true
-}
-
-func (r *CatalogueHandler) TemplatesToInject() []string {
- return []string{"views/pages/blog-catalogue.html"}
-}
-
-func (r *CatalogueHandler) ToCache() router.CacheSetting {
- return router.ByUrlAndQuery
-}
-
-func (r *CatalogueHandler) CacheDuration() time.Duration {
- return 5 * time.Minute
-}
-
-func (r *CatalogueHandler) ToValidateLang() router.LangSetting {
- return router.InPath
-}
-
-func (r *CatalogueHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- querySort := c.Query("sort")
- if querySort == "" {
- querySort = "publicationDateDesc"
- }
-
- encodedQuery := c.Request().URI().QueryString()
- decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
- matches := r.getTagsQuery.FindAllStringSubmatch(decodedQuery, -1)
-
- queryTags := make([]string, 0, len(matches))
- for _, match := range matches {
- queryTags = append(queryTags, string(match[1]))
- }
-
- tagsArray, err := getTags(supplements.B2Client, lang)
- if err != nil {
- return fiber.StatusInternalServerError, fmt.Errorf("failed to get the available tags")
- }
-
- templateMap["Tags"] = tagsArray
- templateMap["QuerySort"] = querySort
- templateMap["QueryTags"] = strings.Join(queryTags, ",")
- templateMap["Title"] = supplements.Localization[lang].BlogSearch.Header
-
- return fiber.StatusOK, nil
-}
-
-func (r *CatalogueHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["Title"] = supplements.Localization[lang].BlogSearch.Header
- return fiber.StatusOK, 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/handlers/lang-map.go b/internal/router/handlers/lang-map.go
deleted file mode 100644
index dbf1437..0000000
--- a/internal/router/handlers/lang-map.go
+++ /dev/null
@@ -1,95 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "log/slog"
- "strconv"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type MapHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &MapHandler{})
-}
-
-func (r *MapHandler) Filter() (method string, path string) {
- return "GET", "/:lang/map"
-}
-
-func (r *MapHandler) IsTemplated() bool {
- return false
-}
-
-func (r *MapHandler) TemplatesToInject() []string {
- return []string{"views/pages/global-map.html"}
-}
-
-func (r *MapHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *MapHandler) ToValidateLang() router.LangSetting {
- return router.InPath
-}
-
-func (r *MapHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- pages, err := supplements.B2Client.Scan(lang + "/")
- status := fiber.StatusOK
- if err != nil {
- slog.Error("received an error while scanning b2 pages",
- slog.String("error", err.Error()),
- slog.String("lang", lang),
- )
- 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,
- })
- }
-
- templateMap["MapMarkers"] = mapMarkers
- templateMap["MapLocationLat"] = 45.4507
- templateMap["MapLocationLong"] = 68.8319
-
- return status, nil
-}
diff --git a/internal/router/handlers/lang-user-unsubscribe.go b/internal/router/handlers/lang-user-unsubscribe.go
deleted file mode 100644
index 4fbd244..0000000
--- a/internal/router/handlers/lang-user-unsubscribe.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package handlers
-
-import (
- "log/slog"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type UnsubscribeHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &UnsubscribeHandler{})
-}
-
-func (r *UnsubscribeHandler) Filter() (method string, path string) {
- return "GET", "/:lang/user/unsubscribe"
-}
-
-func (r *UnsubscribeHandler) IsTemplated() bool {
- return false
-}
-
-func (r *UnsubscribeHandler) TemplatesToInject() []string {
- return []string{"views/pages/unsubscribe-page.html"}
-}
-
-func (r *UnsubscribeHandler) ToValidateLang() router.LangSetting {
- return router.InPath
-}
-
-func (r *UnsubscribeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- var statusEmoji, statusText, statusColor string
- var status int
-
- unsubscribeCode := c.FormValue("code")
- if unsubscribeCode == "" {
- statusColor = "0, 0, 255"
- statusEmoji = "(╭ರ_•́)"
- statusText = supplements.Localization[lang].UnsubscribePage.UnsetCode
- status = fiber.ErrBadRequest.Code
- } else if clientError, serverError := supplements.Mailer.Unsubscribe(unsubscribeCode); clientError != nil {
- slog.Info("got a client error when unsubscribing", slog.String("error", clientError.Error()))
- statusColor = "255, 0, 0"
- statusEmoji = "(͠≖~≖ ͡ )"
- statusText = supplements.Localization[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 = supplements.Localization[lang].UnsubscribePage.OnServerError
- status = fiber.ErrInternalServerError.Code
- } else {
- statusColor = "0, 255, 0"
- statusEmoji = "♡⸜(˶˃ ᵕ ˂˶)⸝♡"
- statusText = supplements.Localization[lang].UnsubscribePage.Success
- status = fiber.StatusOK
- }
-
- templateMap["StatusColor"] = statusColor
- templateMap["StatusEmoji"] = statusEmoji
- templateMap["StatusText"] = statusText
-
- return status, nil
-}
diff --git a/internal/router/handlers/lang-user.go b/internal/router/handlers/lang-user.go
deleted file mode 100644
index db566fe..0000000
--- a/internal/router/handlers/lang-user.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "log/slog"
-
- "github.com/SayaAndy/saya-today-web/internal/mailer"
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- router.Routes = append(router.Routes, &UserHandler{})
-}
-
-type UserHandler struct {
- router.BasicHandler
-}
-
-func (r *UserHandler) Filter() (method string, path string) {
- return "GET", "/:lang/user"
-}
-
-func (r *UserHandler) IsTemplated() bool {
- return true
-}
-
-func (r *UserHandler) TemplatesToInject() []string {
- return []string{"views/pages/user-page.html"}
-}
-
-func (r *UserHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *UserHandler) ToValidateLang() router.LangSetting {
- return router.InPath
-}
-
-func (r *UserHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["Title"] = supplements.Localization[lang].UserProfile.Header
-
- email, _, err := supplements.Mailer.GetInfo(supplements.Mailer.GetHash(c.IP()))
- if err != nil {
- slog.Error("get info from mailer about a client", slog.String("error", err.Error()))
- }
-
- tagsArray, err := getTags(supplements.B2Client, lang)
- if err != nil {
- return fiber.ErrInternalServerError.Code, fmt.Errorf("failed to get the available tags")
- }
-
- subscriptionType, tags, err := supplements.Mailer.GetSubscriptions(c.IP())
- if err != nil {
- return fiber.ErrInternalServerError.Code, fmt.Errorf("failed to get the user subscriptions")
- }
-
- switch subscriptionType {
- case mailer.None:
- templateMap["TagsPicked"] = "none"
- case mailer.All:
- templateMap["TagsPicked"] = "all"
- case mailer.Specific:
- templateMap["TagsPicked"] = "specific"
- }
-
- templateMap["TagsPickedList"] = tags
-
- templateMap["Email"] = email
- templateMap["EmailCode"] = c.Query("email_code")
- templateMap["ExistingTags"] = tagsArray
- return fiber.StatusOK, nil
-}
-
-func (r *UserHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["Title"] = supplements.Localization[lang].UserProfile.Header
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/lang.go b/internal/router/handlers/lang.go
deleted file mode 100644
index fc4deb6..0000000
--- a/internal/router/handlers/lang.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "math/rand/v2"
- "time"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- router.Routes = append(router.Routes, &HomeHandler{})
-}
-
-type HomeHandler struct {
- router.BasicHandler
-}
-
-func (r *HomeHandler) Filter() (method string, path string) {
- return "GET", "/:lang<len(2)>"
-}
-
-func (r *HomeHandler) IsTemplated() bool {
- return true
-}
-
-func (r *HomeHandler) TemplatesToInject() []string {
- return []string{"views/pages/home-page.html"}
-}
-
-func (r *HomeHandler) ToCache() router.CacheSetting {
- return router.ByUrlOnly
-}
-
-func (r *HomeHandler) CacheDuration() time.Duration {
- return 10 * time.Minute
-}
-
-func (r *HomeHandler) ToValidateLang() router.LangSetting {
- return router.InPath
-}
-
-func (r *HomeHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["Title"] = supplements.Localization[lang].HomePage.Header
- templateMap["FilledHeartCount"] = uint(40)
- templateMap["OutlineHeartCount"] = uint(40)
- templateMap["GifName"] = fmt.Sprintf("otter-%d.gif", rand.Int()%3+1)
- templateMap["FunFacts"] = supplements.FactGiver.Give(lang)
- return fiber.StatusOK, nil
-}
-
-func (r *HomeHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["Title"] = supplements.Localization[lang].HomePage.Header
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/root.go b/internal/router/handlers/root.go
deleted file mode 100644
index c8c4cd4..0000000
--- a/internal/router/handlers/root.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package handlers
-
-import (
- "time"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- router.Routes = append(router.Routes, &RootHandler{})
-}
-
-type RootHandler struct {
- router.BasicHandler
-}
-
-func (r *RootHandler) Filter() (method string, path string) {
- return "GET", "/"
-}
-
-func (r *RootHandler) IsTemplated() bool {
- return true
-}
-
-func (r *RootHandler) TemplatesToInject() []string {
- return []string{"views/pages/language-pick.html"}
-}
-
-func (r *RootHandler) ToCache() router.CacheSetting {
- return router.ByUrlOnly
-}
-
-func (r *RootHandler) CacheDuration() time.Duration {
- return 24 * time.Hour
-}
-
-func (r *RootHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, _ string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["Title"] = "Choose Your Language"
- templateMap["AvailableLanguages"] = supplements.AvailableLanguages
- return fiber.StatusOK, nil
-}
-
-func (r *RootHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, _ string, templateMap fiber.Map) (statusCode int, err error) {
- templateMap["Title"] = "Choose Your Language"
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/path-matcher.go b/internal/router/path-matcher.go
deleted file mode 100644
index c8aef08..0000000
--- a/internal/router/path-matcher.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package router
-
-import (
- "github.com/gofiber/fiber/v2"
- "github.com/valyala/fasthttp"
-)
-
-type PathMatcher struct {
- app *fiber.App
-}
-
-func NewPathMatcher() *PathMatcher {
- app := fiber.New(fiber.Config{
- DisableStartupMessage: true,
- })
- return &PathMatcher{app: app}
-}
-
-func (pm *PathMatcher) AddRoute(method, pattern string) {
- pm.app.Add(method, pattern, func(c *fiber.Ctx) error {
- c.Locals("pattern", pattern)
- return nil
- })
-}
-
-func (pm *PathMatcher) MatchPath(method, path string) (pattern string, params map[string]string, matched bool) {
- fctx := &fasthttp.RequestCtx{}
- fctx.Request.Header.SetMethod(method)
- fctx.Request.SetRequestURI(path)
-
- ctx := pm.app.AcquireCtx(fctx)
- defer pm.app.ReleaseCtx(ctx)
-
- pm.app.Handler()(fctx)
-
- if ctx.Route() == nil {
- return "", nil, false
- }
-
- pattern = ctx.Locals("pattern").(string)
-
- params = make(map[string]string)
- for _, paramName := range ctx.Route().Params {
- params[paramName] = ctx.Params(paramName)
- }
-
- return pattern, params, true
-}
diff --git a/internal/router/router.go b/internal/router/router.go
deleted file mode 100644
index ef3f063..0000000
--- a/internal/router/router.go
+++ /dev/null
@@ -1,559 +0,0 @@
-package router
-
-import (
- "database/sql"
- "errors"
- "fmt"
- "log/slog"
- "net/url"
- "slices"
- "strings"
- "time"
-
- "github.com/SayaAndy/saya-today-web/config"
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/internal/blogtrigger"
- "github.com/SayaAndy/saya-today-web/internal/factgiver"
- "github.com/SayaAndy/saya-today-web/internal/glightbox"
- "github.com/SayaAndy/saya-today-web/internal/mailer"
- "github.com/SayaAndy/saya-today-web/internal/tailwind"
- "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/gofiber/fiber/v2/middleware/cors"
- "github.com/golang-migrate/migrate/v4"
- "github.com/golang-migrate/migrate/v4/database/sqlite3"
- "github.com/yuin/goldmark"
- "github.com/yuin/goldmark/parser"
- "github.com/yuin/goldmark/renderer"
- "github.com/yuin/goldmark/renderer/html"
- "github.com/yuin/goldmark/util"
-)
-
-type CacheSetting int
-
-const (
- Disabled CacheSetting = iota
- ByUrlOnly
- ByUrlAndQuery
-)
-
-type LangSetting int
-
-const (
- NotRequired LangSetting = iota
- InPath
- InForm
- InReferer
-)
-
-var (
- Routes = make([]Route, 0)
-)
-
-type Route interface {
- Filter() (method string, path string)
- IsTemplated() bool
- ToCache() CacheSetting
- CacheDuration() time.Duration
- ToValidateLang() LangSetting
- TemplatesToInject() []string
- Render(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error)
- RenderHeader(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error)
- RenderBody(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error)
- RenderFooter(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error)
- RenderTopEmbeds(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error)
- RenderBottomEmbeds(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error)
-}
-
-type Supplements struct {
- DB *sql.DB
- B2Client *b2.B2Client
- Localization map[string]*locale.LocaleConfig
- AvailableLanguages []config.AvailableLanguageConfig
- ClientCache *ClientCache
- PageCache *ristretto.Cache[string, []byte]
- FactGiver *factgiver.FactGiver
- Mailer *mailer.Mailer
- BlogTrigger *blogtrigger.BlogTriggerScheduler
- TemplateManager *templatemanager.TemplateManager
- MarkdownRenderer goldmark.Markdown
-}
-
-type Router struct {
- supplements *Supplements
- app *fiber.App
- templatedRoutes map[string]map[string]Route
- templatedPathMatcher *PathMatcher
-}
-
-func NewRouter(cfg *config.Config) (*Router, error) {
- supplements := &Supplements{
- AvailableLanguages: cfg.AvailableLanguages,
- }
-
- var err error
-
- supplements.DB, err = sql.Open(cfg.Auth.Db.Type, cfg.Auth.Db.Cfg.DSN)
- if err != nil {
- return nil, fmt.Errorf("fail to initialize db: %w", err)
- }
-
- driver, err := sqlite3.WithInstance(supplements.DB, &sqlite3.Config{})
- if err != nil {
- return nil, fmt.Errorf("fail to initialize driver for migrating db: %w", err)
- }
-
- m, err := migrate.NewWithDatabaseInstance(
- "file://migrations",
- cfg.Auth.Db.Type, driver)
- if err != nil {
- return nil, fmt.Errorf("fail to initialize migration client: %w", err)
- }
-
- if err = m.Up(); err != nil && err == errors.New("no change") {
- return nil, fmt.Errorf("fail to apply migrations: %w", err)
- }
- slog.Debug("successfully applied migrations")
-
- supplements.B2Client, err = b2.NewB2Client(&cfg.BlogPages.Storage.Config)
- if err != nil {
- return nil, fmt.Errorf("fail to initialize b2 client: %w", err)
- }
-
- supplements.Localization = make(map[string]*locale.LocaleConfig, len(cfg.AvailableLanguages))
- for _, lang := range cfg.AvailableLanguages {
- localeCfg, err := locale.InitConfig(cfg.LocalePath + lang.LocFile)
- if err != nil {
- return nil, fmt.Errorf("fail to initialize a locale: %w", err)
- }
- supplements.Localization[lang.Name] = localeCfg
- }
-
- supplements.MarkdownRenderer = goldmark.New(
- goldmark.WithExtensions(
- glightbox.NewGLightboxExtension(),
- tailwind.NewTailwindExtension(),
- ),
- goldmark.WithParserOptions(
- parser.WithAutoHeadingID(),
- parser.WithAttribute(),
- ),
- goldmark.WithRenderer(
- renderer.NewRenderer(
- renderer.WithNodeRenderers(
- util.Prioritized(tailwind.NewCustomLinkRenderer(html.WithUnsafe(), html.WithXHTML()), 50),
- util.Prioritized(html.NewRenderer(html.WithXHTML()), 100),
- ),
- ),
- ),
- )
-
- supplements.ClientCache, err = NewClientCache(supplements.DB, []byte(cfg.Auth.Salt))
- if err != nil {
- return nil, fmt.Errorf("fail to initialize client cache: %w", err)
- }
-
- supplements.PageCache, err = ristretto.NewCache(&ristretto.Config[string, []byte]{
- NumCounters: 1e6, // 1,000,000
- MaxCost: 1 << 29, // 512 MB
- BufferItems: 64, // number of keys per Get buffer.
- })
- if err != nil {
- return nil, fmt.Errorf("fail to initialize page cache: %w", err)
- }
-
- supplements.FactGiver, err = factgiver.NewFactGiver(&cfg.FactGiver, supplements.AvailableLanguages)
- if err != nil {
- return nil, fmt.Errorf("fail to initialize fact giver: %w", err)
- }
-
- supplements.Mailer, err = mailer.NewMailer(supplements.DB, cfg.Mail.ClientHost, cfg.Mail.MailHost,
- cfg.Mail.PublicName, cfg.Mail.MailAddress, cfg.Mail.Username, cfg.Mail.Password, []byte(cfg.Mail.Salt), supplements.Localization)
- if err != nil {
- return nil, fmt.Errorf("fail to initialize mailer: %w", err)
- }
-
- supplements.BlogTrigger, err = blogtrigger.NewBlogTriggerScheduler(supplements.B2Client, cfg.AvailableLanguages, cfg.Mail.Trigger.OnNewPost,
- func(bp []*b2.BlogPage) error {
- for _, post := range bp {
- if err := supplements.Mailer.NewPost(post); err != nil {
- return err
- }
- }
- return nil
- })
- if err != nil {
- return nil, fmt.Errorf("fail to initialize blog trigger: %w", err)
- }
-
- supplements.TemplateManager, err = templatemanager.NewTemplateManager()
- if err != nil {
- return nil, fmt.Errorf("fail to initialize template manager: %w", err)
- }
-
- enablePrintRoutes := false
- if cfg.LogLevel <= slog.LevelDebug {
- enablePrintRoutes = true
- }
-
- app := fiber.New(fiber.Config{
- EnablePrintRoutes: enablePrintRoutes,
- ProxyHeader: "X-Forwarded-For",
- })
-
- app.Use(cors.New(cors.Config{
- AllowOrigins: "https://f003.backblazeb2.com",
- AllowMethods: "GET,POST,OPTIONS",
- AllowHeaders: "Origin, Content-Type, Accept",
- }))
-
- templatedRoutes := make(map[string]map[string]Route)
- templatedPathMatcher := NewPathMatcher()
-
- return &Router{supplements, app, templatedRoutes, templatedPathMatcher}, nil
-}
-
-func (r *Router) InitRoutes() (err error) {
- r.supplements.TemplateManager.Add("general-page", "views/layouts/general-page.html")
- for _, route := range Routes {
- method, match := route.Filter()
-
- if err = r.supplements.TemplateManager.Add(method+" "+match, route.TemplatesToInject()...); err != nil {
- return fmt.Errorf("failed to add '%s %s' route into template manager: %w", method, match, err)
- }
-
- if route.IsTemplated() {
- if _, ok := r.templatedRoutes[method]; !ok {
- r.templatedRoutes[method] = make(map[string]Route)
- }
- r.templatedRoutes[method][match] = route
- r.templatedPathMatcher.AddRoute(method, match)
-
- currentRoute := route
- r.app.Add(method, match, func(c *fiber.Ctx) error {
- lang, err := r.getAndValidateLang(c, currentRoute.ToValidateLang())
- if err != nil {
- return err
- }
- err = r.generalPage(c, currentRoute, lang)
- return err
- })
- } else {
- currentRoute := route
- r.app.Add(method, match, func(c *fiber.Ctx) error {
- lang, err := r.getAndValidateLang(c, currentRoute.ToValidateLang())
- if err != nil {
- return err
- }
-
- var cacheKey string
- trimmedPath := strings.Trim(c.Path(), "/")
- queryString := c.Request().URI().QueryString()
-
- switch currentRoute.ToCache() {
- case ByUrlOnly:
- cacheKey = fmt.Sprintf("%s.full-page.%s", method, trimmedPath)
- case ByUrlAndQuery:
- cacheKey = fmt.Sprintf("%s.full-page.%s.%s", method, trimmedPath, queryString)
- }
-
- defaultMap := fiber.Map{
- "L": r.supplements.Localization[lang],
- "Lang": lang,
- "Path": trimmedPath,
- "QueryString": queryString,
- }
-
- statusCode, err := currentRoute.Render(c, r.supplements, lang, defaultMap)
- method := c.Method()
- _, match := currentRoute.Filter()
- if err != nil {
- slog.Error("failed to finish rendering a page",
- slog.Int("status_code", statusCode),
- slog.String("method", method),
- slog.String("path", c.Path()),
- slog.String("match", match),
- slog.String("query", string(queryString)),
- slog.String("error", err.Error()),
- )
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(statusCode).SendString(err.Error())
- }
-
- content, err := r.supplements.TemplateManager.Render(method+" "+match, defaultMap)
- if err != nil {
- slog.Error("failed to generate div",
- slog.String("method", method),
- slog.String("path", c.Path()),
- slog.String("match", match),
- slog.String("query", string(queryString)),
- slog.String("error", err.Error()),
- )
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
- if val, ok := defaultMap["Output"]; ok && len(content) == 0 {
- content = val.([]byte)
- }
-
- if statusCode >= 200 && statusCode < 300 && route.ToCache() != Disabled {
- go r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration())
- }
-
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(statusCode).Type("html").Send(content)
- })
- }
- }
-
- segments := []string{"header", "body", "footer", "top-embeds", "bottom-embeds"}
-
- r.app.Get("/api/v1/general-page/:part", func(c *fiber.Ctx) error {
- part := c.Params("part")
- if !slices.Contains(segments, part) {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("unknown segment '%s'", part))
- }
- err := r.generalPageSegment(c, part)
- return err
- })
-
- for _, segment := range segments {
- r.supplements.TemplateManager.Add("general-page-"+segment, "views/partials/general-page-"+segment+".html")
- }
-
- r.app.Static("/", "./static")
-
- Routes = make([]Route, 0)
-
- return nil
-}
-
-func (r *Router) Listen(endpoint string) error {
- if err := r.app.Listen(endpoint); err != nil {
- return fmt.Errorf("error while running fiber server: %w", err)
- }
- return nil
-}
-
-func (r *Router) Close() (err error) {
- allErrors := make([]error, 0)
- if err = r.supplements.BlogTrigger.Close(); err != nil {
- allErrors = append(allErrors, fmt.Errorf("fail to shutdown blog trigger scheduler: %w", err))
- }
- if err = r.app.Shutdown(); err != nil {
- allErrors = append(allErrors, fmt.Errorf("fail to shutdown fiber server: %w", err))
- }
- if err = r.supplements.ClientCache.Close(); err != nil {
- allErrors = append(allErrors, fmt.Errorf("fail to dump cache: %w", err))
- }
- if err = r.supplements.DB.Close(); err != nil {
- allErrors = append(allErrors, fmt.Errorf("fail to close db connection: %w", err))
- }
- r.supplements.PageCache.Close()
- return errors.Join(allErrors...)
-}
-
-func (r *Router) generalPage(c *fiber.Ctx, route Route, lang string) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- path := c.Path()
- method := c.Method()
- trimmedPath := strings.Trim(path, "/")
- cacheKey := ""
- queryString := c.Request().URI().QueryString()
-
- switch route.ToCache() {
- case ByUrlOnly:
- cacheKey = fmt.Sprintf("%s.general-page.%s", method, trimmedPath)
- case ByUrlAndQuery:
- cacheKey = fmt.Sprintf("%s.general-page.%s.%s", method, trimmedPath, queryString)
- }
-
- if val, ok := r.supplements.PageCache.Get(cacheKey); val != nil && ok {
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(val)
- }
-
- content, err := r.supplements.TemplateManager.Render("general-page", fiber.Map{
- "L": r.supplements.Localization[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 r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration())
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(content)
-}
-
-func (r *Router) generalPageSegment(c *fiber.Ctx, part string) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- path, pathParts, queryString, err := GetPathFromReferer(c)
- if err != nil {
- return err
- }
-
- method := c.Method()
- pattern, _, matched := r.templatedPathMatcher.MatchPath(method, path)
- if !matched {
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("fail to get templated page for '%s %s': not found", method, path))
- }
- route := r.templatedRoutes[method][pattern]
-
- lang := ""
-
- switch route.ToValidateLang() {
- case InPath, InReferer:
- if len(pathParts) > 0 {
- lang = pathParts[0]
- }
- if _, err := r.getAndValidateLang(c, NotRequired, lang); err != nil {
- return err
- }
- case InForm:
- if lang, err = r.getAndValidateLang(c, InForm); err != nil {
- return err
- }
- }
-
- cacheKey := ""
- trimmedPath := strings.Trim(path, "/")
- switch route.ToCache() {
- case ByUrlOnly:
- cacheKey = fmt.Sprintf("%s.%s.%s", method, part, trimmedPath)
- case ByUrlAndQuery:
- cacheKey = fmt.Sprintf("%s.%s.%s.%s", method, part, trimmedPath, queryString)
- }
-
- if route.ToCache() != Disabled {
- if val, ok := r.supplements.PageCache.Get(cacheKey); val != nil && ok {
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(val)
- }
- }
-
- var statusCode int
- defaultMap := fiber.Map{
- "L": r.supplements.Localization[lang],
- "Lang": lang,
- "Path": strings.Trim(path, "/"),
- "QueryString": queryString,
- }
-
- switch part {
- case "body":
- statusCode, err = route.RenderBody(c, r.supplements, lang, defaultMap)
- case "header":
- statusCode, err = route.RenderHeader(c, r.supplements, lang, defaultMap)
- case "footer":
- statusCode, err = route.RenderFooter(c, r.supplements, lang, defaultMap)
- case "top-embeds":
- statusCode, err = route.RenderTopEmbeds(c, r.supplements, lang, defaultMap)
- case "bottom-embeds":
- statusCode, err = route.RenderBottomEmbeds(c, r.supplements, lang, defaultMap)
- }
- if err != nil {
- slog.Error(err.Error(),
- slog.String("method", method),
- slog.String("path", path),
- slog.String("query", string(queryString)),
- slog.String("segment", part),
- slog.String("error", err.Error()),
- )
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(statusCode).SendString(err.Error())
- }
-
- content, err := r.supplements.TemplateManager.Render("general-page-"+part, defaultMap, route.TemplatesToInject()...)
- if err != nil {
- slog.Error("failed to generate div",
- slog.String("method", method),
- slog.String("path", path),
- slog.String("query", string(queryString)),
- slog.String("segment", part),
- slog.String("error", err.Error()),
- )
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
-
- if statusCode == fiber.StatusNoContent && len(content) != 0 {
- statusCode = fiber.StatusOK
- }
-
- if statusCode >= 200 && statusCode < 300 && route.ToCache() != Disabled {
- go r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration())
- }
-
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(statusCode).Type("html").Send(content)
-}
-
-func (r *Router) getAndValidateLang(c *fiber.Ctx, langSetting LangSetting, defaultLang ...string) (string, error) {
- var lang string
- if len(defaultLang) > 0 {
- lang = defaultLang[0]
- }
-
- switch langSetting {
- case NotRequired:
- return lang, nil
-
- case InPath:
- path := c.Path()
-
- pathParts := strings.Split(strings.Trim(path, "/"), "/")
- if len(pathParts) == 1 && pathParts[0] == "" {
- pathParts = []string{}
- }
- if len(pathParts) > 0 && len(pathParts[0]) == 2 {
- lang = pathParts[0]
- }
-
- case InForm:
- lang = c.FormValue("lang")
-
- case InReferer:
- _, pathParts, _, err := GetPathFromReferer(c)
- if err != nil {
- return "", err
- }
-
- if len(pathParts) >= 1 {
- lang = pathParts[0]
- }
- }
-
- for _, availableLang := range r.supplements.AvailableLanguages {
- if availableLang.Name == lang {
- return lang, nil
- }
- }
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return "", c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("lang value is invalid: '%s' is not considered an available language", lang))
-}
-
-func GetPathFromReferer(c *fiber.Ctx) (path string, pathParts []string, queryString string, err error) {
- referer := c.Get("Referer")
- if referer == "" {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return "", nil, "", c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
- }
- urlStruct, err := url.ParseRequestURI(referer)
- if err != nil {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return "", nil, "", c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
- }
-
- path = urlStruct.EscapedPath()
- pathParts = strings.Split(strings.Trim(path, "/"), "/")
- queryString = urlStruct.RawQuery
- return
-}
diff --git a/internal/tailwind/extension.go b/internal/tailwind/extension.go
deleted file mode 100644
index c3254cc..0000000
--- a/internal/tailwind/extension.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package tailwind
-
-import (
- "github.com/yuin/goldmark"
- "github.com/yuin/goldmark/parser"
- "github.com/yuin/goldmark/util"
-)
-
-type TailwindExtension struct{}
-
-func NewTailwindExtension() goldmark.Extender {
- return &TailwindExtension{}
-}
-
-func (e *TailwindExtension) Extend(m goldmark.Markdown) {
- m.Parser().AddOptions(
- parser.WithASTTransformers(
- util.Prioritized(&TailwindTransformer{}, 500),
- ),
- )
-}
diff --git a/internal/tailwind/link_renderer.go b/internal/tailwind/link_renderer.go
deleted file mode 100644
index ff3d437..0000000
--- a/internal/tailwind/link_renderer.go
+++ /dev/null
@@ -1,49 +0,0 @@
-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
deleted file mode 100644
index c53ded1..0000000
--- a/internal/tailwind/transformer.go
+++ /dev/null
@@ -1,79 +0,0 @@
-package tailwind
-
-import (
- "bytes"
- "fmt"
-
- "github.com/yuin/goldmark/ast"
- "github.com/yuin/goldmark/parser"
- "github.com/yuin/goldmark/text"
-)
-
-type TailwindTransformer struct{}
-
-func (t *TailwindTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
- ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
- if !entering {
- return ast.WalkContinue, nil
- }
-
- switch node := n.(type) {
- case *ast.Heading:
- classes := map[int]string{
- 1: "font-andika text-4xl font-bold text-main-hard mb-3 tracking-[.0125rem]",
- 2: "font-andika text-3xl font-bold text-main-hard mb-1 tracking-[.0125rem]",
- 3: "font-andika text-2xl font-medium text-main-hard mb-0.8 tracking-[.0125rem]",
- 4: "font-andika text-xl font-medium text-main-medium mb-0.5 tracking-[.0125rem]",
- 5: "font-andika text-base font-medium text-main-medium mb-0.5 italic tracking-[.0125rem]",
- 6: "font-andika text-base font-medium text-secondary mb-0.5",
- }
- if class, ok := classes[node.Level]; ok {
- node.SetAttribute([]byte("class"), []byte(class))
- }
-
- case *ast.Paragraph:
- node.SetAttribute([]byte("class"), []byte("text-base/[2] font-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-2 mb-4 pl-8"))
- } else {
- 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-base/[2] font-andika tracking-[.0125rem]"))
-
- case *ast.Blockquote:
- node.SetAttribute([]byte("class"), []byte("border-l-2 border-main-medium bg-background-dark p-1 mb-2 italic"))
-
- 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-1 rounded-lg overflow-x-auto mb-4"))
-
- case *ast.Link:
- 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-4"))
-
- case *ast.Emphasis:
- switch node.Level {
- case 1:
- node.SetAttribute([]byte("class"), []byte("italic"))
- case 2:
- node.SetAttribute([]byte("class"), []byte("font-bold"))
- case 3:
- node.SetAttribute([]byte("class"), []byte("italic font-bold"))
- }
- }
-
- return ast.WalkContinue, nil
- })
-}
diff --git a/internal/templatemanager/templatemanager.go b/internal/templatemanager/templatemanager.go
deleted file mode 100644
index 75309e4..0000000
--- a/internal/templatemanager/templatemanager.go
+++ /dev/null
@@ -1,120 +0,0 @@
-package templatemanager
-
-import (
- "bytes"
- "fmt"
- "html/template"
- "path/filepath"
- "strings"
-)
-
-type TemplateManager struct {
- templates map[string]templateManagerRender
-}
-
-type templateManagerRender struct {
- Main string
- Tmpl *template.Template
-}
-
-type TemplateManagerTemplates struct {
- Name string
- Files []string
-}
-
-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(tmplStruct.Name).Funcs(templateFuncMap)
- if len(tmplStruct.Files) == 0 {
- templateMap[tmplStruct.Name] = templateManagerRender{
- Main: "",
- Tmpl: tmpl,
- }
- continue
- }
- tmpl, err := tmpl.ParseFiles(tmplStruct.Files...)
- if err != nil {
- return nil, err
- }
-
- templateMap[tmplStruct.Name] = templateManagerRender{
- Main: filepath.Base(tmplStruct.Files[0]),
- Tmpl: tmpl,
- }
- }
-
- return &TemplateManager{
- templates: templateMap,
- }, nil
-}
-
-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 not found", name)
- }
-
- var err error
- var tempTmpl *template.Template
- var mainTmpl string = tmpl.Main
- if len(files) == 0 {
- if mainTmpl == "" {
- return []byte{}, nil
- }
- 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)
- }
- if mainTmpl == "" {
- mainTmpl = filepath.Base(files[0])
- }
- }
-
- var buf bytes.Buffer
- err = tempTmpl.ExecuteTemplate(&buf, mainTmpl, data)
- return buf.Bytes(), err
-}
-
-func (tm *TemplateManager) Add(name string, files ...string) error {
- tmpl := template.New(name).Funcs(templateFuncMap)
-
- if len(files) == 0 {
- tm.templates[name] = templateManagerRender{
- Main: "",
- Tmpl: tmpl,
- }
- return nil
- }
-
- 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
-}