summaryrefslogtreecommitdiffci
path: root/internal/router
diff refs
from: back
to: back
| flip
diff options
context:
space:
mode:
Diffstat (limited to 'internal/router')
-rw-r--r--internal/router/api-v1-blog-search.go123
-rw-r--r--internal/router/api-v1-general-page-body.go164
-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.go65
-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
38 files changed, 1123 insertions, 2437 deletions
diff --git a/internal/router/api-v1-blog-search.go b/internal/router/api-v1-blog-search.go
new file mode 100644
index 0000000..69be651
--- /dev/null
+++ b/internal/router/api-v1-blog-search.go
@@ -0,0 +1,123 @@
+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
new file mode 100644
index 0000000..c1a4a9f
--- /dev/null
+++ b/internal/router/api-v1-general-page-body.go
@@ -0,0 +1,164 @@
+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)
+ values["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00")
+ values["ActionDate"] = metadata.ActionDate
+
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+
+ go CCache.View(c.IP(), pathParts[2])
+ } else if len(pathParts) == 1 {
+ values["Title"] = l[lang].HomePage.Header
+ values["FilledHeartCount"] = uint(40)
+ values["OutlineHeartCount"] = uint(40)
+ values["GifName"] = fmt.Sprintf("otter-%d.gif", rand.Int()%3+1)
+ values["FunFacts"] = FactGiver.Give(lang)
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ }
+
+ 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
new file mode 100644
index 0000000..c4d9543
--- /dev/null
+++ b/internal/router/api-v1-general-page-bottom-embeds.go
@@ -0,0 +1,75 @@
+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
new file mode 100644
index 0000000..003f54c
--- /dev/null
+++ b/internal/router/api-v1-general-page-footer.go
@@ -0,0 +1,75 @@
+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
new file mode 100644
index 0000000..80228f7
--- /dev/null
+++ b/internal/router/api-v1-general-page-header.go
@@ -0,0 +1,85 @@
+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
new file mode 100644
index 0000000..78021b2
--- /dev/null
+++ b/internal/router/api-v1-general-page-top-embeds.go
@@ -0,0 +1,75 @@
+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
new file mode 100644
index 0000000..d0ea559
--- /dev/null
+++ b/internal/router/api-v1-general-page.go
@@ -0,0 +1,52 @@
+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
new file mode 100644
index 0000000..327dafa
--- /dev/null
+++ b/internal/router/api-v1-like.go
@@ -0,0 +1,122 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ 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
new file mode 100644
index 0000000..4e4e462
--- /dev/null
+++ b/internal/router/api-v1-tz.go
@@ -0,0 +1,37 @@
+package router
+
+import (
+ "log/slog"
+ "time"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func Api_V1_TZ() func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ timestampString := c.Query("timestamp")
+ tz := c.Query("tz")
+
+ loc, err := time.LoadLocation(tz)
+ if err != nil {
+ loc = time.UTC
+ slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz))
+ }
+
+ var format string
+ var timestamp time.Time
+ for _, format = range []string{"2006-01-02 15:04:05 -07:00", time.RFC3339} {
+ if timestamp, err = time.Parse(format, timestampString); err == nil {
+ break
+ }
+ }
+
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).SendString("invalid timestamp format")
+ }
+
+ return c.Status(fiber.StatusOK).SendString(timestamp.In(loc).Format(format))
+ }
+}
diff --git a/internal/router/basic-handler.go b/internal/router/basic-handler.go
deleted file mode 100644
index 68d305c..0000000
--- a/internal/router/basic-handler.go
+++ /dev/null
@@ -1,82 +0,0 @@
-package router
-
-import (
- "time"
-
- "github.com/gofiber/fiber/v2"
-)
-
-type BasicHandler struct{}
-
-var _ Route = &BasicHandler{}
-
-func (r *BasicHandler) Filter() (method string, path string) {
- panic("handler did not implement Filter method")
-}
-
-func (r *BasicHandler) IsTemplated() bool {
- return false
-}
-
-func (r *BasicHandler) ToCache() CacheSetting {
- return Disabled
-}
-
-func (r *BasicHandler) CacheDuration() time.Duration {
- return 5 * time.Minute
-}
-
-func (r *BasicHandler) ToValidateLang() LangSetting {
- return NotRequired
-}
-
-func (r *BasicHandler) TemplatesToInject() []string {
- return []string{}
-}
-
-func (r *BasicHandler) 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 7c28b9e..d607cdd 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,7 +62,6 @@ 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)
}
@@ -82,11 +81,8 @@ 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),
@@ -103,28 +99,18 @@ 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)
}
- 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
+ return tx.Commit()
}
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
deleted file mode 100644
index b59f09a..0000000
--- a/internal/router/handlers/api-v1-blog-search.go
+++ /dev/null
@@ -1,145 +0,0 @@
-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
deleted file mode 100644
index b8ae162..0000000
--- a/internal/router/handlers/api-v1-email-is-in-verification.go
+++ /dev/null
@@ -1,60 +0,0 @@
-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
deleted file mode 100644
index ea8df4d..0000000
--- a/internal/router/handlers/api-v1-email-send-verification-code.go
+++ /dev/null
@@ -1,102 +0,0 @@
-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
deleted file mode 100644
index 65ad6d9..0000000
--- a/internal/router/handlers/api-v1-email-verify.go
+++ /dev/null
@@ -1,67 +0,0 @@
-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
deleted file mode 100644
index 9e2c856..0000000
--- a/internal/router/handlers/api-v1-like-get.go
+++ /dev/null
@@ -1,63 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "log/slog"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type GetLikeHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &GetLikeHandler{})
-}
-
-func (r *GetLikeHandler) Filter() (method string, path string) {
- return "GET", "/api/v1/like"
-}
-
-func (r *GetLikeHandler) IsTemplated() bool {
- return false
-}
-
-func (r *GetLikeHandler) TemplatesToInject() []string {
- return []string{"views/partials/blog-page-like-button.html"}
-}
-
-func (r *GetLikeHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *GetLikeHandler) ToValidateLang() router.LangSetting {
- return router.InReferer
-}
-
-func (r *GetLikeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- path, pathParts, _, err := router.GetPathFromReferer(c)
- if err != nil {
- return fiber.StatusBadRequest, fmt.Errorf("error getting path from referer: %w", err)
- }
-
- if len(pathParts) != 3 || pathParts[1] != "blog" {
- return fiber.StatusBadRequest, fmt.Errorf("invalid path format: expected '/:lang/blog/:page', got '%s'", path)
- }
-
- page := pathParts[2]
- pageLink := lang + "/" + page + ".md"
- if pages, _ := supplements.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
deleted file mode 100644
index 8305d2b..0000000
--- a/internal/router/handlers/api-v1-like-put.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "log/slog"
- "strconv"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type PutLikeHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &PutLikeHandler{})
-}
-
-func (r *PutLikeHandler) Filter() (method string, path string) {
- return "PUT", "/api/v1/like"
-}
-
-func (r *PutLikeHandler) IsTemplated() bool {
- return false
-}
-
-func (r *PutLikeHandler) TemplatesToInject() []string {
- return []string{"views/partials/blog-page-like-button.html"}
-}
-
-func (r *PutLikeHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *PutLikeHandler) ToValidateLang() router.LangSetting {
- return router.InReferer
-}
-
-func (r *PutLikeHandler) 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
deleted file mode 100644
index e19c54a..0000000
--- a/internal/router/handlers/api-v1-map-get.go
+++ /dev/null
@@ -1,116 +0,0 @@
-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
deleted file mode 100644
index 12dc5cf..0000000
--- a/internal/router/handlers/api-v1-subs-put.go
+++ /dev/null
@@ -1,70 +0,0 @@
-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
deleted file mode 100644
index 8b08113..0000000
--- a/internal/router/handlers/api-v1-tz-get.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "log/slog"
- "time"
-
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/gofiber/fiber/v2"
-)
-
-type GetTimezoneHandler struct {
- router.BasicHandler
-}
-
-func init() {
- router.Routes = append(router.Routes, &GetTimezoneHandler{})
-}
-
-func (r *GetTimezoneHandler) Filter() (method string, path string) {
- return "GET", "/api/v1/tz"
-}
-
-func (r *GetTimezoneHandler) IsTemplated() bool {
- return false
-}
-
-func (r *GetTimezoneHandler) TemplatesToInject() []string {
- return []string{}
-}
-
-func (r *GetTimezoneHandler) ToCache() router.CacheSetting {
- return router.Disabled
-}
-
-func (r *GetTimezoneHandler) ToValidateLang() router.LangSetting {
- return router.NotRequired
-}
-
-func (r *GetTimezoneHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
- timestampString := c.Query("timestamp")
- tz := c.Query("tz")
-
- loc, err := time.LoadLocation(tz)
- if err != nil {
- loc = time.UTC
- slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz))
- }
-
- var format string
- var timestamp time.Time
- for _, format = range []string{"2006-01-02 15:04:05 -07:00", time.RFC3339} {
- if timestamp, err = time.Parse(format, timestampString); err == nil {
- break
- }
- }
-
- if err != nil {
- return fiber.StatusBadRequest, fmt.Errorf("invalid timestamp format for '%s'", timestampString)
- }
-
- templateMap["Output"] = []byte(timestamp.In(loc).Format("2006-01-02 15:04:05 -07:00"))
-
- return fiber.StatusOK, nil
-}
diff --git a/internal/router/handlers/lang-blog-title.go b/internal/router/handlers/lang-blog-title.go
deleted file mode 100644
index 76dc335..0000000
--- a/internal/router/handlers/lang-blog-title.go
+++ /dev/null
@@ -1,182 +0,0 @@
-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
deleted file mode 100644
index c31c2eb..0000000
--- a/internal/router/handlers/lang-blog.go
+++ /dev/null
@@ -1,129 +0,0 @@
-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
deleted file mode 100644
index 32ab35e..0000000
--- a/internal/router/handlers/lang-map.go
+++ /dev/null
@@ -1,46 +0,0 @@
-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
deleted file mode 100644
index e22d854..0000000
--- a/internal/router/handlers/lang-user-unsubscribe.go
+++ /dev/null
@@ -1,69 +0,0 @@
-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
deleted file mode 100644
index 085a9aa..0000000
--- a/internal/router/handlers/lang-user.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "log/slog"
-
- "github.com/SayaAndy/saya-today-web/internal/mailer"
- "github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/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
deleted file mode 100644
index cc0b33b..0000000
--- a/internal/router/handlers/lang.go
+++ /dev/null
@@ -1,81 +0,0 @@
-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
deleted file mode 100644
index fc60af4..0000000
--- a/internal/router/handlers/robots.txt.go
+++ /dev/null
@@ -1,48 +0,0 @@
-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
deleted file mode 100644
index 4fc4e31..0000000
--- a/internal/router/handlers/root.go
+++ /dev/null
@@ -1,65 +0,0 @@
-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"},
- }
- for _, field := range supplements.Meta {
- meta = append(meta, router.MetaField{Name: field.Name, Content: field.Value})
- }
- 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
deleted file mode 100644
index bb1df93..0000000
--- a/internal/router/handlers/sitemap.xml.go
+++ /dev/null
@@ -1,79 +0,0 @@
-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
new file mode 100644
index 0000000..d6ca917
--- /dev/null
+++ b/internal/router/lang-blog-title.go
@@ -0,0 +1,83 @@
+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
new file mode 100644
index 0000000..38a6597
--- /dev/null
+++ b/internal/router/lang-blog.go
@@ -0,0 +1,94 @@
+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
new file mode 100644
index 0000000..e94d756
--- /dev/null
+++ b/internal/router/lang-map.go
@@ -0,0 +1,85 @@
+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
new file mode 100644
index 0000000..01e4792
--- /dev/null
+++ b/internal/router/page-cache.go
@@ -0,0 +1,5 @@
+package router
+
+import "github.com/dgraph-io/ristretto/v2"
+
+var PCache *ristretto.Cache[string, []byte]
diff --git a/internal/router/path-matcher.go b/internal/router/path-matcher.go
deleted file mode 100644
index c8aef08..0000000
--- a/internal/router/path-matcher.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package router
-
-import (
- "github.com/gofiber/fiber/v2"
- "github.com/valyala/fasthttp"
-)
-
-type PathMatcher struct {
- app *fiber.App
-}
-
-func NewPathMatcher() *PathMatcher {
- app := fiber.New(fiber.Config{
- DisableStartupMessage: true,
- })
- return &PathMatcher{app: app}
-}
-
-func (pm *PathMatcher) AddRoute(method, pattern string) {
- pm.app.Add(method, pattern, func(c *fiber.Ctx) error {
- c.Locals("pattern", pattern)
- return nil
- })
-}
-
-func (pm *PathMatcher) MatchPath(method, path string) (pattern string, params map[string]string, matched bool) {
- fctx := &fasthttp.RequestCtx{}
- fctx.Request.Header.SetMethod(method)
- fctx.Request.SetRequestURI(path)
-
- ctx := pm.app.AcquireCtx(fctx)
- defer pm.app.ReleaseCtx(ctx)
-
- pm.app.Handler()(fctx)
-
- if ctx.Route() == nil {
- return "", nil, false
- }
-
- pattern = ctx.Locals("pattern").(string)
-
- params = make(map[string]string)
- for _, paramName := range ctx.Route().Params {
- params[paramName] = ctx.Params(paramName)
- }
-
- return pattern, params, true
-}
diff --git a/internal/router/rate-limiters.go b/internal/router/rate-limiters.go
deleted file mode 100644
index b43581d..0000000
--- a/internal/router/rate-limiters.go
+++ /dev/null
@@ -1,37 +0,0 @@
-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
new file mode 100644
index 0000000..f4a0692
--- /dev/null
+++ b/internal/router/root.go
@@ -0,0 +1,27 @@
+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
deleted file mode 100644
index a11a934..0000000
--- a/internal/router/router.go
+++ /dev/null
@@ -1,699 +0,0 @@
-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
new file mode 100644
index 0000000..e8095ee
--- /dev/null
+++ b/internal/router/tm.go
@@ -0,0 +1,18 @@
+package router
+
+import (
+ "log/slog"
+ "os"
+
+ "github.com/SayaAndy/saya-today-web/internal/templatemanager"
+)
+
+var tm = assert(templatemanager.NewTemplateManager())
+
+func assert[T any](t T, err error) T {
+ if err != nil {
+ slog.Error("fail to initialize template manager", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+ return t
+}