summaryrefslogtreecommitdiff
path: root/internal
diff refs
from: back
to: back
| flip
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/blog/b2.go (renamed from internal/b2/client.go)62
-rw-r--r--internal/blog/client.go28
-rw-r--r--internal/blog/index.go103
-rw-r--r--internal/blog/s3.go241
-rw-r--r--internal/blogtrigger/blogtrigger.go72
-rw-r--r--internal/factgiver/factgiver.go24
-rw-r--r--internal/frontmatter/parser.go33
-rw-r--r--internal/glightbox/block.go33
-rw-r--r--internal/glightbox/extension.go31
-rw-r--r--internal/glightbox/html_renderer.go202
-rw-r--r--internal/glightbox/parser.go (renamed from internal/lightgallery/parser.go)49
-rw-r--r--internal/lightgallery/block.go31
-rw-r--r--internal/lightgallery/extension.go28
-rw-r--r--internal/lightgallery/html_renderer.go145
-rw-r--r--internal/mailer/mailer.go540
-rw-r--r--internal/router/api-v1-blog-search.go123
-rw-r--r--internal/router/api-v1-general-page-body.go162
-rw-r--r--internal/router/api-v1-general-page-bottom-embeds.go75
-rw-r--r--internal/router/api-v1-general-page-footer.go75
-rw-r--r--internal/router/api-v1-general-page-header.go85
-rw-r--r--internal/router/api-v1-general-page-top-embeds.go75
-rw-r--r--internal/router/api-v1-general-page.go52
-rw-r--r--internal/router/api-v1-like.go122
-rw-r--r--internal/router/api-v1-tz.go37
-rw-r--r--internal/router/basic-handler.go82
-rw-r--r--internal/router/client-cache.go20
-rw-r--r--internal/router/handlers/api-v1-blog-search.go145
-rw-r--r--internal/router/handlers/api-v1-email-is-in-verification.go60
-rw-r--r--internal/router/handlers/api-v1-email-send-verification-code.go102
-rw-r--r--internal/router/handlers/api-v1-email-verify.go67
-rw-r--r--internal/router/handlers/api-v1-like-get.go63
-rw-r--r--internal/router/handlers/api-v1-like-put.go78
-rw-r--r--internal/router/handlers/api-v1-map-get.go116
-rw-r--r--internal/router/handlers/api-v1-subs-put.go70
-rw-r--r--internal/router/handlers/api-v1-tz-get.go65
-rw-r--r--internal/router/handlers/lang-blog-title.go182
-rw-r--r--internal/router/handlers/lang-blog.go129
-rw-r--r--internal/router/handlers/lang-map.go46
-rw-r--r--internal/router/handlers/lang-user-unsubscribe.go69
-rw-r--r--internal/router/handlers/lang-user.go89
-rw-r--r--internal/router/handlers/lang.go81
-rw-r--r--internal/router/handlers/robots.txt.go48
-rw-r--r--internal/router/handlers/root.go68
-rw-r--r--internal/router/handlers/sitemap.xml.go79
-rw-r--r--internal/router/lang-blog-title.go83
-rw-r--r--internal/router/lang-blog.go94
-rw-r--r--internal/router/lang-map.go85
-rw-r--r--internal/router/page-cache.go5
-rw-r--r--internal/router/path-matcher.go48
-rw-r--r--internal/router/rate-limiters.go37
-rw-r--r--internal/router/root.go27
-rw-r--r--internal/router/router.go699
-rw-r--r--internal/router/tm.go18
-rw-r--r--internal/tailwind/link_renderer.go49
-rw-r--r--internal/tailwind/transformer.go39
-rw-r--r--internal/templatemanager/templatemanager.go92
56 files changed, 3948 insertions, 1415 deletions
diff --git a/internal/b2/client.go b/internal/blog/b2.go
index 7b745a8..f2ac890 100644
--- a/internal/b2/client.go
+++ b/internal/blog/b2.go
@@ -1,8 +1,10 @@
-package b2
+package blog
import (
"context"
+ "encoding/json"
"fmt"
+ "slices"
"strings"
"time"
@@ -17,28 +19,41 @@ type B2Client struct {
b2cl *b2.Client
}
-func NewB2Client(cfg *config.B2Config) (*B2Client, error) {
- b2cl, err := b2.NewClient(context.Background(), cfg.KeyID, cfg.ApplicationKey)
+func NewB2Client(cfg *config.StorageConfig) (Client, error) {
+ if cfg.Type != "b2" {
+ return nil, fmt.Errorf("invalid storage type for B2InputClient")
+ }
+ b2cfg := cfg.Config.(*config.B2Config)
+
+ b2cl, err := b2.NewClient(context.Background(), b2cfg.KeyID, b2cfg.ApplicationKey)
if err != nil {
return nil, err
}
- bucket, err := b2cl.Bucket(context.Background(), cfg.BucketName)
+ bucket, err := b2cl.Bucket(context.Background(), b2cfg.BucketName)
if err != nil {
return nil, err
}
- return &B2Client{b2cl: b2cl, bucket: bucket, prefix: cfg.Prefix}, nil
+ return &B2Client{b2cl: b2cl, bucket: bucket, prefix: b2cfg.Prefix}, nil
}
-type BlogPage struct {
- Link string
- FileName string
- Metadata *frontmatter.Metadata
+func (c *B2Client) GetMedleys() ([]MedleyEntry, error) {
+ idxRaw, err := c.readAll(MedleysIndexFileName)
+ if err != nil {
+ return nil, fmt.Errorf("read %s: %w", MedleysIndexFileName, err)
+ }
+
+ var idx []MedleyEntry
+ if err := json.Unmarshal(idxRaw, &idx); err != nil {
+ return nil, fmt.Errorf("unmarshal %s: %w", MedleysIndexFileName, err)
+ }
+
+ return idx, nil
}
-func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) {
- filePaths := []*BlogPage{}
+func (c *B2Client) Scan(prefix string) ([]*Page, error) {
+ filePaths := []*Page{}
iter := c.bucket.List(context.Background(), b2.ListPrefix(c.prefix+prefix))
@@ -70,20 +85,25 @@ func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) {
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], ".")
+ link := obj.Name()
+ fileName := link[strings.LastIndex(link, "/")+1 : strings.LastIndex(link, ".")]
+ tags := strings.Split(attrs.Info["tags"], ",")
+ slices.Sort(tags)
+
+ lang, _ := strings.CutPrefix(link[0:strings.Index(link, "/")], c.prefix)
- filePaths = append(filePaths, &BlogPage{
- Link: obj.Name(),
- FileName: fileName,
+ filePaths = append(filePaths, &Page{
+ Link: link,
+ FileName: fileName,
+ Lang: lang,
+ ModifiedTime: attrs.LastModified,
Metadata: &frontmatter.Metadata{
Title: attrs.Info["title"],
ShortDescription: attrs.Info["short-description"],
ActionDate: attrs.Info["action-date"],
PublishedTime: publishedTime,
Thumbnail: attrs.Info["thumbnail"],
- Tags: strings.Split(attrs.Info["tags"], ","),
+ Tags: tags,
Geolocation: attrs.Info["geolocation"],
},
})
@@ -97,7 +117,11 @@ func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) {
}
func (c *B2Client) ReadAll(path string) ([]byte, error) {
- obj := c.bucket.Object(c.prefix + path)
+ return c.readAll(c.prefix + path)
+}
+
+func (c *B2Client) readAll(path string) ([]byte, error) {
+ obj := c.bucket.Object(path)
if obj == nil {
return nil, fmt.Errorf("failed to reference object in B2 bucket")
}
diff --git a/internal/blog/client.go b/internal/blog/client.go
new file mode 100644
index 0000000..53eb0fd
--- /dev/null
+++ b/internal/blog/client.go
@@ -0,0 +1,28 @@
+package blog
+
+import (
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/frontmatter"
+)
+
+type Page struct {
+ Link string
+ FileName string
+ Lang string
+ ModifiedTime time.Time
+ Metadata *frontmatter.Metadata
+}
+
+type Client interface {
+ Scan(prefix string) ([]*Page, error)
+ GetMedleys() ([]MedleyEntry, error)
+ ReadAll(path string) ([]byte, error)
+ ReadFrontmatter(path string) (metadata *frontmatter.Metadata, markdown []byte, err error)
+}
+
+var NewClientMap = map[string]func(*config.StorageConfig) (Client, error){
+ "b2": NewB2Client,
+ "s3": NewS3Client,
+}
diff --git a/internal/blog/index.go b/internal/blog/index.go
new file mode 100644
index 0000000..a09b59e
--- /dev/null
+++ b/internal/blog/index.go
@@ -0,0 +1,103 @@
+package blog
+
+import (
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/frontmatter"
+)
+
+const IndexFileName = "index.json"
+const MedleysIndexFileName = "medleys.json"
+
+const IndexSchemaVersion = 2
+
+type IndexEntry struct {
+ Link string `json:"link"`
+ ModifiedTime time.Time `json:"modifiedTime"`
+ Title string `json:"title"`
+ ShortDescription string `json:"shortDescription"`
+ ActionDate string `json:"actionDate"`
+ PublishedTime time.Time `json:"publishedTime"`
+ Thumbnail string `json:"thumbnail"`
+ Tags []string `json:"tags"`
+ Geolocation string `json:"geolocation"`
+ Medley string `json:"medley,omitempty"`
+ MedleyPart int `json:"medleyPart,omitempty"`
+}
+
+func (e IndexEntry) Metadata() *frontmatter.Metadata {
+ return &frontmatter.Metadata{
+ Title: e.Title,
+ ShortDescription: e.ShortDescription,
+ ActionDate: e.ActionDate,
+ PublishedTime: e.PublishedTime,
+ Thumbnail: e.Thumbnail,
+ Tags: e.Tags,
+ Geolocation: e.Geolocation,
+ Medley: e.Medley,
+ MedleyPart: e.MedleyPart,
+ }
+}
+
+type IndexV2Category struct {
+ GeneratedAt time.Time `json:"generatedAt"`
+ Pages map[string]IndexEntry `json:"pages"`
+}
+
+type IndexV1Category struct {
+ GeneratedAt time.Time `json:"generatedAt"`
+ Pages []IndexEntry `json:"pages"`
+}
+
+type Index struct {
+ SchemaVersion int `json:"schemaVersion"`
+ GeneratedAt time.Time `json:"generatedAt"`
+ Categories any `json:"categories"`
+}
+
+func (idx *Index) UnmarshalJSON(data []byte) error {
+ var tmp struct {
+ SchemaVersion int `json:"schemaVersion"`
+ GeneratedAt time.Time `json:"generatedAt"`
+ Categories json.RawMessage `json:"categories"`
+ }
+
+ if err := json.Unmarshal(data, &tmp); err != nil {
+ return err
+ }
+
+ idx.SchemaVersion = tmp.SchemaVersion
+ idx.GeneratedAt = tmp.GeneratedAt
+
+ switch tmp.SchemaVersion {
+ case 1:
+ var categories map[string]*IndexV1Category
+ if err := json.Unmarshal(tmp.Categories, &categories); err != nil {
+ return fmt.Errorf("unmarshal map[string]*IndexV1Category: %w", err)
+ }
+ idx.Categories = &categories
+ case 2:
+ var categories map[string]*IndexV2Category
+ if err := json.Unmarshal(tmp.Categories, &categories); err != nil {
+ return fmt.Errorf("unmarshal map[string]*IndexV2Category: %w", err)
+ }
+ idx.Categories = &categories
+ default:
+ return fmt.Errorf("unsupported index version: %d", tmp.SchemaVersion)
+ }
+
+ return nil
+}
+
+type MedleyEntry struct {
+ Codename string `json:"codename"`
+ Localnames map[string]string `json:"localnames"`
+ Content []string `json:"content"`
+}
+
+type MedleyPageEntry struct {
+ Codename string `json:"codename"`
+ Position int `json:"position"`
+}
diff --git a/internal/blog/s3.go b/internal/blog/s3.go
new file mode 100644
index 0000000..3c1a932
--- /dev/null
+++ b/internal/blog/s3.go
@@ -0,0 +1,241 @@
+package blog
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/frontmatter"
+ "github.com/SayaAndy/saya-today-web/l10n"
+ "github.com/aws/aws-sdk-go-v2/aws"
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/credentials"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+)
+
+type S3Client struct {
+ prefix string
+ bucketName string
+ s3cl *s3.Client
+}
+
+func NewS3Client(cfg *config.StorageConfig) (Client, error) {
+ if cfg.Type != "s3" {
+ return nil, fmt.Errorf("invalid storage type for S3Client")
+ }
+ s3cfg := cfg.Config.(*config.S3Config)
+
+ opts := []func(*awsconfig.LoadOptions) error{
+ awsconfig.WithRegion(s3cfg.Region),
+ }
+ if s3cfg.AccessKeyID != "" && s3cfg.SecretAccessKey != "" {
+ opts = append(opts, awsconfig.WithCredentialsProvider(
+ credentials.NewStaticCredentialsProvider(s3cfg.AccessKeyID, s3cfg.SecretAccessKey, ""),
+ ))
+ }
+
+ awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(), opts...)
+ if err != nil {
+ return nil, fmt.Errorf("load AWS config: %w", err)
+ }
+
+ var s3Opts []func(*s3.Options)
+ if s3cfg.Endpoint != "" {
+ s3Opts = append(s3Opts, func(o *s3.Options) {
+ o.BaseEndpoint = aws.String(s3cfg.Endpoint)
+ })
+ }
+ s3Opts = append(s3Opts, func(o *s3.Options) {
+ o.UsePathStyle = s3cfg.UsePathStyle
+ o.DisableLogOutputChecksumValidationSkipped = true
+ })
+
+ s3cl := s3.NewFromConfig(awsCfg, s3Opts...)
+
+ return &S3Client{s3cfg.Prefix, s3cfg.BucketName, s3cl}, nil
+}
+
+func (c *S3Client) GetMedleys() ([]MedleyEntry, error) {
+ idxRaw, err := c.readAll(MedleysIndexFileName)
+ if err != nil {
+ return nil, fmt.Errorf("read %s: %w", MedleysIndexFileName, err)
+ }
+
+ var idx []MedleyEntry
+ if err := json.Unmarshal(idxRaw, &idx); err != nil {
+ return nil, fmt.Errorf("unmarshal %s: %w", MedleysIndexFileName, err)
+ }
+
+ return idx, nil
+}
+
+func (c *S3Client) Scan(prefix string) ([]*Page, error) {
+ out, err := c.s3cl.GetObject(context.Background(), &s3.GetObjectInput{
+ Bucket: aws.String(c.bucketName),
+ Key: aws.String(IndexFileName),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("get %s: %w", IndexFileName, err)
+ }
+ defer out.Body.Close()
+
+ raw, err := io.ReadAll(out.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read %s: %w", IndexFileName, err)
+ }
+
+ var idx Index
+ if err := json.Unmarshal(raw, &idx); err != nil {
+ return nil, fmt.Errorf("unmarshal %s: %w", IndexFileName, err)
+ }
+
+ wantLang := ""
+ if i := strings.Index(prefix, "/"); i > 0 {
+ wantLang = prefix[:i]
+ }
+
+ fullPrefix := c.prefix + prefix
+ pages := make([]*Page, 0)
+
+ switch idx.SchemaVersion {
+ case 1:
+ for catKey, cat := range *idx.Categories.(*map[string]*IndexV1Category) {
+ lang, ok := strings.CutPrefix(catKey, c.prefix)
+ if !ok {
+ continue
+ }
+ if wantLang != "" && wantLang != lang {
+ continue
+ }
+ for _, e := range cat.Pages {
+ if !strings.HasPrefix(e.Link, fullPrefix) {
+ continue
+ }
+ fileName := e.Link[strings.LastIndex(e.Link, "/")+1 : strings.LastIndex(e.Link, ".")]
+ pages = append(pages, &Page{
+ Link: e.Link,
+ FileName: fileName,
+ Lang: lang,
+ ModifiedTime: e.ModifiedTime,
+ Metadata: &frontmatter.Metadata{
+ Title: e.Title,
+ ShortDescription: e.ShortDescription,
+ ActionDate: e.ActionDate,
+ PublishedTime: e.PublishedTime,
+ Thumbnail: e.Thumbnail,
+ Tags: e.Tags,
+ Geolocation: e.Geolocation,
+ Medley: e.Medley,
+ MedleyPart: e.MedleyPart,
+ },
+ })
+ }
+ }
+ case 2:
+ for catKey, cat := range *idx.Categories.(*map[string]*IndexV2Category) {
+ lang, ok := strings.CutPrefix(catKey, c.prefix)
+ if !ok {
+ continue
+ }
+ if wantLang != "" && wantLang != lang {
+ continue
+ }
+ for codename, e := range cat.Pages {
+ if !strings.HasPrefix(e.Link, fullPrefix) {
+ continue
+ }
+ pages = append(pages, &Page{
+ Link: e.Link,
+ FileName: codename,
+ Lang: lang,
+ ModifiedTime: e.ModifiedTime,
+ Metadata: &frontmatter.Metadata{
+ Title: e.Title,
+ ShortDescription: e.ShortDescription,
+ ActionDate: e.ActionDate,
+ PublishedTime: e.PublishedTime,
+ Thumbnail: e.Thumbnail,
+ Tags: e.Tags,
+ Geolocation: e.Geolocation,
+ Medley: e.Medley,
+ MedleyPart: e.MedleyPart,
+ },
+ })
+ }
+ }
+ }
+
+ medleys, _ := c.GetMedleys()
+ for _, medley := range medleys {
+ for locale, localname := range medley.Localnames {
+ l10n.T.SetPath(localname, true, locale, "Medleys", medley.Codename)
+ }
+ }
+
+ return pages, nil
+}
+
+func (c *S3Client) ReadAll(path string) ([]byte, error) {
+ return c.readAll(c.prefix + path)
+}
+
+func (c *S3Client) readAll(path string) ([]byte, error) {
+ output, err := c.s3cl.GetObject(context.Background(), &s3.GetObjectInput{
+ Bucket: aws.String(c.bucketName),
+ Key: aws.String(path),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("get S3 object: %w", err)
+ }
+ defer output.Body.Close()
+
+ content, err := io.ReadAll(output.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read S3 object body: %w", err)
+ }
+
+ return content, nil
+}
+
+func (c *S3Client) ReadFrontmatter(path string) (metadata *frontmatter.Metadata, markdown []byte, err error) {
+ idxRaw, err := c.readAll(IndexFileName)
+ if err != nil {
+ return nil, nil, fmt.Errorf("read %s: %w", IndexFileName, err)
+ }
+
+ var idx Index
+ if err := json.Unmarshal(idxRaw, &idx); err != nil {
+ return nil, nil, fmt.Errorf("unmarshal %s: %w", IndexFileName, err)
+ }
+
+ contentBytes, err := c.ReadAll(path)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to read file for frontmatter parsing: %w", err)
+ }
+
+ switch idx.SchemaVersion {
+ case 1:
+ return frontmatter.ParseFrontmatter(contentBytes)
+ case 2:
+ fullPath := c.prefix + path
+ page := (*idx.Categories.(*map[string]*IndexV2Category))[fullPath[:strings.LastIndex(fullPath, "/")]].Pages[fullPath[strings.LastIndex(fullPath, "/")+1:strings.LastIndex(fullPath, ".")]]
+ metadata = page.Metadata()
+
+ if !bytes.HasPrefix(contentBytes, []byte("---\n")) {
+ return metadata, contentBytes, nil
+ }
+
+ end := bytes.Index(contentBytes[4:], []byte("\n---\n"))
+ if end == -1 {
+ return metadata, contentBytes, nil
+ }
+
+ return metadata, contentBytes[end+9:], nil
+ }
+
+ return frontmatter.ParseFrontmatter(contentBytes)
+}
diff --git a/internal/blogtrigger/blogtrigger.go b/internal/blogtrigger/blogtrigger.go
new file mode 100644
index 0000000..81efafe
--- /dev/null
+++ b/internal/blogtrigger/blogtrigger.go
@@ -0,0 +1,72 @@
+package blogtrigger
+
+import (
+ "fmt"
+ "log/slog"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/blog"
+ "github.com/go-co-op/gocron/v2"
+)
+
+type BlogTriggerScheduler struct {
+ s gocron.Scheduler
+ knownBlogPages map[string]map[string]*blog.Page
+ blogClient blog.Client
+ onTrigger func([]*blog.Page) error
+}
+
+func NewBlogTriggerScheduler(blogClient blog.Client, availableLanguages []config.AvailableLanguageConfig, cron string, onTrigger func([]*blog.Page) 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]*blog.Page, len(availableLanguages))
+ for _, lang := range availableLanguages {
+ knownBlogPages[lang.Name] = make(map[string]*blog.Page)
+ }
+
+ bts := &BlogTriggerScheduler{s, knownBlogPages, blogClient, 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 []*blog.Page, err error) {
+ newPages = make([]*blog.Page, 0)
+ for lang := range bts.knownBlogPages {
+ posts, err := bts.blogClient.Scan(lang + "/")
+ if err != nil {
+ return nil, fmt.Errorf("failed to scan blog pages in b2 on '%s': %w", lang, err)
+ }
+ for _, post := range posts {
+ if _, ok := bts.knownBlogPages[lang][post.FileName]; !ok {
+ post.Lang = lang
+ newPages = append(newPages, post)
+ bts.knownBlogPages[lang][post.FileName] = post
+ }
+ }
+ }
+ return newPages, nil
+}
diff --git a/internal/factgiver/factgiver.go b/internal/factgiver/factgiver.go
index 6f77403..c5b8bc4 100644
--- a/internal/factgiver/factgiver.go
+++ b/internal/factgiver/factgiver.go
@@ -8,26 +8,26 @@ import (
"time"
"github.com/SayaAndy/saya-today-web/config"
- "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/internal/blog"
)
type FactGiver struct {
- b2Client *b2.B2Client
+ blogClient blog.Client
cache map[string][]string
- langs []string
+ langs []config.AvailableLanguageConfig
factsFileName string
nlRe *regexp.Regexp
randGen *rand.Rand
}
-func NewFactGiver(cfg *config.FactGiverConfig, langs []string) (*FactGiver, error) {
- b2Client, err := b2.NewB2Client(&cfg.Storage.Config)
+func NewFactGiver(cfg *config.FactGiverConfig, langs []config.AvailableLanguageConfig) (*FactGiver, error) {
+ blogClient, err := blog.NewClientMap[cfg.Storage.Type](&cfg.Storage)
if err != nil {
return nil, fmt.Errorf("fail to init b2 client for a new fact giver: %s", err.Error())
}
factGiver := &FactGiver{
- b2Client: b2Client,
+ blogClient: blogClient,
cache: make(map[string][]string, len(langs)),
langs: langs,
factsFileName: cfg.FactsFileName,
@@ -52,15 +52,15 @@ func (g *FactGiver) Give(lang string) [3]string {
func (g *FactGiver) initCache() error {
for _, lang := range g.langs {
- localFacts := strings.Replace(g.factsFileName, "*", lang, 1)
- factsContentBytes, err := g.b2Client.ReadAll(localFacts)
+ localFacts := strings.Replace(g.factsFileName, "*", lang.Name, 1)
+ factsContentBytes, err := g.blogClient.ReadAll(localFacts)
if err != nil {
- return fmt.Errorf("fail to read '%s' facts file: %s", lang, err.Error())
+ return fmt.Errorf("fail to read '%s' facts file: %s", lang.Name, err.Error())
}
factsContent := string(factsContentBytes)
- g.cache[lang] = g.nlRe.Split(factsContent, -1)
- if g.cache[lang][len(g.cache[lang])-1] == "" {
- g.cache[lang] = g.cache[lang][:len(g.cache[lang])-1]
+ g.cache[lang.Name] = g.nlRe.Split(factsContent, -1)
+ if g.cache[lang.Name][len(g.cache[lang.Name])-1] == "" {
+ g.cache[lang.Name] = g.cache[lang.Name][:len(g.cache[lang.Name])-1]
}
}
return nil
diff --git a/internal/frontmatter/parser.go b/internal/frontmatter/parser.go
index 83312dc..942760c 100644
--- a/internal/frontmatter/parser.go
+++ b/internal/frontmatter/parser.go
@@ -1,35 +1,38 @@
package frontmatter
import (
+ "bytes"
"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"`
+ 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"`
+ ContentSettings map[string]string `yaml:"contentSettings"`
}
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 !bytes.HasPrefix(content, []byte("---\n")) {
+ return nil, content, nil
+ }
- if len(matches) != 3 {
+ end := bytes.Index(content[4:], []byte("\n---\n"))
+ if end == -1 {
return nil, content, nil
}
- yamlContent := matches[1]
- markdownContent := matches[2]
+ yamlContent := content[4 : end+4]
+ markdownContent := content[end+9:]
metadata = &Metadata{}
if err := yaml.Unmarshal([]byte(yamlContent), &metadata); err != nil {
diff --git a/internal/glightbox/block.go b/internal/glightbox/block.go
new file mode 100644
index 0000000..30cda1c
--- /dev/null
+++ b/internal/glightbox/block.go
@@ -0,0 +1,33 @@
+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
+ Namespace string
+}
+
+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
new file mode 100644
index 0000000..e9a9f3e
--- /dev/null
+++ b/internal/glightbox/extension.go
@@ -0,0 +1,31 @@
+package glightbox
+
+import (
+ "github.com/SayaAndy/saya-today-web/config"
+ "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 {
+ photoStorage config.PhotoStorageConfig
+}
+
+func NewGLightboxExtension(photoStorage config.PhotoStorageConfig) goldmark.Extender {
+ return &GLightboxExtension{photoStorage}
+}
+
+func (e *GLightboxExtension) Extend(m goldmark.Markdown) {
+ m.Parser().AddOptions(
+ parser.WithBlockParsers(
+ util.Prioritized(NewGLightboxParser(), 500),
+ ),
+ )
+ m.Renderer().AddOptions(
+ renderer.WithNodeRenderers(
+ util.Prioritized(NewGLightboxHTMLRenderer(e.photoStorage), 500),
+ ),
+ )
+}
diff --git a/internal/glightbox/html_renderer.go b/internal/glightbox/html_renderer.go
new file mode 100644
index 0000000..1bb606b
--- /dev/null
+++ b/internal/glightbox/html_renderer.go
@@ -0,0 +1,202 @@
+package glightbox
+
+import (
+ "bytes"
+ "fmt"
+ "math/rand"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "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
+ photoStorage config.PhotoStorageConfig
+}
+
+func NewGLightboxHTMLRenderer(photoStorage config.PhotoStorageConfig, 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),
+ ),
+ ),
+ ),
+ ),
+ anchorMatchRe: regexp.MustCompile(`(?s)<\s*a(\s+[^<]*)(href\s*=\s*["'].*?["'])\s*([^<]*)>(.*?)<\s*\/\s*a\s*>`),
+ photoStorage: photoStorage,
+ }
+ for _, opt := range opts {
+ opt.SetHTMLOption(&r.Config)
+ }
+ 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 {
+ fullImageUrl := gallery.Namespace + img.URL
+ imageUrlSegments := strings.Split(fullImageUrl, ".")
+ 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="%[7]s" class="glightbox grid-item %[1]s grid-item-%[2]s p-1"
+ data-gallery="gallery" data-title="%[3]s" %[4]s>
+ <picture>
+ <source media="(width < 800px)" srcset="%[8]s" />
+ <source media="(width < 2400px)" srcset="%[9]s" />
+ <source media="(width < 3200px)" srcset="%[10]s" />
+ <source media="(width < 4000px)" srcset="%[11]s" />
+ <source media="(width >= 4000px)" srcset="%[12]s" />
+ <img src="%[9]s" />
+ </picture>
+ <span class="grid-tooltip-text"><p>%[5]s</p></span>
+ <span class="grid-item-index">%[6]d</span>
+ </a>`,
+ strings.Join(tagClassList, " "),
+ galleryID,
+ dayDate.Format("2006-01-02 15:04:05 -07:00"),
+ dataDescriptionAttribute,
+ anchorlessCaptionHTML,
+ i+1,
+ fmt.Sprintf(r.photoStorage.Full.BaseUrl, fullImageUrl),
+ fmt.Sprintf(r.photoStorage.Thumbnail1600p.BaseUrl, imageUrlWithoutExt),
+ fmt.Sprintf(r.photoStorage.Thumbnail1200p.BaseUrl, imageUrlWithoutExt),
+ fmt.Sprintf(r.photoStorage.Thumbnail800p.BaseUrl, imageUrlWithoutExt),
+ fmt.Sprintf(r.photoStorage.Thumbnail560p.BaseUrl, imageUrlWithoutExt),
+ fmt.Sprintf(r.photoStorage.Thumbnail320p.BaseUrl, imageUrlWithoutExt),
+ ))
+
+ if glightboxDescId != "" {
+ elements = append(elements, fmt.Sprintf(`
+ <div class="glightbox-desc rounded-4 %s">
+ <p>%s</p>
+ </div>`, glightboxDescId, captionHTML))
+ }
+ }
+
+ w.WriteString(strings.ReplaceAll(`
+<div class="items-center flex flex-col">
+ <hr class="border-t-[0.375rem] border-dotted border-main-hard my-2 w-24 ml-auto mr-auto">
+ <div class="grid masonry-grid-{id}">
+ <div class="grid-sizer grid-sizer-{id}"></div>
+ `+strings.Join(elements, "\n")+`
+ </div>
+ <hr class="border-t-[0.375rem] border-dotted border-main-hard my-2 w-24 ml-auto mr-auto">
+</div>
+
+<script>
+ var pckry_{id} = new Packery('.masonry-grid-{id}', {
+ itemSelector: '.grid-item-{id}',
+ columnWidth: '.grid-sizer-{id}',
+ percentPosition: false
+ });
+
+ var imgLoad_{id}_timer;
+ var imgLoad_{id} = imagesLoaded('.masonry-grid-{id}');
+
+ function initMasonryLayout_{id}(tm) {
+ clearTimeout(imgLoad_{id}_timer);
+ imgLoad_{id}_timer = setTimeout(() => pckry_{id}.layout(), 100);
+ }
+
+ imgLoad_{id}.on('progress', initMasonryLayout_{id});
+
+ window.addEventListener('resize', initMasonryLayout_{id});
+
+ document.addEventListener('popout', (e) => {
+ window.removeEventListener('resize', initMasonryLayout_{id});
+ imgLoad_{id}.off('progress', initMasonryLayout_{id});
+ initMasonryLayout_{id} = null;
+ imgLoad_{id} = null;
+ }, {once: true});
+</script>
+`, "{id}", galleryID))
+ }
+
+ return ast.WalkContinue, nil
+}
+
+func generateDivId(length int) string {
+ const charset = "abcdefghijklmnopqrstuvwxyz"
+ seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) // Seed with current time
+ b := make([]byte, length)
+ for i := range b {
+ b[i] = charset[seededRand.Intn(len(charset))]
+ }
+ return string(b)
+}
diff --git a/internal/lightgallery/parser.go b/internal/glightbox/parser.go
index daec797..b376034 100644
--- a/internal/lightgallery/parser.go
+++ b/internal/glightbox/parser.go
@@ -1,9 +1,10 @@
-package lightgallery
+package glightbox
import (
"bytes"
"log/slog"
"regexp"
+ "strings"
"time"
"github.com/yuin/goldmark/ast"
@@ -11,24 +12,24 @@ import (
"github.com/yuin/goldmark/text"
)
-type LightGalleryParser struct{}
+type GLightboxParser struct{}
-func NewLightGalleryParser() parser.BlockParser {
- return &LightGalleryParser{}
+func NewGLightboxParser() parser.BlockParser {
+ return &GLightboxParser{}
}
-func (p *LightGalleryParser) Trigger() []byte {
+func (p *GLightboxParser) Trigger() []byte {
return []byte{'{'}
}
-func (p *LightGalleryParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
+func (p *GLightboxParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
line, _ := reader.PeekLine()
if !bytes.HasPrefix(line, []byte("{Gallery:")) {
return nil, parser.NoChildren
}
- r := regexp.MustCompile(`^\{Gallery:([A-Za-z0-9\+\-/]+)\}$`)
+ r := regexp.MustCompile(`^\{Gallery:([A-Za-z0-9\+\-/]+)(:([A-Za-z0-9\+\-/]+))?\}$`)
trimmed := bytes.TrimSpace(line)
parts := r.FindSubmatch(trimmed)
@@ -43,10 +44,15 @@ func (p *LightGalleryParser) Open(parent ast.Node, reader text.Reader, pc parser
return nil, parser.NoChildren
}
- return &LightGalleryBlock{Location: loc}, parser.NoChildren
+ ns := ""
+ if len(parts) >= 4 {
+ ns = string(parts[3])
+ }
+
+ return &GLightboxBlock{Location: loc, Namespace: ns}, parser.NoChildren
}
-func (p *LightGalleryParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State {
+func (p *GLightboxParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State {
line, segment := reader.PeekLine()
if len(line) == 0 || segment.Len() == 0 {
return parser.Continue | parser.NoChildren
@@ -58,31 +64,42 @@ func (p *LightGalleryParser) Continue(node ast.Node, reader text.Reader, pc pars
return parser.Close
}
- gallery := node.(*LightGalleryBlock)
+ gallery := node.(*GLightboxBlock)
- parts := bytes.SplitN(trimmed, []byte{'|'}, 2)
+ parts := bytes.SplitN(trimmed, []byte{'|'}, 3)
url := bytes.TrimSpace(parts[0])
caption := make([]byte, 0)
- if len(parts) > 1 {
+ tagsRaw := ""
+ if len(parts) == 2 {
caption = bytes.TrimSpace(parts[1])
}
+ if len(parts) >= 3 {
+ tagsRaw = string(parts[1])
+ caption = bytes.TrimSpace(parts[2])
+ }
+
+ tags := strings.Split(tagsRaw, ",")
+ for i := range tags {
+ tags[i] = strings.TrimSpace(tags[i])
+ }
- gallery.Images = append(gallery.Images, LightGalleryImage{
+ gallery.Images = append(gallery.Images, GLightboxImage{
URL: string(url),
+ Tags: tags,
Caption: caption,
})
return parser.Continue | parser.NoChildren
}
-func (p *LightGalleryParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {
+func (p *GLightboxParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {
}
-func (p *LightGalleryParser) CanInterruptParagraph() bool {
+func (p *GLightboxParser) CanInterruptParagraph() bool {
return true
}
-func (p *LightGalleryParser) CanAcceptIndentedLine() bool {
+func (p *GLightboxParser) CanAcceptIndentedLine() bool {
return false
}
diff --git a/internal/lightgallery/block.go b/internal/lightgallery/block.go
deleted file mode 100644
index 8eb7c9e..0000000
--- a/internal/lightgallery/block.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package lightgallery
-
-import (
- "time"
-
- "github.com/yuin/goldmark/ast"
-)
-
-// LightGalleryBlock represents a light gallery block in the AST
-type LightGalleryBlock struct {
- ast.BaseBlock
- Images []LightGalleryImage
- Location *time.Location
-}
-
-type LightGalleryImage struct {
- URL string
- Caption []byte
-}
-
-var KindLightGalleryBlock = ast.NewNodeKind("LightGalleryBlock")
-
-// Dump implements ast.Node.Dump
-func (n *LightGalleryBlock) Dump(source []byte, level int) {
- ast.DumpHelper(n, source, level, nil, nil)
-}
-
-// Kind implements ast.Node.Kind
-func (n *LightGalleryBlock) Kind() ast.NodeKind {
- return KindLightGalleryBlock
-}
diff --git a/internal/lightgallery/extension.go b/internal/lightgallery/extension.go
deleted file mode 100644
index 761dc05..0000000
--- a/internal/lightgallery/extension.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package lightgallery
-
-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 LightGalleryExtension struct{}
-
-func NewLightGalleryExtension() goldmark.Extender {
- return &LightGalleryExtension{}
-}
-
-func (e *LightGalleryExtension) Extend(m goldmark.Markdown) {
- m.Parser().AddOptions(
- parser.WithBlockParsers(
- util.Prioritized(NewLightGalleryParser(), 500),
- ),
- )
- m.Renderer().AddOptions(
- renderer.WithNodeRenderers(
- util.Prioritized(NewLightGalleryHTMLRenderer(), 500),
- ),
- )
-}
diff --git a/internal/lightgallery/html_renderer.go b/internal/lightgallery/html_renderer.go
deleted file mode 100644
index f02b953..0000000
--- a/internal/lightgallery/html_renderer.go
+++ /dev/null
@@ -1,145 +0,0 @@
-package lightgallery
-
-import (
- "bytes"
- "fmt"
- "math/rand"
- "strings"
- "time"
-
- "github.com/yuin/goldmark"
- "github.com/yuin/goldmark/ast"
- "github.com/yuin/goldmark/renderer"
- "github.com/yuin/goldmark/renderer/html"
- "github.com/yuin/goldmark/util"
-)
-
-type LightGalleryHTMLRenderer struct {
- html.Config
- md goldmark.Markdown
-}
-
-func NewLightGalleryHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
- r := &LightGalleryHTMLRenderer{
- Config: html.NewConfig(),
- md: goldmark.New(
- goldmark.WithRendererOptions(
- html.WithHardWraps(),
- html.WithXHTML(),
- ),
- ),
- }
- for _, opt := range opts {
- opt.SetHTMLOption(&r.Config)
- }
- return r
-}
-
-func (r *LightGalleryHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
- reg.Register(KindLightGalleryBlock, r.renderLightGallery)
-}
-
-func (r *LightGalleryHTMLRenderer) renderLightGallery(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
- if entering {
- gallery := n.(*LightGalleryBlock)
-
- if len(gallery.Images) == 0 {
- return ast.WalkContinue, nil
- }
-
- galleryID := generateDivId(8)
-
- w.WriteString(fmt.Sprintf(`
-<div class="justify-content-center display-block m-2">
- <hr class="border-t-4 border-dotted border-main-dark mt-2 mb-2 w-[80%%] ml-auto mr-auto">
- <div id="lg-%s" class="inline-gallery-container relative ml-auto mr-auto"></div>
- <hr class="border-t-4 border-dotted border-main-dark mt-2 mb-2 w-[80%%] ml-auto mr-auto">
-</div>`, galleryID))
-
- var dynamicElements []string
- for _, img := range gallery.Images {
- imageUrlSegments := strings.Split(img.URL, ".")
- imageUrlWithoutExt := strings.Join(imageUrlSegments[:len(imageUrlSegments)-1], ".")
- 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)
- dynamicElements = append(dynamicElements, fmt.Sprintf(`{
- src:
- "https://f003.backblazeb2.com/file/sayana-photos/full/%s",
- downloadUrl:
- "https://f003.backblazeb2.com/file/sayana-photos/full/%s",
- alt: "%s",
- responsive: "https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp 384, https://f003.backblazeb2.com/file/sayana-photos/webp-560p/%s.webp 672, https://f003.backblazeb2.com/file/sayana-photos/webp-800p/%s.webp 960, https://f003.backblazeb2.com/file/sayana-photos/webp-1200p/%s.webp 1440, https://f003.backblazeb2.com/file/sayana-photos/webp-1600p/%s.webp 1920",
- thumb:
- "https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp",
- subHtml: `+"`"+`<div class="flex flex-row light-gallery-captions">
- <p class="grow !text-base/[1] text-left font-spectral text-main-dark">%s</p>
- <p class="!text-base/[1] text-right font-spectral text-secondary">%s</p>
- </div>`+"`"+`
- }`, img.URL, img.URL, util.EscapeHTML(captionHTML), imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, captionHTML, dayDate.Format("2006-01-02 15:04:05 -07:00")))
- }
-
- w.WriteString(fmt.Sprintf(`
-<script>
-function createLightGallery%s() {
- const $lgContainer = document.getElementById('lg-%s');
- const config = {
- container: $lgContainer,
- dynamic: true,
- dynamicEl: [%s],
- width: "100%%",
- height: "50vmin",
- hash: false,
- closable: false,
- showMaximizeIcon: true,
- appendSubHtmlTo: ".lg-sub-html",
- isMobile: () => false,
- slideDelay: 0,
- plugins: [lgZoom, lgThumbnail],
- thumbWidth: calculateVmin(10),
- thumbHeight: "10vmin",
- thumbMargin: 4
- };
- const inlineGallery = lightGallery($lgContainer, config);
-
- setTimeout(() => {
- inlineGallery.openGallery();
- }, 200);
-
- galleryMap.set(inlineGallery, createLightGallery%s);
-}
-
-document.addEventListener('htmx:afterRequest', (e) => {
- if (e.detail.xhr.status == 404 || e.detail.successful != true) {
- return console.error(e);
- }
- if (e.detail.target.id == 'general-page-body') {
- createLightGallery%s();
- }
-}, {once: true});
-</script>`, galleryID, galleryID, strings.Join(dynamicElements, ","), galleryID, galleryID))
- }
-
- return ast.WalkContinue, nil
-}
-
-func generateDivId(length int) string {
- const charset = "abcdefghijklmnopqrstuvwxyz"
- seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) // Seed with current time
- b := make([]byte, length)
- for i := range b {
- b[i] = charset[seededRand.Intn(len(charset))]
- }
- return string(b)
-}
diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go
new file mode 100644
index 0000000..33db4f7
--- /dev/null
+++ b/internal/mailer/mailer.go
@@ -0,0 +1,540 @@
+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/blog"
+ "github.com/SayaAndy/saya-today-web/internal/templatemanager"
+ "github.com/SayaAndy/saya-today-web/l10n"
+ "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
+}
+
+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) (*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),
+ }, 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(l10n.T.GetPath(lang, "Mail", "VerifyEmail", "Subject").(string))
+
+ msg, err := m.tm.Render("verify-email", fiber.Map{
+ "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 *blog.Page) 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(l10n.T.GetPath(post.Lang, "Mail", "UnsubscribeFooter").(string), "{}", 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{
+ "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(l10n.T.GetPath(post.Lang, "Mail", "NewPost", "Subject").(string))
+ message.SetBodyString(mail.TypeTextHTML, string(msgBody))
+
+ m.unsubscribeCodes.Set(unsubscribeCode, user.userId, 40)
+
+ messages = append(messages, message)
+ }
+
+ if err := m.mailClient.DialAndSend(messages...); err != nil {
+ return fmt.Errorf("failed to send new post notifications: %w", err)
+ }
+ return nil
+}
diff --git a/internal/router/api-v1-blog-search.go b/internal/router/api-v1-blog-search.go
deleted file mode 100644
index 69be651..0000000
--- a/internal/router/api-v1-blog-search.go
+++ /dev/null
@@ -1,123 +0,0 @@
-package router
-
-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/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- tm.Add("catalogue-blog-cards", "views/partials/catalogue-blog-cards.html", "views/partials/catalogue-blog-card-tags.html")
-}
-
-func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- sort := c.Query("sort")
- lang := c.Query("lang")
- tz := c.Query("tz")
-
- if !slices.Contains(langs, lang) {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang))
- }
-
- loc, err := time.LoadLocation(tz)
- if err != nil {
- loc = time.UTC
- slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz))
- }
-
- cacheKey := "blog-search." + lang + ".pages-list"
- var pages []*b2.BlogPage
- if pagesBytes, ok := PCache.Get(cacheKey); pagesBytes != nil || ok {
- json.Unmarshal(pagesBytes, &pages)
- } else {
- pages, err = b2Client.Scan(lang + "/")
- if err != nil {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrInternalServerError.Code).SendString(fmt.Sprintf("failed to scan pages for '%s' lang: %v", lang, err))
- }
- pagesBytes, _ := json.Marshal(pages)
- PCache.SetWithTTL(cacheKey, pagesBytes, int64(len(pagesBytes)), 5*time.Minute)
- }
-
- encodedQuery := c.Request().URI().QueryString()
- re, err := regexp.Compile(`tags\[\]=([\w]+)`)
- if err != nil {
- slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error()))
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering")
- }
- decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
- matches := re.FindAllStringSubmatch(decodedQuery, -1)
-
- tags := make([]string, 0, len(matches))
- for _, match := range matches {
- tags = append(tags, string(match[1]))
- }
-
- pageMeta := make([]fiber.Map, 0, len(pages))
- for _, page := range pages {
- for _, tag := range page.Metadata.Tags {
- if len(tags) == 0 || slices.Contains(tags, tag) {
- pageMeta = append(pageMeta, fiber.Map{
- "Link": page.Link,
- "ArticleLink": "/" + lang + "/blog/" + page.FileName,
- "Title": page.Metadata.Title,
- "PublishedTime": page.Metadata.PublishedTime.In(loc).Format("2006-01-02 15:04:05 -07:00"),
- "ActionDate": page.Metadata.ActionDate,
- "ShortDescription": page.Metadata.ShortDescription,
- "Thumbnail": page.Metadata.Thumbnail,
- "Tags": page.Metadata.Tags,
- "LikeCount": CCache.GetLikeCount(page.FileName),
- "ViewCount": CCache.GetViewCount(page.FileName),
- })
- break
- }
- }
- }
-
- slices.SortFunc(pageMeta, func(a, b fiber.Map) int {
- switch sort {
- case "titleAsc":
- return strings.Compare(a["Title"].(string), b["Title"].(string))
- case "titleDesc":
- return strings.Compare(b["Title"].(string), a["Title"].(string))
- case "actionDateAsc":
- return strings.Compare(a["ActionDate"].(string), b["ActionDate"].(string))
- case "actionDateDesc":
- return strings.Compare(b["ActionDate"].(string), a["ActionDate"].(string))
- case "publicationDateAsc":
- publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string))
- publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string))
- return publishedTimeA.Compare(publishedTimeB)
- case "publicationDateDesc":
- publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string))
- publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string))
- return publishedTimeB.Compare(publishedTimeA)
- }
- return 0
- })
-
- content, err := tm.Render("catalogue-blog-cards", fiber.Map{
- "BlogPages": pageMeta,
- "L": l[lang],
- })
- if err != nil {
- slog.Warn("failed to generate div", slog.String("page", "/"+lang+"/blog"), slog.String("error", err.Error()))
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
-
- return c.Type("html").Send(content)
- }
-}
diff --git a/internal/router/api-v1-general-page-body.go b/internal/router/api-v1-general-page-body.go
deleted file mode 100644
index b2fa268..0000000
--- a/internal/router/api-v1-general-page-body.go
+++ /dev/null
@@ -1,162 +0,0 @@
-package router
-
-import (
- "fmt"
- "html/template"
- "log/slog"
- "math/rand"
- "net/url"
- "regexp"
- "slices"
- "strings"
- "time"
-
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/internal/factgiver"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
- "github.com/yuin/goldmark"
-)
-
-var FactGiver *factgiver.FactGiver
-
-func init() {
- tm.Add("general-page-body", "views/partials/general-page-body.html")
-}
-
-func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client, md goldmark.Markdown) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- referer := c.Get("Referer", "")
- if referer == "" {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
- }
- urlStruct, err := url.ParseRequestURI(referer)
- if err != nil {
- return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
- }
-
- path := urlStruct.EscapedPath()
-
- cacheKey := fmt.Sprintf("body.%s", path)
- if val, ok := PCache.Get(cacheKey); val != nil && ok {
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(val)
- }
-
- pathParts := strings.Split(strings.Trim(path, "/"), "/")
- if len(pathParts) == 0 {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
- }
-
- lang := pathParts[0]
- if !slices.Contains(langs, lang) {
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
- }
-
- values := fiber.Map{
- "L": l[lang],
- "Lang": lang,
- "QueryString": string(c.Request().URI().QueryString()),
- }
- var additionalTemplates []string
-
- if len(pathParts) == 2 && pathParts[1] == "blog" {
- querySort := c.Query("sort")
- if querySort == "" {
- querySort = "publicationDateDesc"
- }
-
- encodedQuery := c.Request().URI().QueryString()
- re, err := regexp.Compile(`tags\[\]=([\w]+)`)
- if err != nil {
- slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering")
- }
- decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
- matches := re.FindAllStringSubmatch(decodedQuery, -1)
-
- queryTags := make([]string, 0, len(matches))
- for _, match := range matches {
- queryTags = append(queryTags, string(match[1]))
- }
-
- pages, err := b2Client.Scan(lang + "/")
- if err != nil {
- slog.Warn("failed to scan pages via b2", slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString(fmt.Sprintf("failed to scan pages via b2: %s", slog.String("error", err.Error())))
- }
-
- tagsMap := make(map[string]int)
- for _, page := range pages {
- for _, tag := range page.Metadata.Tags {
- tagsMap[tag]++
- }
- }
- slog.Debug("enlist pages for catalogue", slog.Int("tag_count", len(tagsMap)), slog.Int("page_count", len(pages)), slog.String("path", c.Path()))
-
- type Tag struct {
- Name string `json:"Name" yaml:"name"`
- Count int `json:"Count" yaml:"count"`
- }
-
- tagsArray := make([]Tag, 0, len(tagsMap))
- for tag, count := range tagsMap {
- tagsArray = append(tagsArray, Tag{tag, count})
- }
- slices.SortFunc(tagsArray, func(a Tag, b Tag) int {
- return strings.Compare(a.Name, b.Name)
- })
-
- values["Tags"] = tagsArray
- values["QuerySort"] = querySort
- values["QueryTags"] = strings.Join(queryTags, ",")
- values["Title"] = l[lang].BlogSearch.Header
-
- additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
- } else if len(pathParts) == 3 && pathParts[1] == "blog" {
- metadata, parsedMarkdown, err := readBlogPost(md, b2Client, lang+"/"+pathParts[2])
- if err != nil {
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("failed to find '%s' post", pathParts[2]))
- }
-
- geolocationParts := strings.Split(metadata.Geolocation, " ")
- var x, y, areaError string
- if len(geolocationParts) >= 2 {
- x = geolocationParts[0]
- y = geolocationParts[1]
- }
- if len(geolocationParts) >= 3 {
- areaError = geolocationParts[2]
- }
-
- values["MapLocationX"] = x
- values["MapLocationY"] = y
- values["MapLocationAreaMeters"] = areaError
- values["Title"] = metadata.Title
- values["ParsedMarkdown"] = template.HTML(parsedMarkdown)
-
- additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
-
- go CCache.View(c.IP(), pathParts[2])
- } else if len(pathParts) == 1 {
- values["Title"] = l[lang].HomePage.Header
- values["FilledHeartCount"] = uint(40)
- values["OutlineHeartCount"] = uint(40)
- values["GifName"] = fmt.Sprintf("otter-%d.gif", rand.Int()%3+1)
- values["FunFacts"] = FactGiver.Give(lang)
- additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
- }
-
- content, err := tm.Render("general-page-body", values, additionalTemplates...)
- if err != nil {
- slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
-
- go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(content)
- }
-}
diff --git a/internal/router/api-v1-general-page-bottom-embeds.go b/internal/router/api-v1-general-page-bottom-embeds.go
deleted file mode 100644
index c4d9543..0000000
--- a/internal/router/api-v1-general-page-bottom-embeds.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package router
-
-import (
- "fmt"
- "log/slog"
- "net/url"
- "slices"
- "strings"
- "time"
-
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- tm.Add("general-page-bottom-embeds", "views/partials/general-page-bottom-embeds.html")
-}
-
-func Api_V1_GeneralPage_BottomEmbeds(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- referer := c.Get("Referer", "")
- if referer == "" {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
- }
- urlStruct, err := url.ParseRequestURI(referer)
- if err != nil {
- return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
- }
-
- path := urlStruct.EscapedPath()
- cacheKey := fmt.Sprintf("bottom-embeds.%s", path)
- if val, ok := PCache.Get(cacheKey); val != nil && ok {
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(val)
- }
-
- pathParts := strings.Split(strings.Trim(path, "/"), "/")
- if len(pathParts) == 0 {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
- }
-
- lang := pathParts[0]
- if !slices.Contains(langs, lang) {
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
- }
-
- values := fiber.Map{
- "L": l[lang],
- "Lang": lang,
- "QueryString": string(c.Request().URI().QueryString()),
- }
- var additionalTemplates []string
-
- if len(pathParts) == 2 && pathParts[1] == "blog" {
- additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
- } else if len(pathParts) == 3 && pathParts[1] == "blog" {
- additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
- } else if len(pathParts) == 1 {
- additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
- }
-
- content, err := tm.Render("general-page-bottom-embeds", values, additionalTemplates...)
- if err != nil {
- slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
-
- go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(content)
- }
-}
diff --git a/internal/router/api-v1-general-page-footer.go b/internal/router/api-v1-general-page-footer.go
deleted file mode 100644
index 003f54c..0000000
--- a/internal/router/api-v1-general-page-footer.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package router
-
-import (
- "fmt"
- "log/slog"
- "net/url"
- "slices"
- "strings"
- "time"
-
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- tm.Add("general-page-footer", "views/partials/general-page-footer.html")
-}
-
-func Api_V1_GeneralPage_Footer(l map[string]*locale.LocaleConfig, langs []string) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- referer := c.Get("Referer", "")
- if referer == "" {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
- }
- urlStruct, err := url.ParseRequestURI(referer)
- if err != nil {
- return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
- }
-
- path := urlStruct.EscapedPath()
-
- cacheKey := fmt.Sprintf("footer.%s", path)
- if val, ok := PCache.Get(cacheKey); val != nil && ok {
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(val)
- }
-
- pathParts := strings.Split(strings.Trim(path, "/"), "/")
- if len(pathParts) == 0 {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
- }
-
- lang := pathParts[0]
- if !slices.Contains(langs, lang) {
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
- }
-
- values := fiber.Map{
- "L": l[lang],
- "Lang": lang,
- "QueryString": string(c.Request().URI().QueryString()),
- }
- var additionalTemplates []string
-
- if len(pathParts) == 2 && pathParts[1] == "blog" {
- additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
- } else if len(pathParts) == 3 && pathParts[1] == "blog" {
- additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
- } else if len(pathParts) == 1 {
- additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
- }
-
- content, err := tm.Render("general-page-footer", values, additionalTemplates...)
- if err != nil {
- slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
-
- go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(content)
- }
-}
diff --git a/internal/router/api-v1-general-page-header.go b/internal/router/api-v1-general-page-header.go
deleted file mode 100644
index 80228f7..0000000
--- a/internal/router/api-v1-general-page-header.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package router
-
-import (
- "fmt"
- "log/slog"
- "net/url"
- "slices"
- "strings"
- "time"
-
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- tm.Add("general-page-header", "views/partials/general-page-header.html")
-}
-
-func Api_V1_GeneralPage_Header(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- referer := c.Get("Referer", "")
- if referer == "" {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
- }
- urlStruct, err := url.ParseRequestURI(referer)
- if err != nil {
- return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
- }
-
- path := urlStruct.EscapedPath()
-
- cacheKey := fmt.Sprintf("header.%s", path)
- if val, ok := PCache.Get(cacheKey); val != nil && ok {
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(val)
- }
-
- pathParts := strings.Split(strings.Trim(path, "/"), "/")
- if len(pathParts) == 0 {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
- }
-
- lang := pathParts[0]
- if !slices.Contains(langs, lang) {
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
- }
-
- values := fiber.Map{
- "L": l[lang],
- "Lang": lang,
- "QueryString": string(c.Request().URI().QueryString()),
- }
- var additionalTemplates []string
-
- if len(pathParts) == 2 && pathParts[1] == "blog" {
- values["Title"] = l[lang].BlogSearch.Header
- additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
- } else if len(pathParts) == 3 && pathParts[1] == "blog" {
- metadata, _, err := b2Client.ReadFrontmatter(lang + "/" + pathParts[2] + ".md")
- if err != nil {
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("could not read '%s' for content", path))
- }
- values["Title"] = metadata.Title
- values["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00")
- values["ActionDate"] = metadata.ActionDate
- additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
- } else if len(pathParts) == 1 {
- values["Title"] = l[lang].HomePage.Header
- additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
- }
-
- content, err := tm.Render("general-page-header", values, additionalTemplates...)
- if err != nil {
- slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
-
- go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(content)
- }
-}
diff --git a/internal/router/api-v1-general-page-top-embeds.go b/internal/router/api-v1-general-page-top-embeds.go
deleted file mode 100644
index 78021b2..0000000
--- a/internal/router/api-v1-general-page-top-embeds.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package router
-
-import (
- "fmt"
- "log/slog"
- "net/url"
- "slices"
- "strings"
- "time"
-
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- tm.Add("general-page-top-embeds", "views/partials/general-page-top-embeds.html")
-}
-
-func Api_V1_GeneralPage_TopEmbeds(l map[string]*locale.LocaleConfig, langs []string) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- referer := c.Get("Referer", "")
- if referer == "" {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
- }
- urlStruct, err := url.ParseRequestURI(referer)
- if err != nil {
- return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
- }
-
- path := urlStruct.EscapedPath()
-
- cacheKey := fmt.Sprintf("top-embeds.%s", path)
- if val, ok := PCache.Get(cacheKey); val != nil && ok {
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(val)
- }
-
- pathParts := strings.Split(strings.Trim(path, "/"), "/")
- if len(pathParts) == 0 {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
- }
-
- lang := pathParts[0]
- if !slices.Contains(langs, lang) {
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
- }
-
- values := fiber.Map{
- "L": l[lang],
- "Lang": lang,
- "QueryString": string(c.Request().URI().QueryString()),
- }
- var additionalTemplates []string
-
- if len(pathParts) == 2 && pathParts[1] == "blog" {
- additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
- } else if len(pathParts) == 3 && pathParts[1] == "blog" {
- additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
- } else if len(pathParts) == 1 {
- additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
- }
-
- content, err := tm.Render("general-page-top-embeds", values, additionalTemplates...)
- if err != nil {
- slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
-
- go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(content)
- }
-}
diff --git a/internal/router/api-v1-general-page.go b/internal/router/api-v1-general-page.go
deleted file mode 100644
index d0ea559..0000000
--- a/internal/router/api-v1-general-page.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package router
-
-import (
- "fmt"
- "log/slog"
- "slices"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- tm.Add("general-page", "views/layouts/general-page.html")
-}
-
-func Api_V1_GeneralPage(l map[string]*locale.LocaleConfig, langs []string) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- path := c.Path()
-
- pathParts := strings.Split(strings.Trim(path, "/"), "/")
- if len(pathParts) == 0 {
- return c.Status(fiber.ErrBadRequest.Code).SendString("url path is invalid: expect format '/{lang}/...'")
- }
-
- lang := pathParts[0]
- if !slices.Contains(langs, lang) {
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
- }
-
- cacheKey := "general-page." + lang
- if val, ok := PCache.Get(cacheKey); val != nil && ok {
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(val)
- }
-
- content, err := tm.Render("general-page", fiber.Map{
- "L": l[lang],
- "Lang": lang,
- "QueryString": string(c.Request().URI().QueryString()),
- })
- if err != nil {
- slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
-
- go PCache.Set(cacheKey, content, int64(len(content)))
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(content)
- }
-}
diff --git a/internal/router/api-v1-like.go b/internal/router/api-v1-like.go
deleted file mode 100644
index 327dafa..0000000
--- a/internal/router/api-v1-like.go
+++ /dev/null
@@ -1,122 +0,0 @@
-package router
-
-import (
- "fmt"
- "log/slog"
- "net/url"
- "strconv"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- tm.Add("blog-page-like-button", "views/partials/blog-page-like-button.html")
-}
-
-func Api_V1_Like_Put(l map[string]*locale.LocaleConfig, b2 *b2.B2Client) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- referer := c.Get("Referer", "")
- if referer == "" {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
- }
- urlStruct, err := url.ParseRequestURI(referer)
- if err != nil {
- return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
- }
-
- path := urlStruct.EscapedPath()
- pathParts := strings.Split(strings.Trim(path, "/"), "/")
- if len(pathParts) != 3 {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/blog/{page}'")
- }
-
- lang, page := pathParts[0], pathParts[2]
-
- pageLink := lang + "/" + page + ".md"
- if pages, _ := b2.Scan(pageLink); len(pages) == 0 {
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server did not find '%s' article", pageLink))
- }
-
- newLikeStatus, err := strconv.ParseBool(c.FormValue("like", "true"))
- if err != nil {
- return c.Status(fiber.ErrBadRequest.Code).SendString("invalid 'like' value")
- }
-
- ip := c.IP()
- if newLikeStatus {
- CCache.LikeOn(ip, page)
- } else {
- CCache.LikeOff(ip, page)
- }
-
- slog.Debug("someone pressed the like button!", slog.String("ip", ip), slog.String("page", page), slog.String("new_like_status", fmt.Sprint(newLikeStatus)))
- if c.Get("HX-Request", "false") == "true" {
- content, err := tm.Render("blog-page-like-button", fiber.Map{
- "L": l[lang],
- "Liked": newLikeStatus,
- "LikedCount": CCache.GetLikeCount(page),
- })
- if err != nil {
- slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Send(content)
- }
-
- return c.Status(fiber.StatusOK).SendString(fmt.Sprint(newLikeStatus))
- }
-}
-
-func Api_V1_Like_Get(l map[string]*locale.LocaleConfig, b2 *b2.B2Client) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- referer := c.Get("Referer", "")
- if referer == "" {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
- }
- urlStruct, err := url.ParseRequestURI(referer)
- if err != nil {
- return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
- }
-
- path := urlStruct.EscapedPath()
- pathParts := strings.Split(strings.Trim(path, "/"), "/")
- if len(pathParts) != 3 {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/blog/{page}'")
- }
-
- lang, page := pathParts[0], pathParts[2]
-
- pageLink := lang + "/" + page + ".md"
- if pages, _ := b2.Scan(pageLink); len(pages) == 0 {
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server did not find '%s' article", pageLink))
- }
-
- ip := c.IP()
- likeStatus := CCache.GetLikeStatus(ip, page)
-
- slog.Debug("someone requested the like status!", slog.String("ip", ip), slog.String("page", page), slog.Bool("like_status", likeStatus))
- if c.Get("HX-Request", "false") == "true" {
- content, err := tm.Render("blog-page-like-button", fiber.Map{
- "L": l[lang],
- "Liked": likeStatus,
- "LikedCount": CCache.GetLikeCount(page),
- })
- if err != nil {
- slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Send(content)
- }
-
- return c.Status(fiber.StatusOK).SendString(fmt.Sprint(likeStatus))
- }
-}
diff --git a/internal/router/api-v1-tz.go b/internal/router/api-v1-tz.go
deleted file mode 100644
index 4e4e462..0000000
--- a/internal/router/api-v1-tz.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package router
-
-import (
- "log/slog"
- "time"
-
- "github.com/gofiber/fiber/v2"
-)
-
-func Api_V1_TZ() func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- timestampString := c.Query("timestamp")
- tz := c.Query("tz")
-
- loc, err := time.LoadLocation(tz)
- if err != nil {
- loc = time.UTC
- slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz))
- }
-
- var format string
- var timestamp time.Time
- for _, format = range []string{"2006-01-02 15:04:05 -07:00", time.RFC3339} {
- if timestamp, err = time.Parse(format, timestampString); err == nil {
- break
- }
- }
-
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- if err != nil {
- return c.Status(fiber.StatusBadRequest).SendString("invalid timestamp format")
- }
-
- return c.Status(fiber.StatusOK).SendString(timestamp.In(loc).Format(format))
- }
-}
diff --git a/internal/router/basic-handler.go b/internal/router/basic-handler.go
new file mode 100644
index 0000000..68d305c
--- /dev/null
+++ b/internal/router/basic-handler.go
@@ -0,0 +1,82 @@
+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) SitemapInfo(supplements *Supplements) []SitemapInfo {
+ return nil
+}
+
+func (r *BasicHandler) ContentType() string {
+ return fiber.MIMETextHTMLCharsetUTF8
+}
+
+func (r *BasicHandler) RateLimiter() *fiber.Handler {
+ return nil
+}
+
+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) AddMeta(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (meta []MetaField, err error) {
+ return []MetaField{{Name: "robots", Content: "noindex,nofollow"}}, nil
+}
+
+func (r *BasicHandler) AddLinkedData(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (ld map[string]any, err error) {
+ return nil, 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
index d607cdd..7c28b9e 100644
--- a/internal/router/client-cache.go
+++ b/internal/router/client-cache.go
@@ -24,17 +24,17 @@ type ClientCache struct {
db *sql.DB
}
-var CCache *ClientCache
-
func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) {
tx, err := db.Begin()
if err != nil {
return nil, fmt.Errorf("fail to init transaction with db to fill cache: %w", err)
}
+ slog.Debug("began db transaction", slog.String("method", "NewClientCache"))
rows, err := tx.Query("select * from blog_likes;")
if err != nil {
tx.Rollback()
+ slog.Debug("ended db transaction", slog.String("method", "NewClientCache"))
return nil, fmt.Errorf("fail to query db for blog_likes to fill cache: %w", err)
}
@@ -62,6 +62,7 @@ func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) {
rows, err = tx.Query("select * from blog_views;")
if err != nil {
tx.Rollback()
+ slog.Debug("ended db transaction", slog.String("method", "NewClientCache"))
return nil, fmt.Errorf("fail to query db for blog_views to fill cache: %w", err)
}
@@ -81,8 +82,11 @@ func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) {
}
if err = tx.Commit(); err != nil {
+ tx.Rollback()
+ slog.Debug("ended db transaction", slog.String("method", "NewClientCache"))
return nil, fmt.Errorf("fail to commit transaction in db: %w", err)
}
+ slog.Debug("ended db transaction", slog.String("method", "NewClientCache"))
return &ClientCache{
hashMap: make(map[string]string),
@@ -99,18 +103,28 @@ func (c *ClientCache) Close() error {
if err != nil {
return fmt.Errorf("fail to init transaction with db to dump cache: %w", err)
}
+ slog.Debug("began db transaction in ClientCache.Close")
if err = batchSave(tx, "blog_likes", c.likePageMap); err != nil {
tx.Rollback()
+ slog.Debug("ended db transaction in ClientCache.Close")
return fmt.Errorf("fail to save blog_likes: %s", err)
}
if err = batchSave(tx, "blog_views", c.viewPageMap); err != nil {
tx.Rollback()
+ slog.Debug("ended db transaction in ClientCache.Close")
return fmt.Errorf("fail to save blog_views: %s", err)
}
- return tx.Commit()
+ if err = tx.Commit(); err != nil {
+ tx.Rollback()
+ slog.Debug("ended db transaction in ClientCache.Close")
+ return fmt.Errorf("fail to commit all the changes related to cache: %s", err)
+ }
+
+ slog.Debug("ended db transaction in ClientCache.Close")
+ return nil
}
func (c *ClientCache) GetHash(id string) string {
diff --git a/internal/router/handlers/api-v1-blog-search.go b/internal/router/handlers/api-v1-blog-search.go
new file mode 100644
index 0000000..b59f09a
--- /dev/null
+++ b/internal/router/handlers/api-v1-blog-search.go
@@ -0,0 +1,145 @@
+package handlers
+
+import (
+ "cmp"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/url"
+ "regexp"
+ "slices"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/blog"
+ "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) RateLimiter() *fiber.Handler {
+ return &router.RateLimiterLoose
+}
+
+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")
+ medley := c.Query("medley")
+ highlight := c.Query("highlight")
+ hideTags := c.QueryBool("hideTags", false)
+ hidePublishedTime := c.QueryBool("hidePublishedTime", false)
+
+ 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 []*blog.Page
+ if pagesBytes, ok := supplements.PageCache.Get(cacheKey); pagesBytes != nil || ok {
+ json.Unmarshal(pagesBytes, &pages)
+ } else {
+ pages, err = supplements.BlogClient.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) {
+ if medley == "" || medley == page.Metadata.Medley {
+ 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),
+ "Liked": supplements.ClientCache.GetLikeStatus(c.IP(), page.FileName),
+ "ViewCount": supplements.ClientCache.GetViewCount(page.FileName),
+ "Viewed": supplements.ClientCache.GetViewStatus(c.IP(), page.FileName),
+ "Medley": page.Metadata.Medley,
+ "MedleyPart": page.Metadata.MedleyPart,
+ "ToHighlight": page.FileName == highlight,
+ })
+ break
+ }
+ }
+ }
+ }
+
+ slices.SortFunc(pageMeta, func(a, b fiber.Map) int {
+ switch sort {
+ case "titleAsc":
+ return cmp.Compare(a["Title"].(string), b["Title"].(string))
+ case "titleDesc":
+ return cmp.Compare(b["Title"].(string), a["Title"].(string))
+ case "actionDateAsc":
+ return cmp.Compare(a["ActionDate"].(string), b["ActionDate"].(string))
+ case "actionDateDesc":
+ return cmp.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)
+ case "medley":
+ return cmp.Compare(a["MedleyPart"].(int), b["MedleyPart"].(int))
+ }
+ return 0
+ })
+
+ templateMap["BlogPages"] = pageMeta
+ templateMap["HideTags"] = hideTags
+ templateMap["HidePublishedTime"] = hidePublishedTime
+
+ 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
new file mode 100644
index 0000000..b8ae162
--- /dev/null
+++ b/internal/router/handlers/api-v1-email-is-in-verification.go
@@ -0,0 +1,60 @@
+package handlers
+
+import (
+ "fmt"
+ "html/template"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/SayaAndy/saya-today-web/l10n"
+ "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(l10n.T.GetPath(lang, "UserProfile", "DelayTilVerification").(string), "{}", 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
new file mode 100644
index 0000000..ea8df4d
--- /dev/null
+++ b/internal/router/handlers/api-v1-email-send-verification-code.go
@@ -0,0 +1,102 @@
+package handlers
+
+import (
+ "fmt"
+ "html/template"
+ "log/slog"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/SayaAndy/saya-today-web/l10n"
+ "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) RateLimiter() *fiber.Handler {
+ return &router.RateLimiterStrict
+}
+
+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"] = l10n.T.GetPath(lang, "UserProfile", "EmailEmpty").(string)
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ isTaken, err := supplements.Mailer.MailIsTaken(email)
+ if err != nil {
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "VerificationCodeSendingError").(string)
+ 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"] = l10n.T.GetPath(lang, "UserProfile", "EmailTaken").(string)
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ if isAllowed, whenAllowed, _ := supplements.Mailer.IsAllowedToRetryVerification(id); !isAllowed {
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = strings.ReplaceAll(l10n.T.GetPath(lang, "UserProfile", "DelayTilVerification").(string), "{}", 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"] = l10n.T.GetPath(lang, "UserProfile", "EmailAlreadyValidated").(string)
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ if err = supplements.Mailer.SendVerificationCode(id, email, lang); err != nil {
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "VerificationCodeSendingError").(string)
+ 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"] = l10n.T.GetPath(lang, "UserProfile", "VerificationCodeSent").(string)
+ 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
new file mode 100644
index 0000000..65ad6d9
--- /dev/null
+++ b/internal/router/handlers/api-v1-email-verify.go
@@ -0,0 +1,67 @@
+package handlers
+
+import (
+ "html/template"
+ "log/slog"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/SayaAndy/saya-today-web/l10n"
+ "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) RateLimiter() *fiber.Handler {
+ return &router.RateLimiterStrict
+}
+
+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"] = l10n.T.GetPath(lang, "UserProfile", "VerificationEmpty").(string)
+ 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"] = l10n.T.GetPath(lang, "UserProfile", "VerificationFailed").(string)
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ templateMap["Status"] = "OK"
+ templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "VerificationSuccess").(string)
+ 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
new file mode 100644
index 0000000..9e2c856
--- /dev/null
+++ b/internal/router/handlers/api-v1-like-get.go
@@ -0,0 +1,63 @@
+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.BlogClient.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
new file mode 100644
index 0000000..8305d2b
--- /dev/null
+++ b/internal/router/handlers/api-v1-like-put.go
@@ -0,0 +1,78 @@
+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) RateLimiter() *fiber.Handler {
+ return &router.RateLimiterMedium
+}
+
+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.BlogClient.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-map-get.go b/internal/router/handlers/api-v1-map-get.go
new file mode 100644
index 0000000..e19c54a
--- /dev/null
+++ b/internal/router/handlers/api-v1-map-get.go
@@ -0,0 +1,116 @@
+package handlers
+
+import (
+ "fmt"
+ "log/slog"
+ "slices"
+ "strconv"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/internal/blog"
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type GetMapHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &GetMapHandler{})
+}
+
+func (r *GetMapHandler) Filter() (method string, path string) {
+ return "GET", "/api/v1/map"
+}
+
+func (r *GetMapHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *GetMapHandler) TemplatesToInject() []string {
+ return []string{"views/partials/global-map-widget.html"}
+}
+
+func (r *GetMapHandler) ToCache() router.CacheSetting {
+ return router.ByUrlAndQuery
+}
+
+func (r *GetMapHandler) ToValidateLang() router.LangSetting {
+ return router.InReferer
+}
+
+func (r *GetMapHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ codename := c.Query("codename")
+ zoom := c.QueryInt("zoom", 4)
+ zoomPosition := c.Query("zoomPosition")
+
+ pages, err := supplements.BlogClient.Scan(lang + "/")
+ status := fiber.StatusOK
+ if err != nil {
+ slog.Error("received an error while scanning blog pages",
+ slog.String("error", err.Error()),
+ slog.String("lang", lang),
+ )
+ status = fiber.StatusPartialContent
+ pages = []*blog.Page{}
+ }
+ slices.SortFunc(pages, func(a *blog.Page, b *blog.Page) int {
+ return a.Metadata.PublishedTime.Compare(b.Metadata.PublishedTime)
+ })
+
+ type MapMarker struct {
+ Index int `json:"Index"`
+ Title string `json:"Title"`
+ PageLink string `json:"PageLink"`
+ Lat float64 `json:"Lat"`
+ Long float64 `json:"Long"`
+ AccuracyMeters int64 `json:"AccuracyMeters"`
+ Thumbnail string `json:"Thumbnail"`
+ ToHighlight bool `json:"ToHighlight"`
+ }
+
+ templateMap["MapLocationLat"] = 45.4507
+ templateMap["MapLocationLong"] = 68.8319
+ templateMap["MapLocationZoom"] = zoom
+ templateMap["ZoomPosition"] = zoomPosition
+
+ mapMarkers := make([]*MapMarker, 0, len(pages))
+ for i, 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)
+ }
+
+ toHighlight := page.FileName == codename
+ if toHighlight {
+ templateMap["MapLocationLat"] = x
+ templateMap["MapLocationLong"] = y
+ }
+
+ mapMarkers = append(mapMarkers, &MapMarker{
+ Index: i,
+ Title: page.Metadata.Title,
+ PageLink: fmt.Sprintf("/%s/blog/%s", lang, page.FileName),
+ Lat: x,
+ Long: y,
+ AccuracyMeters: areaError,
+ Thumbnail: page.Metadata.Thumbnail,
+ ToHighlight: toHighlight,
+ })
+ }
+
+ templateMap["MapMarkers"] = mapMarkers
+
+ return status, nil
+}
diff --git a/internal/router/handlers/api-v1-subs-put.go b/internal/router/handlers/api-v1-subs-put.go
new file mode 100644
index 0000000..12dc5cf
--- /dev/null
+++ b/internal/router/handlers/api-v1-subs-put.go
@@ -0,0 +1,70 @@
+package handlers
+
+import (
+ "github.com/SayaAndy/saya-today-web/internal/mailer"
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/SayaAndy/saya-today-web/l10n"
+ "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) RateLimiter() *fiber.Handler {
+ return &router.RateLimiterMedium
+}
+
+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"] = l10n.T.GetPath(lang, "UserProfile", "SubscribeInvalidType").(string)
+ 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"] = l10n.T.GetPath(lang, "UserProfile", "FailedToSubscribe").(string)
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ templateMap["Status"] = "OK"
+ templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "SubscribedSuccessfully").(string)
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/api-v1-tz-get.go b/internal/router/handlers/api-v1-tz-get.go
new file mode 100644
index 0000000..8b08113
--- /dev/null
+++ b/internal/router/handlers/api-v1-tz-get.go
@@ -0,0 +1,65 @@
+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
new file mode 100644
index 0000000..76dc335
--- /dev/null
+++ b/internal/router/handlers/lang-blog-title.go
@@ -0,0 +1,182 @@
+package handlers
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/blog"
+ "github.com/SayaAndy/saya-today-web/internal/frontmatter"
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/SayaAndy/saya-today-web/l10n"
+ "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) SitemapInfo(supplements *router.Supplements) []router.SitemapInfo {
+ sitemapInfo := []router.SitemapInfo{}
+
+ pages, err := supplements.BlogClient.Scan("")
+ if err != nil {
+ return sitemapInfo
+ }
+
+ for _, page := range pages {
+ sitemapInfo = append(sitemapInfo, router.SitemapInfo{
+ Loc: "/" + page.Lang + "/blog/" + page.FileName,
+ LastModified: page.ModifiedTime,
+ Priority: 1.0,
+ })
+ }
+
+ return sitemapInfo
+}
+
+func (r *BlogPageHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta []router.MetaField, err error) {
+ metadata, _, err := supplements.BlogClient.ReadFrontmatter(lang + "/" + c.Params("title") + ".md")
+ if err != nil {
+ return nil, fmt.Errorf("failed to read frontmatter of the desired blog post: %w", err)
+ }
+ title := metadata.Title
+ if metadata.Medley != "" {
+ title += " // " + l10n.T.GetPath(lang, "Medleys", metadata.Medley).(string)
+ }
+
+ return []router.MetaField{
+ {Property: "og:title", Content: title},
+ {Property: "og:description", Content: fmt.Sprintf("%s [%s]", metadata.ShortDescription, metadata.ActionDate)},
+ {Property: "og:image", Content: fmt.Sprintf(supplements.PhotoStorage.Thumbnail560p.BaseUrl, metadata.Thumbnail)},
+ {Property: "og:url", Content: fmt.Sprintf("%s/%s/blog/%s", templateMap["CanonicalEndpoint"], lang, c.Params("title"))},
+ {Property: "og:type", Content: "website"},
+ {Name: "twitter:card", Content: "summary_large_image"},
+ }, nil
+}
+
+func (r *BlogPageHandler) AddLinkedData(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (ld map[string]any, err error) {
+ metadata, _, err := supplements.BlogClient.ReadFrontmatter(lang + "/" + c.Params("title") + ".md")
+ if err != nil {
+ return nil, fmt.Errorf("failed to read frontmatter of the desired blog post: %w", err)
+ }
+ title := metadata.Title
+ if metadata.Medley != "" {
+ title += " // " + l10n.T.GetPath(lang, "Medleys", metadata.Medley).(string)
+ }
+
+ return map[string]any{
+ "@context": "https://schema.org",
+ "@type": "Article",
+ "headline": title,
+ "description": fmt.Sprintf("%s [%s]", metadata.ShortDescription, metadata.ActionDate),
+ "author": map[string]string{"@type": "Person", "name": "Saya Andy"},
+ "datePublished": metadata.PublishedTime.UTC().Format(time.RFC3339),
+ }, nil
+}
+
+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)
+ }
+ title := pathParts[2]
+
+ metadata, parsedMarkdown, err := readBlogPost(supplements.MarkdownRenderer, supplements.BlogClient, lang+"/"+title)
+ if err != nil {
+ return fiber.StatusNotFound, fmt.Errorf("failed to find '%s' post: %w", title, 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["Codename"] = title
+ templateMap["ParsedMarkdown"] = template.HTML(parsedMarkdown)
+ templateMap["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00")
+ templateMap["ActionDate"] = metadata.ActionDate
+ templateMap["ShortDescription"] = metadata.ShortDescription
+ templateMap["Thumbnail"] = metadata.Thumbnail
+ templateMap["Medley"] = metadata.Medley
+
+ go supplements.ClientCache.View(c.IP(), title)
+
+ 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.BlogClient.ReadFrontmatter(lang + "/" + pathParts[2] + ".md")
+ if err != nil {
+ return fiber.StatusNotFound, fmt.Errorf("could not read '%s' for metadata: %w", path, err)
+ }
+
+ pageTitle := metadata.Title
+ if metadata.Medley != "" {
+ pageTitle += " // " + l10n.T.GetPath(lang, "Medleys", metadata.Medley).(string)
+ }
+
+ templateMap["Title"] = metadata.Title
+ templateMap["PageTitle"] = pageTitle
+
+ return fiber.StatusOK, nil
+}
+
+func readBlogPost(md goldmark.Markdown, blogClient blog.Client, sourceName string) (metadata *frontmatter.Metadata, html string, err error) {
+ metadata, markdown, err := blogClient.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
new file mode 100644
index 0000000..c31c2eb
--- /dev/null
+++ b/internal/router/handlers/lang-blog.go
@@ -0,0 +1,129 @@
+package handlers
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "regexp"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/blog"
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/SayaAndy/saya-today-web/l10n"
+ "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) SitemapInfo(supplements *router.Supplements) []router.SitemapInfo {
+ sitemapInfo := []router.SitemapInfo{}
+ for _, lang := range supplements.AvailableLanguages {
+ sitemapInfo = append(sitemapInfo, router.SitemapInfo{Loc: "/" + lang.Name + "/blog", Priority: 0.3})
+ }
+ return sitemapInfo
+}
+
+func (r *CatalogueHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta []router.MetaField, err error) {
+ return []router.MetaField{
+ {Property: "og:title", Content: l10n.T.GetPath(lang, "BlogSearch", "Header").(string)},
+ {Property: "og:description", Content: l10n.T.GetPath(lang, "BlogSearch", "Description").(string)},
+ {Property: "og:url", Content: fmt.Sprintf("%s/%s/blog", templateMap["CanonicalEndpoint"], lang)},
+ {Property: "og:type", Content: "website"},
+ }, nil
+}
+
+func (r *CatalogueHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ querySort := c.Query("sort", "publicationDateDesc")
+ previousBlogPage := c.Query("codename")
+
+ 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.BlogClient, 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"] = l10n.T.GetPath(lang, "BlogSearch", "Header").(string)
+ templateMap["PreviousBlogPage"] = previousBlogPage
+
+ 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"] = l10n.T.GetPath(lang, "BlogSearch", "Header").(string)
+ return fiber.StatusOK, nil
+}
+
+type Tag struct {
+ Name string `json:"Name" yaml:"name"`
+ Count int `json:"Count" yaml:"count"`
+}
+
+func getTags(blogClient blog.Client, lang string) (tags []Tag, err error) {
+ pages, err := blogClient.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
new file mode 100644
index 0000000..32ab35e
--- /dev/null
+++ b/internal/router/handlers/lang-map.go
@@ -0,0 +1,46 @@
+package handlers
+
+import (
+ "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) SitemapInfo(supplements *router.Supplements) []router.SitemapInfo {
+ sitemapInfo := []router.SitemapInfo{}
+ for _, lang := range supplements.AvailableLanguages {
+ sitemapInfo = append(sitemapInfo, router.SitemapInfo{Loc: "/" + lang.Name + "/map", Priority: 0.3})
+ }
+ return sitemapInfo
+}
+
+func (r *MapHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/lang-user-unsubscribe.go b/internal/router/handlers/lang-user-unsubscribe.go
new file mode 100644
index 0000000..e22d854
--- /dev/null
+++ b/internal/router/handlers/lang-user-unsubscribe.go
@@ -0,0 +1,69 @@
+package handlers
+
+import (
+ "log/slog"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/SayaAndy/saya-today-web/l10n"
+ "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 = l10n.T.GetPath(lang, "UnsubscribePage", "UnsetCode").(string)
+ 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 = l10n.T.GetPath(lang, "UnsubscribePage", "InvalidCode").(string)
+ 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 = l10n.T.GetPath(lang, "UnsubscribePage", "OnServerError").(string)
+ status = fiber.ErrInternalServerError.Code
+ } else {
+ statusColor = "0, 255, 0"
+ statusEmoji = "♡⸜(˶˃ ᵕ ˂˶)⸝♡"
+ statusText = l10n.T.GetPath(lang, "UnsubscribePage", "Success").(string)
+ 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
new file mode 100644
index 0000000..085a9aa
--- /dev/null
+++ b/internal/router/handlers/lang-user.go
@@ -0,0 +1,89 @@
+package handlers
+
+import (
+ "fmt"
+ "log/slog"
+
+ "github.com/SayaAndy/saya-today-web/internal/mailer"
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/SayaAndy/saya-today-web/l10n"
+ "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) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta []router.MetaField, err error) {
+ return []router.MetaField{
+ {Name: "robots", Content: "noindex,nofollow"},
+ {Property: "og:title", Content: l10n.T.GetPath(lang, "UserProfile", "Header").(string)},
+ {Property: "og:description", Content: l10n.T.GetPath(lang, "UserProfile", "Description").(string)},
+ {Property: "og:url", Content: fmt.Sprintf("%s/%s/user", templateMap["CanonicalEndpoint"], lang)},
+ {Property: "og:type", Content: "website"},
+ }, nil
+}
+
+func (r *UserHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ templateMap["Title"] = l10n.T.GetPath(lang, "UserProfile", "Header").(string)
+
+ 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.BlogClient, 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"] = l10n.T.GetPath(lang, "UserProfile", "Header").(string)
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/lang.go b/internal/router/handlers/lang.go
new file mode 100644
index 0000000..cc0b33b
--- /dev/null
+++ b/internal/router/handlers/lang.go
@@ -0,0 +1,81 @@
+package handlers
+
+import (
+ "fmt"
+ "math/rand/v2"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/SayaAndy/saya-today-web/l10n"
+ "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) SitemapInfo(supplements *router.Supplements) []router.SitemapInfo {
+ sitemapInfo := []router.SitemapInfo{}
+ for _, lang := range supplements.AvailableLanguages {
+ sitemapInfo = append(sitemapInfo, router.SitemapInfo{Loc: "/" + lang.Name, Priority: 0.7})
+ }
+ return sitemapInfo
+}
+
+func (r *HomeHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta []router.MetaField, err error) {
+ return []router.MetaField{
+ {Property: "og:title", Content: l10n.T.GetPath(lang, "HomePage", "Header").(string)},
+ {Property: "og:description", Content: l10n.T.GetPath(lang, "HomePage", "HomePageDescription").(string)},
+ {Property: "og:image", Content: fmt.Sprintf(
+ supplements.PhotoStorage.HomePageGifs.BaseUrl,
+ supplements.PhotoStorage.HomePageGifs.Indexes[rand.Int()%len(supplements.PhotoStorage.HomePageGifs.Indexes)],
+ )},
+ {Property: "og:url", Content: fmt.Sprintf("%s/%s", templateMap["CanonicalEndpoint"], lang)},
+ {Property: "og:type", Content: "website"},
+ }, nil
+}
+
+func (r *HomeHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ templateMap["Title"] = l10n.T.GetPath(lang, "HomePage", "Header").(string)
+ templateMap["FilledHeartCount"] = uint(40)
+ templateMap["OutlineHeartCount"] = uint(40)
+ templateMap["GifUrl"] = fmt.Sprintf(
+ supplements.PhotoStorage.HomePageGifs.BaseUrl,
+ supplements.PhotoStorage.HomePageGifs.Indexes[rand.Int()%len(supplements.PhotoStorage.HomePageGifs.Indexes)],
+ )
+ 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"] = l10n.T.GetPath(lang, "HomePage", "Header").(string)
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/robots.txt.go b/internal/router/handlers/robots.txt.go
new file mode 100644
index 0000000..fc60af4
--- /dev/null
+++ b/internal/router/handlers/robots.txt.go
@@ -0,0 +1,48 @@
+package handlers
+
+import (
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type RobotsTxtHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &RobotsTxtHandler{})
+}
+
+func (r *RobotsTxtHandler) Filter() (method string, path string) {
+ return "GET", "/robots.txt"
+}
+
+func (r *RobotsTxtHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *RobotsTxtHandler) TemplatesToInject() []string {
+ return []string{"views/pages/robots.txt"}
+}
+
+func (r *RobotsTxtHandler) ToCache() router.CacheSetting {
+ return router.ByUrlOnly
+}
+
+func (r *RobotsTxtHandler) CacheDuration() time.Duration {
+ return time.Hour
+}
+
+func (r *RobotsTxtHandler) ToValidateLang() router.LangSetting {
+ return router.NotRequired
+}
+
+func (r *RobotsTxtHandler) ContentType() string {
+ return fiber.MIMETextPlainCharsetUTF8
+}
+
+func (r *RobotsTxtHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/root.go b/internal/router/handlers/root.go
new file mode 100644
index 0000000..6a011e4
--- /dev/null
+++ b/internal/router/handlers/root.go
@@ -0,0 +1,68 @@
+package handlers
+
+import (
+ "fmt"
+ "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) SitemapInfo(_ *router.Supplements) []router.SitemapInfo {
+ return []router.SitemapInfo{{Loc: "/", Priority: 0.7}}
+}
+
+func (r *RootHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta []router.MetaField, err error) {
+ meta = []router.MetaField{
+ {Property: "og:title", Content: "Saya Blog"},
+ {Property: "og:description", Content: "The blog of a 25 years old, travelling God knows where, proud to be not mainstream // Личный блог 25-летки, странствующего по ебеням, зато не мейнстрим"},
+ {Property: "og:url", Content: fmt.Sprint(templateMap["CanonicalEndpoint"]) + "/"},
+ {Property: "og:type", Content: "website"},
+ }
+ if supplements.Meta.GoogleSiteVerification != "" {
+ meta = append(meta, router.MetaField{Name: "google-site-verification", Content: supplements.Meta.GoogleSiteVerification})
+ }
+ if supplements.Meta.YandexVerification != "" {
+ meta = append(meta, router.MetaField{Name: "yandex-verification", Content: supplements.Meta.YandexVerification})
+ }
+ return meta, nil
+}
+
+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/handlers/sitemap.xml.go b/internal/router/handlers/sitemap.xml.go
new file mode 100644
index 0000000..bb1df93
--- /dev/null
+++ b/internal/router/handlers/sitemap.xml.go
@@ -0,0 +1,79 @@
+package handlers
+
+import (
+ "log/slog"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type SitemapXmlHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &SitemapXmlHandler{})
+}
+
+func (r *SitemapXmlHandler) Filter() (method string, path string) {
+ return "GET", "/sitemap.xml"
+}
+
+func (r *SitemapXmlHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *SitemapXmlHandler) TemplatesToInject() []string {
+ return []string{"views/pages/sitemap.xml"}
+}
+
+func (r *SitemapXmlHandler) ToCache() router.CacheSetting {
+ return router.ByUrlOnly
+}
+
+func (r *SitemapXmlHandler) CacheDuration() time.Duration {
+ return time.Hour
+}
+
+func (r *SitemapXmlHandler) ToValidateLang() router.LangSetting {
+ return router.NotRequired
+}
+
+func (r *SitemapXmlHandler) ContentType() string {
+ return fiber.MIMEApplicationXMLCharsetUTF8
+}
+
+func (r *SitemapXmlHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ urls := []router.SitemapInfo{}
+ for _, route := range router.Routes {
+ sitemapInfo := route.SitemapInfo(supplements)
+ if sitemapInfo == nil {
+ continue
+ }
+
+ method, match := route.Filter()
+ lastMod, err := supplements.TemplateManager.GetLastModified(method + " " + match)
+ if err != nil {
+ slog.Warn("failed to get last modified for a route",
+ slog.String("error", err.Error()),
+ slog.String("method", method),
+ slog.String("match", match))
+ lastMod = time.Now()
+ }
+
+ for i := range sitemapInfo {
+ if sitemapInfo[i].LastModified.Before(lastMod) {
+ sitemapInfo[i].LastModified = lastMod
+ }
+ if sitemapInfo[i].ChangeFreq == "" {
+ sitemapInfo[i].ChangeFreq = "monthly"
+ }
+ urls = append(urls, sitemapInfo[i])
+ }
+ }
+
+ templateMap["URLs"] = urls
+
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/lang-blog-title.go b/internal/router/lang-blog-title.go
deleted file mode 100644
index d6ca917..0000000
--- a/internal/router/lang-blog-title.go
+++ /dev/null
@@ -1,83 +0,0 @@
-package router
-
-import (
- "bytes"
- "fmt"
- "html/template"
- "log/slog"
- "slices"
- "strconv"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/internal/frontmatter"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
- "github.com/yuin/goldmark"
-)
-
-func init() {
- tm.Add("blog-page", "views/layouts/general-page.html", "views/pages/blog-page.html")
-}
-
-func Lang_Blog_Title(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client, md goldmark.Markdown) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- ip := c.IP()
- slog.Debug("client entering blog page", slog.String("ip", ip), slog.String("page", c.Path()))
-
- lang := c.Params("lang")
- if !slices.Contains(langs, lang) {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
- }
-
- metadata, parsedMarkdown, err := readBlogPost(md, b2Client, lang+"/"+c.Params("title"))
- if err != nil {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("failed to find '%s' post", c.Params("title")))
- }
-
- geolocationParts := strings.Split(metadata.Geolocation, " ")
- var x, y, areaError string
- if len(geolocationParts) >= 2 {
- x = geolocationParts[0]
- y = geolocationParts[1]
- }
- if len(geolocationParts) >= 3 {
- areaError = geolocationParts[2]
- }
-
- content, err := tm.Render("blog-page", fiber.Map{
- "Title": metadata.Title,
- "PublishedDate": metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00"),
- "PublishedYear": strconv.Itoa(metadata.PublishedTime.Year()),
- "ParsedMarkdown": template.HTML(parsedMarkdown),
- "MapLocationX": x,
- "MapLocationY": y,
- "MapLocationAreaMeters": areaError,
- "Lang": lang,
- "QueryString": string(c.Request().URI().QueryString()),
- })
- if err != nil {
- slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/blog/"+c.Params("title")), slog.String("error", err.Error()))
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page")
- }
-
- return c.Type("html").Send(content)
- }
-}
-
-func readBlogPost(md goldmark.Markdown, b2Client *b2.B2Client, sourceName string) (metadata *frontmatter.Metadata, html string, err error) {
- metadata, markdown, err := b2Client.ReadFrontmatter(sourceName + ".md")
- if err != nil {
- return nil, "", fmt.Errorf("failed to read a frontmatter file: %w", err)
- }
-
- var buf bytes.Buffer
- if err := md.Convert(markdown, &buf); err != nil {
- return nil, "", fmt.Errorf("convert source context from md to html: %w", err)
- }
-
- return metadata, buf.String(), nil
-}
diff --git a/internal/router/lang-blog.go b/internal/router/lang-blog.go
deleted file mode 100644
index 38a6597..0000000
--- a/internal/router/lang-blog.go
+++ /dev/null
@@ -1,94 +0,0 @@
-package router
-
-import (
- "fmt"
- "log/slog"
- "net/url"
- "regexp"
- "slices"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- tm.Add("blog-catalogue", "views/layouts/general-page.html", "views/pages/blog-catalogue.html")
-}
-
-func Lang_Blog(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- lang := c.Params("lang")
- if !slices.Contains(langs, lang) {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
- }
-
- querySort := c.Query("sort")
- if querySort == "" {
- querySort = "publicationDateDesc"
- }
-
- encodedQuery := c.Request().URI().QueryString()
- re, err := regexp.Compile(`tags\[\]=([\w]+)`)
- if err != nil {
- slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error()))
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering")
- }
- decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
- matches := re.FindAllStringSubmatch(decodedQuery, -1)
-
- queryTags := make([]string, 0, len(matches))
- for _, match := range matches {
- queryTags = append(queryTags, string(match[1]))
- }
-
- pages, err := b2Client.Scan(lang + "/")
- status := fiber.StatusOK
- if err != nil {
- status = fiber.StatusPartialContent
- pages = []*b2.BlogPage{}
- }
-
- tagsMap := make(map[string]int)
- for _, page := range pages {
- for _, tag := range page.Metadata.Tags {
- tagsMap[tag]++
- }
- }
- slog.Debug("enlist pages for catalogue", slog.Int("tag_count", len(tagsMap)), slog.Int("page_count", len(pages)), slog.String("path", c.Path()))
-
- type Tag struct {
- Name string `json:"Name" yaml:"name"`
- Count int `json:"Count" yaml:"count"`
- }
-
- tagsArray := make([]Tag, 0, len(tagsMap))
- for tag, count := range tagsMap {
- tagsArray = append(tagsArray, Tag{tag, count})
- }
- slices.SortFunc(tagsArray, func(a Tag, b Tag) int {
- return strings.Compare(a.Name, b.Name)
- })
-
- content, err := tm.Render("blog-catalogue", fiber.Map{
- "QuerySort": querySort,
- "QueryTags": strings.Join(queryTags, ","),
- "QueryString": string(c.Request().URI().QueryString()),
- "Tags": tagsArray,
- "Lang": lang,
- "L": l[lang],
- "PublishedYear": "2025",
- "Title": l[lang].BlogSearch.Header,
- })
- if err != nil {
- slog.Warn("failed to generate page", slog.String("path", c.Path()), slog.String("error", err.Error()))
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page")
- }
-
- return c.Type("html").Status(status).Send(content)
- }
-}
diff --git a/internal/router/lang-map.go b/internal/router/lang-map.go
deleted file mode 100644
index e94d756..0000000
--- a/internal/router/lang-map.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package router
-
-import (
- "fmt"
- "log/slog"
- "slices"
- "strconv"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- tm.Add("global-map", "views/pages/global-map.html")
-}
-
-func Lang_Map(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- lang := c.Params("lang")
- if !slices.Contains(langs, lang) {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
- }
-
- pages, err := b2Client.Scan(lang + "/")
- status := fiber.StatusOK
- if err != nil {
- status = fiber.StatusPartialContent
- pages = []*b2.BlogPage{}
- }
-
- type MapMarker struct {
- Title string `json:"Title"`
- PageLink string `json:"PageLink"`
- Lat float64 `json:"Lat"`
- Long float64 `json:"Long"`
- AccuracyMeters int64 `json:"AccuracyMeters"`
- Thumbnail string `json:"Thumbnail"`
- }
-
- mapMarkers := make([]*MapMarker, 0, len(pages))
- for _, page := range pages {
- geolocationParts := strings.Split(page.Metadata.Geolocation, " ")
- if len(geolocationParts) < 2 {
- continue
- }
-
- var x, y float64
- var areaError int64
- if len(geolocationParts) >= 2 {
- x, _ = strconv.ParseFloat(geolocationParts[0], 64)
- y, _ = strconv.ParseFloat(geolocationParts[1], 64)
- }
- if len(geolocationParts) >= 3 {
- areaError, _ = strconv.ParseInt(geolocationParts[2], 10, 64)
- }
-
- mapMarkers = append(mapMarkers, &MapMarker{
- Title: page.Metadata.Title,
- PageLink: fmt.Sprintf("/%s/blog/%s", lang, page.FileName),
- Lat: x,
- Long: y,
- AccuracyMeters: areaError,
- Thumbnail: page.Metadata.Thumbnail,
- })
- }
-
- content, err := tm.Render("global-map", fiber.Map{
- "Lang": lang,
- "L": l[lang],
- "MapLocationLat": 45.4507,
- "MapLocationLong": 68.8319,
- "MapMarkers": mapMarkers,
- })
- if err != nil {
- slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/map"), slog.String("error", err.Error()))
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page")
- }
-
- return c.Type("html").Status(status).Send(content)
- }
-}
diff --git a/internal/router/page-cache.go b/internal/router/page-cache.go
deleted file mode 100644
index 01e4792..0000000
--- a/internal/router/page-cache.go
+++ /dev/null
@@ -1,5 +0,0 @@
-package router
-
-import "github.com/dgraph-io/ristretto/v2"
-
-var PCache *ristretto.Cache[string, []byte]
diff --git a/internal/router/path-matcher.go b/internal/router/path-matcher.go
new file mode 100644
index 0000000..c8aef08
--- /dev/null
+++ b/internal/router/path-matcher.go
@@ -0,0 +1,48 @@
+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/rate-limiters.go b/internal/router/rate-limiters.go
new file mode 100644
index 0000000..b43581d
--- /dev/null
+++ b/internal/router/rate-limiters.go
@@ -0,0 +1,37 @@
+package router
+
+import (
+ "time"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/gofiber/fiber/v2/middleware/limiter"
+)
+
+var (
+ RateLimiterStrict = limiter.New(limiter.Config{
+ Max: 5,
+ Expiration: time.Minute,
+ KeyGenerator: func(c *fiber.Ctx) string { return c.IP() },
+ LimitReached: func(c *fiber.Ctx) error {
+ return c.Status(fiber.StatusTooManyRequests).SendString("rate limit")
+ },
+ })
+
+ RateLimiterMedium = limiter.New(limiter.Config{
+ Max: 30,
+ Expiration: time.Minute,
+ KeyGenerator: func(c *fiber.Ctx) string { return c.IP() },
+ LimitReached: func(c *fiber.Ctx) error {
+ return c.Status(fiber.StatusTooManyRequests).SendString("rate limit")
+ },
+ })
+
+ RateLimiterLoose = limiter.New(limiter.Config{
+ Max: 60,
+ Expiration: time.Minute,
+ KeyGenerator: func(c *fiber.Ctx) string { return c.IP() },
+ LimitReached: func(c *fiber.Ctx) error {
+ return c.Status(fiber.StatusTooManyRequests).SendString("rate limit")
+ },
+ })
+)
diff --git a/internal/router/root.go b/internal/router/root.go
deleted file mode 100644
index f4a0692..0000000
--- a/internal/router/root.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package router
-
-import (
- "log/slog"
-
- "github.com/SayaAndy/saya-today-web/config"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- tm.Add("index", "views/index.html")
-}
-
-func Root(localeCfg []config.AvailableLanguageConfig) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- content, err := tm.Render("index", fiber.Map{
- "AvailableLanguages": localeCfg,
- })
- if err != nil {
- slog.Warn("failed to generate page", slog.String("page", "/"), slog.String("error", err.Error()))
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page")
- }
-
- return c.Type("html").Send(content)
- }
-}
diff --git a/internal/router/router.go b/internal/router/router.go
new file mode 100644
index 0000000..c8b5686
--- /dev/null
+++ b/internal/router/router.go
@@ -0,0 +1,699 @@
+package router
+
+import (
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net"
+ "net/url"
+ "os"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/blog"
+ "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/dgraph-io/ristretto/v2"
+ "github.com/gofiber/fiber/v2"
+ "github.com/gofiber/fiber/v2/middleware/compress"
+ "github.com/gofiber/fiber/v2/middleware/cors"
+ "github.com/gofiber/fiber/v2/middleware/etag"
+ "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 SitemapInfo struct {
+ Loc string
+ LastModified time.Time
+ ChangeFreq string
+ Priority float32
+}
+
+type MetaField struct {
+ Name string
+ Property string
+ Content string
+}
+
+type Route interface {
+ Filter() (method string, path string)
+ IsTemplated() bool
+ ToCache() CacheSetting
+ CacheDuration() time.Duration
+ ToValidateLang() LangSetting
+ TemplatesToInject() []string
+ SitemapInfo(supplements *Supplements) []SitemapInfo
+ ContentType() string
+ RateLimiter() *fiber.Handler
+ Render(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error)
+ AddMeta(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (meta []MetaField, err error)
+ AddLinkedData(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (ld map[string]any, 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
+ BlogClient blog.Client
+ 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
+ Meta config.MetaConfig
+ PhotoStorage config.PhotoStorageConfig
+ StaticStorage config.StaticStorageConfig
+}
+
+type Router struct {
+ supplements *Supplements
+ app *fiber.App
+ templatedRoutes map[string]map[string]Route
+ templatedPathMatcher *PathMatcher
+ canonicalEndpoint string
+ endpoint config.EndpointConfig
+}
+
+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.BlogClient, err = blog.NewClientMap[cfg.BlogPages.Storage.Type](&cfg.BlogPages.Storage)
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize blog client: type %s: %w", cfg.BlogPages.Storage.Type, err)
+ }
+
+ supplements.MarkdownRenderer = goldmark.New(
+ goldmark.WithExtensions(
+ glightbox.NewGLightboxExtension(cfg.PhotoStorage),
+ 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))
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize mailer: %w", err)
+ }
+
+ supplements.BlogTrigger, err = blogtrigger.NewBlogTriggerScheduler(supplements.BlogClient, cfg.AvailableLanguages, cfg.Mail.Trigger.OnNewPost,
+ func(bp []*blog.Page) 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)
+ }
+
+ supplements.Meta = cfg.Meta
+ supplements.PhotoStorage = cfg.PhotoStorage
+ supplements.StaticStorage = cfg.StaticStorage
+
+ 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: strings.Join(cfg.AllowOrigins, ","),
+ AllowMethods: "GET,POST,OPTIONS",
+ AllowHeaders: "Origin, Content-Type, Accept",
+ }))
+
+ app.Use(compress.New(compress.Config{
+ Level: compress.LevelBestSpeed,
+ }))
+
+ app.Use(etag.New())
+
+ app.Use(func(c *fiber.Ctx) error {
+ path := c.Path()
+ if len(path) > 1 && path[len(path)-1] == '/' {
+ return c.Redirect(strings.TrimRight(path, "/"), fiber.StatusMovedPermanently)
+ }
+ return c.Next()
+ })
+
+ templatedRoutes := make(map[string]map[string]Route)
+ templatedPathMatcher := NewPathMatcher()
+
+ return &Router{supplements, app, templatedRoutes, templatedPathMatcher, cfg.CanonicalEndpoint, cfg.Endpoint}, 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 rateLimiter := route.RateLimiter(); rateLimiter != nil {
+ r.app.Use(match, *rateLimiter)
+ }
+
+ 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)
+ case Disabled:
+ c.Set("Cache-Control", "no-store, no-cache, must-revalidate")
+ }
+
+ defaultMap := fiber.Map{
+ "Lang": lang,
+ "Path": trimmedPath,
+ "QueryString": queryString,
+ "CanonicalEndpoint": r.canonicalEndpoint,
+ "StaticStorage": r.supplements.StaticStorage,
+ "PhotoStorage": r.supplements.PhotoStorage,
+ }
+
+ 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, route.ContentType())
+ return c.Status(statusCode).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))
+ }
+ c.Set("Cache-Control", "no-store, no-cache, must-revalidate")
+ 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() error {
+ switch r.endpoint.Type {
+ case "unix":
+ unixConfig := r.endpoint.Config.(*config.UnixConfig)
+ endpoint, _ := strings.CutPrefix(unixConfig.Path, "unix://")
+
+ if err := os.Remove(endpoint); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("error while cleaning up existing unix socket: %w", err)
+ }
+
+ ln, err := net.Listen("unix", endpoint)
+ if err != nil {
+ return fmt.Errorf("error while initializing unix listener: %w", err)
+ }
+ chmod, _ := strconv.ParseUint(unixConfig.Chmod[1:], 8, 32)
+ os.Chmod(unixConfig.Path, os.FileMode(chmod))
+ if err := r.app.Listener(ln); err != nil {
+ return fmt.Errorf("error while running fiber server: %w", err)
+ }
+ case "http":
+ httpConfig := r.endpoint.Config.(*config.HttpConfig)
+ fmt.Print(httpConfig)
+ if err := r.app.Listen(httpConfig.ListenOn); err != nil {
+ return fmt.Errorf("error while running fiber server: %w", err)
+ }
+ default:
+ return fmt.Errorf("error with initializing fiber server: invalid endpoint type (supported are unix and http)")
+ }
+
+ return nil
+}
+
+func (r *Router) Close() (err error) {
+ allErrors := make([]error, 0)
+ slog.Debug("shutting down blog trigger scheduler")
+ if err = r.supplements.BlogTrigger.Close(); err != nil {
+ allErrors = append(allErrors, fmt.Errorf("fail to shutdown blog trigger scheduler: %w", err))
+ }
+ slog.Debug("shutting down fiber server")
+ if err = r.app.Shutdown(); err != nil {
+ allErrors = append(allErrors, fmt.Errorf("fail to shutdown fiber server: %w", err))
+ }
+ slog.Debug("dumping cache")
+ if err = r.supplements.ClientCache.Close(); err != nil {
+ allErrors = append(allErrors, fmt.Errorf("fail to dump cache: %w", err))
+ }
+ slog.Debug("closing db connection")
+ if err = r.supplements.DB.Close(); err != nil {
+ allErrors = append(allErrors, fmt.Errorf("fail to close db connection: %w", err))
+ }
+ slog.Debug("closing page cache")
+ 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, "/")
+ queryString := c.Request().URI().QueryString()
+ cacheKey := ""
+
+ 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, route.ContentType())
+ return c.Status(fiber.StatusOK).Send(val)
+ }
+
+ valueMap := fiber.Map{
+ "Lang": lang,
+ "Path": trimmedPath,
+ "QueryString": queryString,
+ "CanonicalEndpoint": r.canonicalEndpoint,
+ "StaticStorage": r.supplements.StaticStorage,
+ "PhotoStorage": r.supplements.PhotoStorage,
+ }
+
+ var err error
+ if valueMap["Meta"], err = route.AddMeta(c, r.supplements, lang, valueMap); err != nil {
+ slog.Warn("failed to receive meta", slog.String("path", path), slog.String("error", err.Error()))
+ }
+
+ var ld map[string]any
+ if ld, err = route.AddLinkedData(c, r.supplements, lang, valueMap); err != nil {
+ slog.Warn("failed to receive linked data", slog.String("path", path), slog.String("error", err.Error()))
+ }
+ if ld != nil {
+ ldBytes, _ := json.Marshal(ld)
+ valueMap["LinkedData"] = template.JS(ldBytes)
+ }
+
+ syntheticReferer := path
+ if len(queryString) > 0 {
+ syntheticReferer += "?" + string(queryString)
+ }
+ c.Request().Header.Set("Referer", syntheticReferer)
+
+ parts := []struct {
+ name string
+ key string
+ render func(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (int, error)
+ }{
+ {"top-embeds", "RenderedTopEmbeds", route.RenderTopEmbeds},
+ {"header", "RenderedHeader", route.RenderHeader},
+ {"body", "RenderedBody", route.RenderBody},
+ {"footer", "RenderedFooter", route.RenderFooter},
+ {"bottom-embeds", "RenderedBottomEmbeds", route.RenderBottomEmbeds},
+ }
+
+ for _, p := range parts {
+ statusCode, err := p.render(c, r.supplements, lang, valueMap)
+ if err != nil {
+ slog.Error("failed to render segment for full page",
+ slog.String("path", path),
+ slog.String("segment", p.name),
+ slog.String("error", err.Error()),
+ )
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(statusCode).SendString(err.Error())
+ }
+ segContent, err := r.supplements.TemplateManager.Render("general-page-"+p.name, valueMap, route.TemplatesToInject()...)
+ if err != nil {
+ slog.Error("failed to render segment template for full page",
+ slog.String("path", path),
+ slog.String("segment", p.name),
+ slog.String("error", err.Error()),
+ )
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate segment")
+ }
+ valueMap[p.key] = template.HTML(segContent)
+ }
+
+ content, err := r.supplements.TemplateManager.Render("general-page", valueMap)
+ if err != nil {
+ slog.Warn("failed to generate full page", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate full page")
+ }
+
+ go r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration())
+ c.Set(fiber.HeaderContentType, route.ContentType())
+ return c.Status(fiber.StatusOK).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, "/")
+ requestQuery := string(c.Request().URI().QueryString())
+ switch route.ToCache() {
+ case ByUrlOnly:
+ cacheKey = fmt.Sprintf("%s.%s.%s", method, part, trimmedPath)
+ case ByUrlAndQuery:
+ cacheKey = fmt.Sprintf("%s.%s.%s.%s.%s", method, part, trimmedPath, queryString, requestQuery)
+ }
+
+ if route.ToCache() != Disabled {
+ if val, ok := r.supplements.PageCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, route.ContentType())
+ return c.Status(fiber.StatusOK).Send(val)
+ }
+ }
+
+ var statusCode int
+ defaultMap := fiber.Map{
+ "Lang": lang,
+ "Path": strings.Trim(path, "/"),
+ "QueryString": queryString,
+ "CanonicalEndpoint": r.canonicalEndpoint,
+ "StaticStorage": r.supplements.StaticStorage,
+ "PhotoStorage": r.supplements.PhotoStorage,
+ }
+
+ 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, route.ContentType())
+ return c.Status(statusCode).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
+ }
+ }
+ return "", fiber.NewError(fiber.StatusBadRequest, 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/router/tm.go b/internal/router/tm.go
deleted file mode 100644
index e8095ee..0000000
--- a/internal/router/tm.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package router
-
-import (
- "log/slog"
- "os"
-
- "github.com/SayaAndy/saya-today-web/internal/templatemanager"
-)
-
-var tm = assert(templatemanager.NewTemplateManager())
-
-func assert[T any](t T, err error) T {
- if err != nil {
- slog.Error("fail to initialize template manager", slog.String("error", err.Error()))
- os.Exit(1)
- }
- return t
-}
diff --git a/internal/tailwind/link_renderer.go b/internal/tailwind/link_renderer.go
new file mode 100644
index 0000000..ff3d437
--- /dev/null
+++ b/internal/tailwind/link_renderer.go
@@ -0,0 +1,49 @@
+package tailwind
+
+import (
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/renderer"
+ "github.com/yuin/goldmark/renderer/html"
+ "github.com/yuin/goldmark/util"
+)
+
+type CustomLinkRenderer struct {
+ html.Config
+}
+
+func NewCustomLinkRenderer(opts ...html.Option) renderer.NodeRenderer {
+ r := &CustomLinkRenderer{
+ Config: html.NewConfig(),
+ }
+ for _, opt := range opts {
+ opt.SetHTMLOption(&r.Config)
+ }
+ return r
+}
+
+func (r *CustomLinkRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ n := node.(*ast.Link)
+ if entering {
+ _, _ = w.WriteString("<a href=\"")
+ if r.Unsafe || !html.IsDangerousURL(n.Destination) {
+ _, _ = w.Write(util.EscapeHTML(util.URLEscape(n.Destination, true)))
+ }
+ _, _ = w.WriteString(`"`)
+ if n.Title != nil {
+ _, _ = w.WriteString(` title="`)
+ _, _ = w.Write(util.EscapeHTML(n.Title))
+ _, _ = w.WriteString(`"`)
+ }
+ if n.Attributes() != nil {
+ html.RenderAttributes(w, n, nil)
+ }
+ _, _ = w.WriteString(">")
+ } else {
+ _, _ = w.WriteString("</a>")
+ }
+ return ast.WalkContinue, nil
+}
+
+func (r *CustomLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+ reg.Register(ast.KindLink, r.renderLink)
+}
diff --git a/internal/tailwind/transformer.go b/internal/tailwind/transformer.go
index 4057ea4..f90489f 100644
--- a/internal/tailwind/transformer.go
+++ b/internal/tailwind/transformer.go
@@ -1,6 +1,9 @@
package tailwind
import (
+ "bytes"
+ "fmt"
+
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
@@ -17,49 +20,53 @@ func (t *TailwindTransformer) Transform(node *ast.Document, reader text.Reader,
switch node := n.(type) {
case *ast.Heading:
classes := map[int]string{
- 1: "font-patua text-4xl font-bold text-main-dark mb-6 tracking-[.0125rem]",
- 2: "font-spectral text-3xl font-bold text-main-dark mb-2 tracking-[.0125rem]",
- 3: "font-spectral text-2xl font-medium text-main-dark mb-1.5 tracking-[.0125rem]",
- 4: "font-spectral text-xl font-medium text-main-medium mb-1 tracking-[.0125rem]",
- 5: "font-spectral text-base font-medium text-main-medium mb-1 italic tracking-[.0125rem]",
- 6: "font-spectral text-base font-medium text-secondary mb-1",
+ 1: "font-gentium text-4xl font-bold text-main-hard mb-3 tracking-[.0125rem]",
+ 2: "font-gentium text-3xl font-bold text-main-hard mb-1 tracking-[.0125rem]",
+ 3: "font-gentium text-2xl font-medium text-main-hard mb-0.8 tracking-[.0125rem]",
+ 4: "font-gentium text-xl font-medium text-main-medium mb-0.5 tracking-[.0125rem]",
+ 5: "font-gentium text-base font-medium text-main-medium mb-0.5 italic tracking-[.0125rem]",
+ 6: "font-gentium text-base font-medium text-secondary mb-0.5",
}
if class, ok := classes[node.Level]; ok {
node.SetAttribute([]byte("class"), []byte(class))
}
case *ast.Paragraph:
- node.SetAttribute([]byte("class"), []byte("text-base/[2] font-spectral tracking-[.0125rem] -indent-8 ml-8 mb-8"))
+ node.SetAttribute([]byte("class"), []byte("text-base/[2] font-gentium 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-8 pl-8"))
+ 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-8 pl-8"))
+ 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-spectral tracking-[.0125rem]"))
+ node.SetAttribute([]byte("class"), []byte("text-base/[2] font-gentium tracking-[.0125rem]"))
case *ast.Blockquote:
- node.SetAttribute([]byte("class"), []byte("border-l-1 border-main-medium bg-background-dark p-2 mb-2 italic"))
+ node.SetAttribute([]byte("class"), []byte("border-l-[0.125rem] border-main-medium bg-paper bg-background-dark p-1 mb-2 italic font-thin"))
case *ast.CodeSpan:
- node.SetAttribute([]byte("class"), []byte("bg-background-dark"))
+ node.SetAttribute([]byte("class"), []byte("bg-paper bg-background-dark"))
case *ast.CodeBlock, *ast.FencedCodeBlock:
- node.SetAttribute([]byte("class"), []byte("bg-background-dark p-2 rounded-lg overflow-x-auto mb-8"))
+ node.SetAttribute([]byte("class"), []byte("bg-paper bg-background-dark p-1 rounded-lg overflow-x-auto mb-4"))
case *ast.Link:
- node.SetAttribute([]byte("class"), []byte("text-secondary hover:text-main-dark underline"))
+ if bytes.HasPrefix(node.Destination, []byte{'.', '/'}) {
+ onclickAttr := fmt.Appendf(make([]byte, 0, 21+len(node.Destination)), "return changeUrl('%s');", node.Destination)
+ node.SetAttribute([]byte("onclick"), onclickAttr)
+ }
+ node.SetAttribute([]byte("class"), []byte("text-secondary hover:text-main-hard underline"))
case *ast.Image:
- node.SetAttribute([]byte("class"), []byte("max-w-full h-auto rounded-lg shadow-lg mb-8"))
+ 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"))
+ node.SetAttribute([]byte("class"), []byte("italic font-thin"))
case 2:
node.SetAttribute([]byte("class"), []byte("font-bold"))
case 3:
diff --git a/internal/templatemanager/templatemanager.go b/internal/templatemanager/templatemanager.go
index 4bf9a49..ff1214a 100644
--- a/internal/templatemanager/templatemanager.go
+++ b/internal/templatemanager/templatemanager.go
@@ -4,8 +4,12 @@ import (
"bytes"
"fmt"
"html/template"
+ "os"
"path/filepath"
"strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/l10n"
)
type TemplateManager struct {
@@ -13,8 +17,9 @@ type TemplateManager struct {
}
type templateManagerRender struct {
- Main string
- Tmpl *template.Template
+ Main string
+ Tmpl *template.Template
+ LastModified time.Time
}
type TemplateManagerTemplates struct {
@@ -31,20 +36,41 @@ var templateFuncMap = template.FuncMap{
}
return items
},
+ "replace": strings.ReplaceAll,
+ "fdiv": func(a, b int) float64 {
+ return float64(a) / float64(b)
+ },
+ "l": func(path ...any) any {
+ return l10n.T.GetPath(path...)
+ },
+ "join": strings.Join,
}
func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager, error) {
templateMap := make(map[string]templateManagerRender)
for _, tmplStruct := range templates {
- tmpl := template.New("").Funcs(templateFuncMap)
+ 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
}
+ modTime, err := setLastModified(tmplStruct.Files...)
+ if err != nil {
+ return nil, err
+ }
+
templateMap[tmplStruct.Name] = templateManagerRender{
- Main: filepath.Base(tmplStruct.Files[0]),
- Tmpl: tmpl,
+ Main: filepath.Base(tmplStruct.Files[0]),
+ Tmpl: tmpl,
+ LastModified: modTime,
}
}
@@ -56,12 +82,16 @@ func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager
func (tm *TemplateManager) Render(name string, data any, files ...string) ([]byte, error) {
tmpl, exists := tm.templates[name]
if !exists {
- return nil, fmt.Errorf("template %s is not found", name)
+ 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()
@@ -72,28 +102,66 @@ func (tm *TemplateManager) Render(name string, data any, files ...string) ([]byt
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, tmpl.Main, data)
+ 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 {
- return fmt.Errorf("you can't add template without any files")
+ tm.templates[name] = templateManagerRender{
+ Main: "",
+ Tmpl: tmpl,
+ }
+ return nil
}
- tmpl := template.New("").Funcs(templateFuncMap)
-
tmpl, err := tmpl.ParseFiles(files...)
if err != nil {
return fmt.Errorf("failed to add template into manager: %w", err)
}
+ modTime, err := setLastModified(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,
+ Main: filepath.Base(files[0]),
+ Tmpl: tmpl,
+ LastModified: modTime,
}
+
return nil
}
+
+func (tm *TemplateManager) GetLastModified(name string) (time.Time, error) {
+ tmpl, exists := tm.templates[name]
+ if !exists {
+ return time.Time{}, fmt.Errorf("template %s not found", name)
+ }
+
+ return tmpl.LastModified, nil
+}
+
+func setLastModified(filenames ...string) (time.Time, error) {
+ lastModified := time.Time{}
+ for _, filename := range filenames {
+ stat, err := os.Stat(filename)
+ if err != nil {
+ return lastModified, fmt.Errorf("failed to stat file: path '%s': %w", filename, err)
+ }
+ modTime := stat.ModTime()
+ if lastModified.Before(modTime) {
+ lastModified = modTime
+ }
+ }
+ return lastModified, nil
+}