summaryrefslogtreecommitdiff
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/basic-handler.go78
-rw-r--r--internal/router/client-cache.go318
-rw-r--r--internal/router/handlers/api-v1-blog-search.go126
-rw-r--r--internal/router/handlers/api-v1-email-is-in-verification.go59
-rw-r--r--internal/router/handlers/api-v1-email-send-verification-code.go97
-rw-r--r--internal/router/handlers/api-v1-email-verify.go62
-rw-r--r--internal/router/handlers/api-v1-like-get.go63
-rw-r--r--internal/router/handlers/api-v1-like-put.go74
-rw-r--r--internal/router/handlers/api-v1-subs-put.go65
-rw-r--r--internal/router/handlers/api-v1-tz-get.go65
-rw-r--r--internal/router/handlers/lang-blog-title.go163
-rw-r--r--internal/router/handlers/lang-blog.go129
-rw-r--r--internal/router/handlers/lang-map.go109
-rw-r--r--internal/router/handlers/lang-user-unsubscribe.go68
-rw-r--r--internal/router/handlers/lang-user.go88
-rw-r--r--internal/router/handlers/lang.go74
-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/path-matcher.go48
-rw-r--r--internal/router/router.go622
21 files changed, 2500 insertions, 0 deletions
diff --git a/internal/router/basic-handler.go b/internal/router/basic-handler.go
new file mode 100644
index 0000000..b973d01
--- /dev/null
+++ b/internal/router/basic-handler.go
@@ -0,0 +1,78 @@
+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) 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
new file mode 100644
index 0000000..7c28b9e
--- /dev/null
+++ b/internal/router/client-cache.go
@@ -0,0 +1,318 @@
+package router
+
+import (
+ "database/sql"
+ "encoding/base64"
+ "fmt"
+ "log/slog"
+ "strings"
+ "sync"
+
+ "golang.org/x/crypto/argon2"
+)
+
+type ClientCache struct {
+ hashMap map[string]string
+ hashMapMutex sync.RWMutex
+
+ likePageMap map[string]map[string]struct{}
+ viewPageMap map[string]map[string]struct{}
+ pageMutexMap map[string]*sync.RWMutex
+ pageMutexMapMutex sync.Mutex
+
+ salt []byte
+ db *sql.DB
+}
+
+func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) {
+ tx, err := db.Begin()
+ if err != nil {
+ return nil, fmt.Errorf("fail to init transaction with db to fill cache: %w", err)
+ }
+ slog.Debug("began db transaction", slog.String("method", "NewClientCache"))
+
+ rows, err := tx.Query("select * from blog_likes;")
+ if err != nil {
+ tx.Rollback()
+ slog.Debug("ended db transaction", slog.String("method", "NewClientCache"))
+ return nil, fmt.Errorf("fail to query db for blog_likes to fill cache: %w", err)
+ }
+
+ likePageMap := make(map[string]map[string]struct{})
+ viewPageMap := make(map[string]map[string]struct{})
+ pageMutexMap := make(map[string]*sync.RWMutex)
+
+ for rows.Next() {
+ var pageRef string
+ var userId []byte
+ if err = rows.Scan(&pageRef, &userId); err != nil {
+ tx.Rollback()
+ return nil, fmt.Errorf("fail scanning blog_likes to fill cache: %w", err)
+ }
+ userIdString := base64.RawStdEncoding.EncodeToString(userId)
+ if _, ok := likePageMap[pageRef]; !ok {
+ likePageMap[pageRef] = make(map[string]struct{})
+ viewPageMap[pageRef] = make(map[string]struct{})
+ pageMutexMap[pageRef] = &sync.RWMutex{}
+ }
+ likePageMap[pageRef][userIdString] = struct{}{}
+ viewPageMap[pageRef][userIdString] = struct{}{}
+ }
+
+ rows, err = tx.Query("select * from blog_views;")
+ if err != nil {
+ tx.Rollback()
+ slog.Debug("ended db transaction", slog.String("method", "NewClientCache"))
+ return nil, fmt.Errorf("fail to query db for blog_views to fill cache: %w", err)
+ }
+
+ for rows.Next() {
+ var pageRef string
+ var userId []byte
+ if err = rows.Scan(&pageRef, &userId); err != nil {
+ tx.Rollback()
+ return nil, fmt.Errorf("fail scanning blog_views to fill cache: %w", err)
+ }
+ userIdString := base64.RawStdEncoding.EncodeToString(userId)
+ if _, ok := viewPageMap[pageRef]; !ok {
+ viewPageMap[pageRef] = make(map[string]struct{})
+ pageMutexMap[pageRef] = &sync.RWMutex{}
+ }
+ viewPageMap[pageRef][userIdString] = struct{}{}
+ }
+
+ if err = tx.Commit(); err != nil {
+ tx.Rollback()
+ slog.Debug("ended db transaction", slog.String("method", "NewClientCache"))
+ return nil, fmt.Errorf("fail to commit transaction in db: %w", err)
+ }
+ slog.Debug("ended db transaction", slog.String("method", "NewClientCache"))
+
+ return &ClientCache{
+ hashMap: make(map[string]string),
+ likePageMap: likePageMap,
+ viewPageMap: viewPageMap,
+ pageMutexMap: pageMutexMap,
+ salt: salt,
+ db: db,
+ }, nil
+}
+
+func (c *ClientCache) Close() error {
+ tx, err := c.db.Begin()
+ if err != nil {
+ return fmt.Errorf("fail to init transaction with db to dump cache: %w", err)
+ }
+ slog.Debug("began db transaction in ClientCache.Close")
+
+ if err = batchSave(tx, "blog_likes", c.likePageMap); err != nil {
+ tx.Rollback()
+ slog.Debug("ended db transaction in ClientCache.Close")
+ return fmt.Errorf("fail to save blog_likes: %s", err)
+ }
+
+ if err = batchSave(tx, "blog_views", c.viewPageMap); err != nil {
+ tx.Rollback()
+ slog.Debug("ended db transaction in ClientCache.Close")
+ return fmt.Errorf("fail to save blog_views: %s", err)
+ }
+
+ if err = tx.Commit(); err != nil {
+ tx.Rollback()
+ slog.Debug("ended db transaction in ClientCache.Close")
+ return fmt.Errorf("fail to commit all the changes related to cache: %s", err)
+ }
+
+ slog.Debug("ended db transaction in ClientCache.Close")
+ return nil
+}
+
+func (c *ClientCache) GetHash(id string) string {
+ c.hashMapMutex.RLock()
+ if val, ok := c.hashMap[id]; ok {
+ c.hashMapMutex.RUnlock()
+ slog.Debug("gave an old hash", slog.String("hash", val))
+ return val
+ }
+ c.hashMapMutex.RUnlock()
+
+ c.hashMapMutex.Lock()
+ defer c.hashMapMutex.Unlock()
+
+ if val, ok := c.hashMap[id]; ok {
+ slog.Debug("gave a newly generated hash", slog.String("hash", val))
+ return val
+ }
+
+ c.hashMap[id] = base64.RawStdEncoding.EncodeToString(argon2.IDKey([]byte(id), c.salt, 1, 64*1024, 4, 32))
+ slog.Debug("generated hash", slog.String("hash", c.hashMap[id]))
+ return c.hashMap[id]
+}
+
+func (c *ClientCache) getPageMutex(page string) *sync.RWMutex {
+ c.pageMutexMapMutex.Lock()
+ defer c.pageMutexMapMutex.Unlock()
+
+ if mutex, ok := c.pageMutexMap[page]; ok {
+ return mutex
+ }
+
+ c.pageMutexMap[page] = &sync.RWMutex{}
+ return c.pageMutexMap[page]
+}
+
+func (c *ClientCache) GetLikeStatus(id string, page string) bool {
+ page = strings.Clone(page)
+
+ mutex := c.getPageMutex(page)
+ mutex.RLock()
+ defer mutex.RUnlock()
+
+ if _, ok := c.likePageMap[page]; !ok {
+ return false
+ }
+ _, ok := c.likePageMap[page][c.GetHash(id)]
+ return ok
+}
+
+func (c *ClientCache) GetLikeCount(page string) int {
+ page = strings.Clone(page)
+
+ mutex := c.getPageMutex(page)
+ mutex.RLock()
+ defer mutex.RUnlock()
+
+ if userSet, ok := c.likePageMap[page]; ok {
+ return len(userSet)
+ }
+ return 0
+}
+
+func (c *ClientCache) LikeOn(id string, page string) (alreadyLiked bool) {
+ page = strings.Clone(page)
+ hash := c.GetHash(id)
+
+ mutex := c.getPageMutex(page)
+ mutex.Lock()
+ defer mutex.Unlock()
+
+ if userSet, ok := c.likePageMap[page]; ok {
+ _, alreadyLiked = userSet[hash]
+ c.likePageMap[page][hash] = struct{}{}
+ return
+ }
+
+ c.likePageMap[page] = make(map[string]struct{})
+ c.likePageMap[page][hash] = struct{}{}
+ return
+}
+
+func (c *ClientCache) LikeOff(id string, page string) (alreadyUnliked bool) {
+ page = strings.Clone(page)
+ hash := c.GetHash(id)
+
+ mutex := c.getPageMutex(page)
+ mutex.Lock()
+ defer mutex.Unlock()
+
+ if _, ok := c.likePageMap[page]; !ok {
+ return true
+ }
+ if _, ok := c.likePageMap[page][hash]; !ok {
+ return true
+ }
+
+ delete(c.likePageMap[page], hash)
+ return false
+}
+
+func (c *ClientCache) GetViewStatus(id string, page string) bool {
+ page = strings.Clone(page)
+
+ mutex := c.getPageMutex(page)
+ mutex.RLock()
+ defer mutex.RUnlock()
+
+ if _, ok := c.viewPageMap[page]; !ok {
+ return false
+ }
+ _, ok := c.viewPageMap[page][c.GetHash(id)]
+ return ok
+}
+
+func (c *ClientCache) GetViewCount(page string) int {
+ page = strings.Clone(page)
+
+ mutex := c.getPageMutex(page)
+ mutex.RLock()
+ defer mutex.RUnlock()
+
+ if userSet, ok := c.viewPageMap[page]; ok {
+ return len(userSet)
+ }
+ return 0
+}
+
+func (c *ClientCache) View(id string, page string) {
+ page = strings.Clone(page)
+ hash := c.GetHash(id)
+
+ mutex := c.getPageMutex(page)
+ mutex.Lock()
+ defer mutex.Unlock()
+
+ if _, ok := c.viewPageMap[page]; !ok {
+ c.viewPageMap[page] = make(map[string]struct{})
+ }
+ c.viewPageMap[page][hash] = struct{}{}
+}
+
+func batchSave(tx *sql.Tx, table string, pageMap map[string]map[string]struct{}) (err error) {
+ if _, err = tx.Exec(fmt.Sprintf("delete from %s;", table)); err != nil {
+ return fmt.Errorf("fail to truncate table %s: %w", table, err)
+ }
+
+ userIdBytes := make(map[string][]byte)
+
+ sqlStatement := fmt.Sprintf(`
+ INSERT OR IGNORE INTO %s (page_ref, user_id)
+ VALUES %s(?, ?);
+ `, table, strings.Repeat("(?, ?), ", 99))
+ sqlStatementVars := make([]any, 0, 200)
+
+ for pageRef, userSet := range pageMap {
+ for userId := range userSet {
+ if _, ok := userIdBytes[userId]; !ok {
+ userIdBytes[userId], err = base64.RawStdEncoding.DecodeString(userId)
+ if err != nil {
+ slog.Warn("couldn't parse one of user hashes into bytes back", slog.String("hash", userId), slog.String("error", err.Error()))
+ continue
+ }
+ }
+
+ sqlStatementVars = append(sqlStatementVars, any(pageRef), any(userIdBytes[userId]))
+ if len(sqlStatementVars) < 200 {
+ continue
+ }
+
+ if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil {
+ slog.Warn("couldn't insert blog stat pairs into db", slog.String("table", table), slog.String("error", err.Error()))
+ }
+
+ sqlStatementVars = make([]any, 0, 200)
+ }
+ }
+
+ if len(sqlStatementVars) > 0 {
+ sqlStatement = fmt.Sprintf(`
+ INSERT OR IGNORE INTO %s (page_ref, user_id)
+ VALUES %s(?, ?);
+ `, table, strings.Repeat("(?, ?), ", len(sqlStatementVars)/2-1))
+
+ if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil {
+ slog.Warn("couldn't insert blog stat pairs into db", slog.String("table", table), slog.String("error", err.Error()))
+ }
+ }
+
+ return nil
+}
diff --git a/internal/router/handlers/api-v1-blog-search.go b/internal/router/handlers/api-v1-blog-search.go
new file mode 100644
index 0000000..a0316e2
--- /dev/null
+++ b/internal/router/handlers/api-v1-blog-search.go
@@ -0,0 +1,126 @@
+package handlers
+
+import (
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/url"
+ "regexp"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type BlogSearchHandler struct {
+ router.BasicHandler
+ getTagsQuery *regexp.Regexp
+}
+
+func init() {
+ router.Routes = append(router.Routes, &BlogSearchHandler{getTagsQuery: regexp.MustCompile(`tags\[\]=([\w]+)`)})
+}
+
+func (r *BlogSearchHandler) Filter() (method string, path string) {
+ return "GET", "/api/v1/blog-search"
+}
+
+func (r *BlogSearchHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *BlogSearchHandler) TemplatesToInject() []string {
+ return []string{"views/partials/catalogue-blog-cards.html", "views/partials/catalogue-blog-card-tags.html"}
+}
+
+func (r *BlogSearchHandler) ToCache() router.CacheSetting {
+ return router.Disabled
+}
+
+func (r *BlogSearchHandler) ToValidateLang() router.LangSetting {
+ return router.InForm
+}
+
+func (r *BlogSearchHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ sort := c.Query("sort")
+ tz := c.Query("tz")
+
+ loc, err := time.LoadLocation(tz)
+ if err != nil {
+ loc = time.UTC
+ slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz))
+ }
+
+ cacheKey := "blog-search." + lang + ".pages-list"
+
+ var pages []*b2.BlogPage
+ if pagesBytes, ok := supplements.PageCache.Get(cacheKey); pagesBytes != nil || ok {
+ json.Unmarshal(pagesBytes, &pages)
+ } else {
+ pages, err = supplements.B2Client.Scan(lang + "/")
+ if err != nil {
+ return fiber.StatusInternalServerError, fmt.Errorf("failed to scan pages for '%s' lang: %w", lang, err)
+ }
+ pagesBytes, _ := json.Marshal(pages)
+ supplements.PageCache.SetWithTTL(cacheKey, pagesBytes, int64(len(pagesBytes)), r.CacheDuration())
+ }
+
+ encodedQuery := c.Request().URI().QueryString()
+ decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
+ matches := r.getTagsQuery.FindAllStringSubmatch(decodedQuery, -1)
+
+ tags := make([]string, 0, len(matches))
+ for _, match := range matches {
+ tags = append(tags, string(match[1]))
+ }
+
+ pageMeta := make([]fiber.Map, 0, len(pages))
+ for _, page := range pages {
+ for _, tag := range page.Metadata.Tags {
+ if len(tags) == 0 || slices.Contains(tags, tag) {
+ pageMeta = append(pageMeta, fiber.Map{
+ "Link": page.Link,
+ "ArticleLink": "/" + lang + "/blog/" + page.FileName,
+ "Title": page.Metadata.Title,
+ "PublishedTime": page.Metadata.PublishedTime.In(loc).Format("2006-01-02 15:04:05 -07:00"),
+ "ActionDate": page.Metadata.ActionDate,
+ "ShortDescription": page.Metadata.ShortDescription,
+ "Thumbnail": page.Metadata.Thumbnail,
+ "Tags": page.Metadata.Tags,
+ "LikeCount": supplements.ClientCache.GetLikeCount(page.FileName),
+ "ViewCount": supplements.ClientCache.GetViewCount(page.FileName),
+ })
+ break
+ }
+ }
+ }
+
+ slices.SortFunc(pageMeta, func(a, b fiber.Map) int {
+ switch sort {
+ case "titleAsc":
+ return strings.Compare(a["Title"].(string), b["Title"].(string))
+ case "titleDesc":
+ return strings.Compare(b["Title"].(string), a["Title"].(string))
+ case "actionDateAsc":
+ return strings.Compare(a["ActionDate"].(string), b["ActionDate"].(string))
+ case "actionDateDesc":
+ return strings.Compare(b["ActionDate"].(string), a["ActionDate"].(string))
+ case "publicationDateAsc":
+ publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string))
+ publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string))
+ return publishedTimeA.Compare(publishedTimeB)
+ case "publicationDateDesc":
+ publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string))
+ publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string))
+ return publishedTimeB.Compare(publishedTimeA)
+ }
+ return 0
+ })
+
+ templateMap["BlogPages"] = pageMeta
+
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/api-v1-email-is-in-verification.go b/internal/router/handlers/api-v1-email-is-in-verification.go
new file mode 100644
index 0000000..4f05f48
--- /dev/null
+++ b/internal/router/handlers/api-v1-email-is-in-verification.go
@@ -0,0 +1,59 @@
+package handlers
+
+import (
+ "fmt"
+ "html/template"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type OngoingVerificationHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &OngoingVerificationHandler{})
+}
+
+func (r *OngoingVerificationHandler) Filter() (method string, path string) {
+ return "GET", "/api/v1/email/is-in-verification"
+}
+
+func (r *OngoingVerificationHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *OngoingVerificationHandler) TemplatesToInject() []string {
+ return []string{"views/partials/personal-page-status.html"}
+}
+
+func (r *OngoingVerificationHandler) ToCache() router.CacheSetting {
+ return router.Disabled
+}
+
+func (r *OngoingVerificationHandler) ToValidateLang() router.LangSetting {
+ return router.InReferer
+}
+
+func (r *OngoingVerificationHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ isAllowed, whenAllowed, codeExpiry := supplements.Mailer.IsAllowedToRetryVerification(c.IP())
+
+ sterileDataset := make(map[string]any)
+ sterileDataset["code-expiry-time"] = template.HTMLAttr(fmt.Sprintf("data-code-expiry-time=\"%d\"", codeExpiry.UnixMilli()))
+
+ templateMap["StatusId"] = "email-message"
+ templateMap["DataAttributes"] = sterileDataset
+
+ if !isAllowed {
+ sterileDataset["striked-end-time"] = template.HTMLAttr(fmt.Sprintf("data-striked-end-time=\"%d\"", whenAllowed.UnixMilli()))
+ templateMap["Status"] = "Neutral"
+ templateMap["Message"] = strings.ReplaceAll(supplements.Localization[lang].UserProfile.DelayTilVerification, "{}", whenAllowed.Format("2006-01-02 15:04:05 MST"))
+ } else {
+ templateMap["Status"] = "OK"
+ templateMap["Message"] = ""
+ }
+
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/api-v1-email-send-verification-code.go b/internal/router/handlers/api-v1-email-send-verification-code.go
new file mode 100644
index 0000000..20c5fb6
--- /dev/null
+++ b/internal/router/handlers/api-v1-email-send-verification-code.go
@@ -0,0 +1,97 @@
+package handlers
+
+import (
+ "fmt"
+ "html/template"
+ "log/slog"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type SendVerificationCodeHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &SendVerificationCodeHandler{})
+}
+
+func (r *SendVerificationCodeHandler) Filter() (method string, path string) {
+ return "POST", "/api/v1/email/send-verification-code"
+}
+
+func (r *SendVerificationCodeHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *SendVerificationCodeHandler) TemplatesToInject() []string {
+ return []string{"views/partials/personal-page-status.html"}
+}
+
+func (r *SendVerificationCodeHandler) ToCache() router.CacheSetting {
+ return router.Disabled
+}
+
+func (r *SendVerificationCodeHandler) ToValidateLang() router.LangSetting {
+ return router.InReferer
+}
+
+func (r *SendVerificationCodeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ id := c.IP()
+ templateMap["StatusId"] = "email-message"
+
+ email := c.FormValue("email")
+ if email == "" {
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.EmailEmpty
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ isTaken, err := supplements.Mailer.MailIsTaken(email)
+ if err != nil {
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationCodeSendingError
+ slog.Error("failed to check if address is already taken", slog.String("error", err.Error()))
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ if isTaken {
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.EmailTaken
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ if isAllowed, whenAllowed, _ := supplements.Mailer.IsAllowedToRetryVerification(id); !isAllowed {
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = strings.ReplaceAll(supplements.Localization[lang].UserProfile.DelayTilVerification, "{}", whenAllowed.Format("2006-01-02 15:04:05 MST"))
+ templateMap["DataAttributes"] = map[string]any{
+ "striked-end-time": template.HTMLAttr(fmt.Sprintf("data-striked-end-time=\"%d\"", whenAllowed.UnixMilli())),
+ }
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ if previousEmail, _, _ := supplements.Mailer.GetInfo(supplements.Mailer.GetHash(id)); previousEmail == email {
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.EmailAlreadyValidated
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ if err = supplements.Mailer.SendVerificationCode(id, email, lang); err != nil {
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationCodeSendingError
+ slog.Error("failed to send a verification code", slog.String("error", err.Error()))
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ _, endTime, codeExpiry := supplements.Mailer.IsAllowedToRetryVerification(id)
+ templateMap["Status"] = "OK"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationCodeSent
+ templateMap["DataAttributes"] = map[string]any{
+ "striked-end-time": template.HTMLAttr(fmt.Sprintf("data-striked-end-time=\"%d\"", endTime.UnixMilli())),
+ "code-expiry-time": template.HTMLAttr(fmt.Sprintf("data-code-expiry-time=\"%d\"", codeExpiry.UnixMilli())),
+ }
+
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/api-v1-email-verify.go b/internal/router/handlers/api-v1-email-verify.go
new file mode 100644
index 0000000..c9ec969
--- /dev/null
+++ b/internal/router/handlers/api-v1-email-verify.go
@@ -0,0 +1,62 @@
+package handlers
+
+import (
+ "html/template"
+ "log/slog"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type VerifyCodeHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &VerifyCodeHandler{})
+}
+
+func (r *VerifyCodeHandler) Filter() (method string, path string) {
+ return "POST", "/api/v1/email/verify"
+}
+
+func (r *VerifyCodeHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *VerifyCodeHandler) TemplatesToInject() []string {
+ return []string{"views/partials/personal-page-status.html"}
+}
+
+func (r *VerifyCodeHandler) ToCache() router.CacheSetting {
+ return router.Disabled
+}
+
+func (r *VerifyCodeHandler) ToValidateLang() router.LangSetting {
+ return router.InReferer
+}
+
+func (r *VerifyCodeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ verificationCode := c.FormValue("email_code")
+ templateMap["StatusId"] = "verification-message"
+
+ if verificationCode == "" {
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationEmpty
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ if err = supplements.Mailer.Verify(verificationCode, lang); err != nil {
+ slog.Warn("verification code is invalid", slog.String("verification_code", verificationCode), slog.String("error", err.Error()))
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationFailed
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ templateMap["Status"] = "OK"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationSuccess
+ templateMap["DataAttributes"] = map[string]any{
+ "hide-verification-panel": template.HTMLAttr("data-code-expiry-time=\"true\""),
+ }
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/api-v1-like-get.go b/internal/router/handlers/api-v1-like-get.go
new file mode 100644
index 0000000..e9c176e
--- /dev/null
+++ b/internal/router/handlers/api-v1-like-get.go
@@ -0,0 +1,63 @@
+package handlers
+
+import (
+ "fmt"
+ "log/slog"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type GetLikeHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &GetLikeHandler{})
+}
+
+func (r *GetLikeHandler) Filter() (method string, path string) {
+ return "GET", "/api/v1/like"
+}
+
+func (r *GetLikeHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *GetLikeHandler) TemplatesToInject() []string {
+ return []string{"views/partials/blog-page-like-button.html"}
+}
+
+func (r *GetLikeHandler) ToCache() router.CacheSetting {
+ return router.Disabled
+}
+
+func (r *GetLikeHandler) ToValidateLang() router.LangSetting {
+ return router.InReferer
+}
+
+func (r *GetLikeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ path, pathParts, _, err := router.GetPathFromReferer(c)
+ if err != nil {
+ return fiber.StatusBadRequest, fmt.Errorf("error getting path from referer: %w", err)
+ }
+
+ if len(pathParts) != 3 && pathParts[1] != "blog" {
+ return fiber.StatusBadRequest, fmt.Errorf("invalid path format: expected '/:lang/blog/:page', got '%s'", path)
+ }
+
+ page := pathParts[2]
+ pageLink := lang + "/" + page + ".md"
+ if pages, _ := supplements.B2Client.Scan(pageLink); len(pages) == 0 {
+ return fiber.StatusNotFound, fmt.Errorf("server did not find '%s' article", pageLink)
+ }
+
+ ip := c.IP()
+ likeStatus := supplements.ClientCache.GetLikeStatus(ip, page)
+
+ slog.Debug("someone requested the like status!", slog.String("ip", ip), slog.String("page", page), slog.Bool("like_status", likeStatus))
+ templateMap["Liked"] = likeStatus
+ templateMap["LikedCount"] = supplements.ClientCache.GetLikeCount(page)
+
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/api-v1-like-put.go b/internal/router/handlers/api-v1-like-put.go
new file mode 100644
index 0000000..ea4ef4b
--- /dev/null
+++ b/internal/router/handlers/api-v1-like-put.go
@@ -0,0 +1,74 @@
+package handlers
+
+import (
+ "fmt"
+ "log/slog"
+ "strconv"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type PutLikeHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &PutLikeHandler{})
+}
+
+func (r *PutLikeHandler) Filter() (method string, path string) {
+ return "PUT", "/api/v1/like"
+}
+
+func (r *PutLikeHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *PutLikeHandler) TemplatesToInject() []string {
+ return []string{"views/partials/blog-page-like-button.html"}
+}
+
+func (r *PutLikeHandler) ToCache() router.CacheSetting {
+ return router.Disabled
+}
+
+func (r *PutLikeHandler) ToValidateLang() router.LangSetting {
+ return router.InReferer
+}
+
+func (r *PutLikeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ path, pathParts, _, err := router.GetPathFromReferer(c)
+ if err != nil {
+ return fiber.StatusBadRequest, fmt.Errorf("error getting path from referer: %w", err)
+ }
+
+ if len(pathParts) != 3 && pathParts[1] != "blog" {
+ return fiber.StatusBadRequest, fmt.Errorf("invalid path format: expected '/:lang/blog/:page', got '%s'", path)
+ }
+
+ page := pathParts[2]
+ pageLink := lang + "/" + page + ".md"
+ if pages, _ := supplements.B2Client.Scan(pageLink); len(pages) == 0 {
+ return fiber.StatusNotFound, fmt.Errorf("server did not find '%s' article", pageLink)
+ }
+
+ newLikeStatus, err := strconv.ParseBool(c.FormValue("like", "true"))
+ if err != nil {
+ return fiber.StatusBadRequest, fmt.Errorf("invalid like value '%s'", c.FormValue("like"))
+ }
+
+ ip := c.IP()
+ if newLikeStatus {
+ supplements.ClientCache.LikeOn(ip, page)
+ } else {
+ supplements.ClientCache.LikeOff(ip, page)
+ }
+
+ slog.Debug("someone pressed the like button!", slog.String("ip", ip), slog.String("page", page), slog.String("new_like_status", fmt.Sprint(newLikeStatus)))
+ templateMap["Liked"] = newLikeStatus
+ templateMap["LikedCount"] = supplements.ClientCache.GetLikeCount(page)
+ templateMap["StatusId"] = "email-message"
+
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/api-v1-subs-put.go b/internal/router/handlers/api-v1-subs-put.go
new file mode 100644
index 0000000..144abe5
--- /dev/null
+++ b/internal/router/handlers/api-v1-subs-put.go
@@ -0,0 +1,65 @@
+package handlers
+
+import (
+ "github.com/SayaAndy/saya-today-web/internal/mailer"
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type PutSubsHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &PutSubsHandler{})
+}
+
+func (r *PutSubsHandler) Filter() (method string, path string) {
+ return "PUT", "/api/v1/subs"
+}
+
+func (r *PutSubsHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *PutSubsHandler) TemplatesToInject() []string {
+ return []string{"views/partials/personal-page-status.html"}
+}
+
+func (r *PutSubsHandler) ToCache() router.CacheSetting {
+ return router.Disabled
+}
+
+func (r *PutSubsHandler) ToValidateLang() router.LangSetting {
+ return router.InReferer
+}
+
+func (r *PutSubsHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ templateMap["StatusId"] = "subs-message"
+
+ subscriptionType := c.FormValue("tags")
+ var subscriptionTypeEnum mailer.SubscriptionType
+ switch subscriptionType {
+ case "all":
+ subscriptionTypeEnum = mailer.All
+ case "none":
+ subscriptionTypeEnum = mailer.None
+ case "specific":
+ subscriptionTypeEnum = mailer.Specific
+ default:
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.SubscribeInvalidType
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ specificTags := c.FormValue("tags_picked")
+ if err = supplements.Mailer.Subscribe(supplements.Mailer.GetHash(c.IP()), subscriptionTypeEnum, specificTags); err != nil {
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.FailedToSubscribe
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ templateMap["Status"] = "OK"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.SubscribedSuccessfully
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/api-v1-tz-get.go b/internal/router/handlers/api-v1-tz-get.go
new file mode 100644
index 0000000..8b08113
--- /dev/null
+++ b/internal/router/handlers/api-v1-tz-get.go
@@ -0,0 +1,65 @@
+package handlers
+
+import (
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type GetTimezoneHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &GetTimezoneHandler{})
+}
+
+func (r *GetTimezoneHandler) Filter() (method string, path string) {
+ return "GET", "/api/v1/tz"
+}
+
+func (r *GetTimezoneHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *GetTimezoneHandler) TemplatesToInject() []string {
+ return []string{}
+}
+
+func (r *GetTimezoneHandler) ToCache() router.CacheSetting {
+ return router.Disabled
+}
+
+func (r *GetTimezoneHandler) ToValidateLang() router.LangSetting {
+ return router.NotRequired
+}
+
+func (r *GetTimezoneHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ timestampString := c.Query("timestamp")
+ tz := c.Query("tz")
+
+ loc, err := time.LoadLocation(tz)
+ if err != nil {
+ loc = time.UTC
+ slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz))
+ }
+
+ var format string
+ var timestamp time.Time
+ for _, format = range []string{"2006-01-02 15:04:05 -07:00", time.RFC3339} {
+ if timestamp, err = time.Parse(format, timestampString); err == nil {
+ break
+ }
+ }
+
+ if err != nil {
+ return fiber.StatusBadRequest, fmt.Errorf("invalid timestamp format for '%s'", timestampString)
+ }
+
+ templateMap["Output"] = []byte(timestamp.In(loc).Format("2006-01-02 15:04:05 -07:00"))
+
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/lang-blog-title.go b/internal/router/handlers/lang-blog-title.go
new file mode 100644
index 0000000..5bfc6e7
--- /dev/null
+++ b/internal/router/handlers/lang-blog-title.go
@@ -0,0 +1,163 @@
+package handlers
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/internal/frontmatter"
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+ "github.com/yuin/goldmark"
+)
+
+func init() {
+ router.Routes = append(router.Routes, &BlogPageHandler{})
+}
+
+type BlogPageHandler struct {
+ router.BasicHandler
+}
+
+func (r *BlogPageHandler) Filter() (method string, path string) {
+ return "GET", "/:lang/blog/:title"
+}
+
+func (r *BlogPageHandler) IsTemplated() bool {
+ return true
+}
+
+func (r *BlogPageHandler) TemplatesToInject() []string {
+ return []string{"views/pages/blog-page.html"}
+}
+
+func (r *BlogPageHandler) ToCache() router.CacheSetting {
+ return router.ByUrlOnly
+}
+
+func (r *BlogPageHandler) CacheDuration() time.Duration {
+ return 15 * time.Minute
+}
+
+func (r *BlogPageHandler) ToValidateLang() router.LangSetting {
+ return router.InPath
+}
+
+func (r *BlogPageHandler) SitemapInfo(supplements *router.Supplements) []router.SitemapInfo {
+ sitemapInfo := []router.SitemapInfo{}
+
+ pages, err := supplements.B2Client.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.B2Client.ReadFrontmatter(lang + "/" + c.Params("title") + ".md")
+ if err != nil {
+ return nil, fmt.Errorf("failed to read frontmatter of the desired blog post: %w", err)
+ }
+
+ return []router.MetaField{
+ {Property: "og:title", Content: metadata.Title},
+ {Property: "og:description", Content: fmt.Sprintf("%s [%s]", metadata.ShortDescription, metadata.ActionDate)},
+ {Property: "og:image", Content: fmt.Sprintf("https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp", 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.B2Client.ReadFrontmatter(lang + "/" + c.Params("title") + ".md")
+ if err != nil {
+ return nil, fmt.Errorf("failed to read frontmatter of the desired blog post: %w", err)
+ }
+
+ return map[string]any{
+ "@context": "https://schema.org",
+ "@type": "Article",
+ "headline": metadata.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)
+ }
+
+ metadata, parsedMarkdown, err := readBlogPost(supplements.MarkdownRenderer, supplements.B2Client, lang+"/"+pathParts[2])
+ if err != nil {
+ return fiber.StatusNotFound, fmt.Errorf("failed to find '%s' post: %w", pathParts[2], err)
+ }
+
+ geolocationParts := strings.Split(metadata.Geolocation, " ")
+ var x, y, areaError string
+ if len(geolocationParts) >= 2 {
+ x = geolocationParts[0]
+ y = geolocationParts[1]
+ }
+ if len(geolocationParts) >= 3 {
+ areaError = geolocationParts[2]
+ }
+
+ templateMap["MapLocationX"] = x
+ templateMap["MapLocationY"] = y
+ templateMap["MapLocationAreaMeters"] = areaError
+ templateMap["Title"] = metadata.Title
+ templateMap["ParsedMarkdown"] = template.HTML(parsedMarkdown)
+ templateMap["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00")
+ templateMap["ActionDate"] = metadata.ActionDate
+ templateMap["ShortDescription"] = metadata.ShortDescription
+
+ go supplements.ClientCache.View(c.IP(), pathParts[2])
+
+ return fiber.StatusOK, nil
+}
+
+func (r *BlogPageHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ path, pathParts, _, err := router.GetPathFromReferer(c)
+ if err != nil {
+ return fiber.StatusBadRequest, fmt.Errorf("failed to get path from referer: %w", err)
+ }
+
+ metadata, _, err := supplements.B2Client.ReadFrontmatter(lang + "/" + pathParts[2] + ".md")
+ if err != nil {
+ return fiber.StatusNotFound, fmt.Errorf("could not read '%s' for metadata: %w", path, err)
+ }
+
+ templateMap["Title"] = metadata.Title
+
+ return fiber.StatusOK, nil
+}
+
+func readBlogPost(md goldmark.Markdown, b2Client *b2.B2Client, sourceName string) (metadata *frontmatter.Metadata, html string, err error) {
+ metadata, markdown, err := b2Client.ReadFrontmatter(sourceName + ".md")
+ if err != nil {
+ return nil, "", fmt.Errorf("failed to read a frontmatter file: %w", err)
+ }
+
+ var buf bytes.Buffer
+ if err := md.Convert(markdown, &buf); err != nil {
+ return nil, "", fmt.Errorf("convert source context from md to html: %w", err)
+ }
+
+ return metadata, buf.String(), nil
+}
diff --git a/internal/router/handlers/lang-blog.go b/internal/router/handlers/lang-blog.go
new file mode 100644
index 0000000..709d3dc
--- /dev/null
+++ b/internal/router/handlers/lang-blog.go
@@ -0,0 +1,129 @@
+package handlers
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "regexp"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ router.Routes = append(router.Routes, &CatalogueHandler{getTagsQuery: regexp.MustCompile(`tags\[\]=([\w]+)`)})
+}
+
+type CatalogueHandler struct {
+ router.BasicHandler
+ getTagsQuery *regexp.Regexp
+}
+
+func (r *CatalogueHandler) Filter() (method string, path string) {
+ return "GET", "/:lang/blog"
+}
+
+func (r *CatalogueHandler) IsTemplated() bool {
+ return true
+}
+
+func (r *CatalogueHandler) TemplatesToInject() []string {
+ return []string{"views/pages/blog-catalogue.html"}
+}
+
+func (r *CatalogueHandler) ToCache() router.CacheSetting {
+ return router.ByUrlAndQuery
+}
+
+func (r *CatalogueHandler) CacheDuration() time.Duration {
+ return 5 * time.Minute
+}
+
+func (r *CatalogueHandler) ToValidateLang() router.LangSetting {
+ return router.InPath
+}
+
+func (r *CatalogueHandler) 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: supplements.Localization[lang].BlogSearch.Header},
+ {Property: "og:description", Content: supplements.Localization[lang].BlogSearch.Description},
+ {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")
+ if querySort == "" {
+ querySort = "publicationDateDesc"
+ }
+
+ encodedQuery := c.Request().URI().QueryString()
+ decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
+ matches := r.getTagsQuery.FindAllStringSubmatch(decodedQuery, -1)
+
+ queryTags := make([]string, 0, len(matches))
+ for _, match := range matches {
+ queryTags = append(queryTags, string(match[1]))
+ }
+
+ tagsArray, err := getTags(supplements.B2Client, lang)
+ if err != nil {
+ return fiber.StatusInternalServerError, fmt.Errorf("failed to get the available tags")
+ }
+
+ templateMap["Tags"] = tagsArray
+ templateMap["QuerySort"] = querySort
+ templateMap["QueryTags"] = strings.Join(queryTags, ",")
+ templateMap["Title"] = supplements.Localization[lang].BlogSearch.Header
+
+ return fiber.StatusOK, nil
+}
+
+func (r *CatalogueHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ templateMap["Title"] = supplements.Localization[lang].BlogSearch.Header
+ return fiber.StatusOK, nil
+}
+
+type Tag struct {
+ Name string `json:"Name" yaml:"name"`
+ Count int `json:"Count" yaml:"count"`
+}
+
+func getTags(b2Client *b2.B2Client, lang string) (tags []Tag, err error) {
+ pages, err := b2Client.Scan(lang + "/")
+ if err != nil {
+ slog.Warn("failed to scan pages via b2", slog.String("error", err.Error()))
+ return nil, fmt.Errorf("failed to scan pages via b2: %w", err)
+ }
+
+ tagsMap := make(map[string]int)
+ for _, page := range pages {
+ for _, tag := range page.Metadata.Tags {
+ tagsMap[tag]++
+ }
+ }
+ slog.Debug("enlist pages for catalogue", slog.Int("tag_count", len(tagsMap)), slog.Int("page_count", len(pages)), slog.String("lang", lang))
+
+ tagsArray := make([]Tag, 0, len(tagsMap))
+ for tag, count := range tagsMap {
+ tagsArray = append(tagsArray, Tag{tag, count})
+ }
+ slices.SortFunc(tagsArray, func(a Tag, b Tag) int {
+ return strings.Compare(a.Name, b.Name)
+ })
+
+ return tagsArray, nil
+}
diff --git a/internal/router/handlers/lang-map.go b/internal/router/handlers/lang-map.go
new file mode 100644
index 0000000..551b485
--- /dev/null
+++ b/internal/router/handlers/lang-map.go
@@ -0,0 +1,109 @@
+package handlers
+
+import (
+ "fmt"
+ "log/slog"
+ "slices"
+ "strconv"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type MapHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &MapHandler{})
+}
+
+func (r *MapHandler) Filter() (method string, path string) {
+ return "GET", "/:lang/map"
+}
+
+func (r *MapHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *MapHandler) TemplatesToInject() []string {
+ return []string{"views/pages/global-map.html"}
+}
+
+func (r *MapHandler) ToCache() router.CacheSetting {
+ return router.Disabled
+}
+
+func (r *MapHandler) ToValidateLang() router.LangSetting {
+ return router.InPath
+}
+
+func (r *MapHandler) 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) {
+ pages, err := supplements.B2Client.Scan(lang + "/")
+ slices.SortFunc(pages, func(a *b2.BlogPage, b *b2.BlogPage) int {
+ return a.Metadata.PublishedTime.Compare(b.Metadata.PublishedTime)
+ })
+ status := fiber.StatusOK
+ if err != nil {
+ slog.Error("received an error while scanning b2 pages",
+ slog.String("error", err.Error()),
+ slog.String("lang", lang),
+ )
+ status = fiber.StatusPartialContent
+ pages = []*b2.BlogPage{}
+ }
+
+ type MapMarker struct {
+ 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"`
+ }
+
+ 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)
+ }
+
+ 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,
+ })
+ }
+
+ templateMap["MapMarkers"] = mapMarkers
+ templateMap["MapLocationLat"] = 45.4507
+ templateMap["MapLocationLong"] = 68.8319
+
+ return status, nil
+}
diff --git a/internal/router/handlers/lang-user-unsubscribe.go b/internal/router/handlers/lang-user-unsubscribe.go
new file mode 100644
index 0000000..4fbd244
--- /dev/null
+++ b/internal/router/handlers/lang-user-unsubscribe.go
@@ -0,0 +1,68 @@
+package handlers
+
+import (
+ "log/slog"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type UnsubscribeHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &UnsubscribeHandler{})
+}
+
+func (r *UnsubscribeHandler) Filter() (method string, path string) {
+ return "GET", "/:lang/user/unsubscribe"
+}
+
+func (r *UnsubscribeHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *UnsubscribeHandler) TemplatesToInject() []string {
+ return []string{"views/pages/unsubscribe-page.html"}
+}
+
+func (r *UnsubscribeHandler) ToValidateLang() router.LangSetting {
+ return router.InPath
+}
+
+func (r *UnsubscribeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ var statusEmoji, statusText, statusColor string
+ var status int
+
+ unsubscribeCode := c.FormValue("code")
+ if unsubscribeCode == "" {
+ statusColor = "0, 0, 255"
+ statusEmoji = "(╭ರ_•́)"
+ statusText = supplements.Localization[lang].UnsubscribePage.UnsetCode
+ status = fiber.ErrBadRequest.Code
+ } else if clientError, serverError := supplements.Mailer.Unsubscribe(unsubscribeCode); clientError != nil {
+ slog.Info("got a client error when unsubscribing", slog.String("error", clientError.Error()))
+ statusColor = "255, 0, 0"
+ statusEmoji = "(͠≖~≖ ͡ )"
+ statusText = supplements.Localization[lang].UnsubscribePage.InvalidCode
+ status = fiber.ErrBadRequest.Code
+ } else if serverError != nil {
+ slog.Error("got a server error when unsubscribing", slog.String("error", serverError.Error()))
+ statusColor = "255, 128, 0"
+ statusEmoji = "( ˶°ㅁ°) !!"
+ statusText = supplements.Localization[lang].UnsubscribePage.OnServerError
+ status = fiber.ErrInternalServerError.Code
+ } else {
+ statusColor = "0, 255, 0"
+ statusEmoji = "♡⸜(˶˃ ᵕ ˂˶)⸝♡"
+ statusText = supplements.Localization[lang].UnsubscribePage.Success
+ status = fiber.StatusOK
+ }
+
+ templateMap["StatusColor"] = statusColor
+ templateMap["StatusEmoji"] = statusEmoji
+ templateMap["StatusText"] = statusText
+
+ return status, nil
+}
diff --git a/internal/router/handlers/lang-user.go b/internal/router/handlers/lang-user.go
new file mode 100644
index 0000000..eae1685
--- /dev/null
+++ b/internal/router/handlers/lang-user.go
@@ -0,0 +1,88 @@
+package handlers
+
+import (
+ "fmt"
+ "log/slog"
+
+ "github.com/SayaAndy/saya-today-web/internal/mailer"
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ router.Routes = append(router.Routes, &UserHandler{})
+}
+
+type UserHandler struct {
+ router.BasicHandler
+}
+
+func (r *UserHandler) Filter() (method string, path string) {
+ return "GET", "/:lang/user"
+}
+
+func (r *UserHandler) IsTemplated() bool {
+ return true
+}
+
+func (r *UserHandler) TemplatesToInject() []string {
+ return []string{"views/pages/user-page.html"}
+}
+
+func (r *UserHandler) ToCache() router.CacheSetting {
+ return router.Disabled
+}
+
+func (r *UserHandler) ToValidateLang() router.LangSetting {
+ return router.InPath
+}
+
+func (r *UserHandler) 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: supplements.Localization[lang].UserProfile.Header},
+ {Property: "og:description", Content: supplements.Localization[lang].UserProfile.Description},
+ {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"] = supplements.Localization[lang].UserProfile.Header
+
+ email, _, err := supplements.Mailer.GetInfo(supplements.Mailer.GetHash(c.IP()))
+ if err != nil {
+ slog.Error("get info from mailer about a client", slog.String("error", err.Error()))
+ }
+
+ tagsArray, err := getTags(supplements.B2Client, lang)
+ if err != nil {
+ return fiber.ErrInternalServerError.Code, fmt.Errorf("failed to get the available tags")
+ }
+
+ subscriptionType, tags, err := supplements.Mailer.GetSubscriptions(c.IP())
+ if err != nil {
+ return fiber.ErrInternalServerError.Code, fmt.Errorf("failed to get the user subscriptions")
+ }
+
+ switch subscriptionType {
+ case mailer.None:
+ templateMap["TagsPicked"] = "none"
+ case mailer.All:
+ templateMap["TagsPicked"] = "all"
+ case mailer.Specific:
+ templateMap["TagsPicked"] = "specific"
+ }
+
+ templateMap["TagsPickedList"] = tags
+
+ templateMap["Email"] = email
+ templateMap["EmailCode"] = c.Query("email_code")
+ templateMap["ExistingTags"] = tagsArray
+ return fiber.StatusOK, nil
+}
+
+func (r *UserHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ templateMap["Title"] = supplements.Localization[lang].UserProfile.Header
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/lang.go b/internal/router/handlers/lang.go
new file mode 100644
index 0000000..c618353
--- /dev/null
+++ b/internal/router/handlers/lang.go
@@ -0,0 +1,74 @@
+package handlers
+
+import (
+ "fmt"
+ "math/rand/v2"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ router.Routes = append(router.Routes, &HomeHandler{})
+}
+
+type HomeHandler struct {
+ router.BasicHandler
+}
+
+func (r *HomeHandler) Filter() (method string, path string) {
+ return "GET", "/:lang<len(2)>"
+}
+
+func (r *HomeHandler) IsTemplated() bool {
+ return true
+}
+
+func (r *HomeHandler) TemplatesToInject() []string {
+ return []string{"views/pages/home-page.html"}
+}
+
+func (r *HomeHandler) ToCache() router.CacheSetting {
+ return router.ByUrlOnly
+}
+
+func (r *HomeHandler) CacheDuration() time.Duration {
+ return 10 * time.Minute
+}
+
+func (r *HomeHandler) ToValidateLang() router.LangSetting {
+ return router.InPath
+}
+
+func (r *HomeHandler) 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: supplements.Localization[lang].HomePage.Header},
+ {Property: "og:description", Content: supplements.Localization[lang].HomePage.HomePageDescription},
+ {Property: "og:image", Content: fmt.Sprintf("https://f003.backblazeb2.com/file/sayana-static/home-page-gifs/otter-%d.gif", rand.Int()%3+1)},
+ {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"] = supplements.Localization[lang].HomePage.Header
+ templateMap["FilledHeartCount"] = uint(40)
+ templateMap["OutlineHeartCount"] = uint(40)
+ templateMap["GifName"] = fmt.Sprintf("otter-%d.gif", rand.Int()%3+1)
+ templateMap["FunFacts"] = supplements.FactGiver.Give(lang)
+ return fiber.StatusOK, nil
+}
+
+func (r *HomeHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ templateMap["Title"] = supplements.Localization[lang].HomePage.Header
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/robots.txt.go b/internal/router/handlers/robots.txt.go
new file mode 100644
index 0000000..fc60af4
--- /dev/null
+++ b/internal/router/handlers/robots.txt.go
@@ -0,0 +1,48 @@
+package handlers
+
+import (
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type RobotsTxtHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &RobotsTxtHandler{})
+}
+
+func (r *RobotsTxtHandler) Filter() (method string, path string) {
+ return "GET", "/robots.txt"
+}
+
+func (r *RobotsTxtHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *RobotsTxtHandler) TemplatesToInject() []string {
+ return []string{"views/pages/robots.txt"}
+}
+
+func (r *RobotsTxtHandler) ToCache() router.CacheSetting {
+ return router.ByUrlOnly
+}
+
+func (r *RobotsTxtHandler) CacheDuration() time.Duration {
+ return time.Hour
+}
+
+func (r *RobotsTxtHandler) ToValidateLang() router.LangSetting {
+ return router.NotRequired
+}
+
+func (r *RobotsTxtHandler) ContentType() string {
+ return fiber.MIMETextPlainCharsetUTF8
+}
+
+func (r *RobotsTxtHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/root.go b/internal/router/handlers/root.go
new file mode 100644
index 0000000..a8890fa
--- /dev/null
+++ b/internal/router/handlers/root.go
@@ -0,0 +1,65 @@
+package handlers
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ router.Routes = append(router.Routes, &RootHandler{})
+}
+
+type RootHandler struct {
+ router.BasicHandler
+}
+
+func (r *RootHandler) Filter() (method string, path string) {
+ return "GET", "/"
+}
+
+func (r *RootHandler) IsTemplated() bool {
+ return true
+}
+
+func (r *RootHandler) TemplatesToInject() []string {
+ return []string{"views/pages/language-pick.html"}
+}
+
+func (r *RootHandler) ToCache() router.CacheSetting {
+ return router.ByUrlOnly
+}
+
+func (r *RootHandler) CacheDuration() time.Duration {
+ return 24 * time.Hour
+}
+
+func (r *RootHandler) SitemapInfo(_ *router.Supplements) []router.SitemapInfo {
+ return []router.SitemapInfo{{Loc: "/", Priority: 0.7}}
+}
+
+func (r *RootHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta []router.MetaField, err error) {
+ meta = []router.MetaField{
+ {Property: "og:title", Content: "Saya Blog"},
+ {Property: "og:description", Content: "The blog of a 25 years old, travelling God knows where, proud to be not mainstream // Личный блог 25-летки, странствующего по ебеням, зато не мейнстрим"},
+ {Property: "og:url", Content: fmt.Sprint(templateMap["CanonicalEndpoint"]) + "/"},
+ {Property: "og:type", Content: "website"},
+ }
+ if supplements.Meta.GoogleSiteVerification != "" {
+ meta = append(meta, router.MetaField{Name: "google-site-verification", Content: supplements.Meta.GoogleSiteVerification})
+ }
+ return meta, nil
+}
+
+func (r *RootHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, _ string, templateMap fiber.Map) (statusCode int, err error) {
+ templateMap["Title"] = "Choose Your Language"
+ templateMap["AvailableLanguages"] = supplements.AvailableLanguages
+ return fiber.StatusOK, nil
+}
+
+func (r *RootHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, _ string, templateMap fiber.Map) (statusCode int, err error) {
+ templateMap["Title"] = "Choose Your Language"
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/sitemap.xml.go b/internal/router/handlers/sitemap.xml.go
new file mode 100644
index 0000000..bb1df93
--- /dev/null
+++ b/internal/router/handlers/sitemap.xml.go
@@ -0,0 +1,79 @@
+package handlers
+
+import (
+ "log/slog"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type SitemapXmlHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &SitemapXmlHandler{})
+}
+
+func (r *SitemapXmlHandler) Filter() (method string, path string) {
+ return "GET", "/sitemap.xml"
+}
+
+func (r *SitemapXmlHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *SitemapXmlHandler) TemplatesToInject() []string {
+ return []string{"views/pages/sitemap.xml"}
+}
+
+func (r *SitemapXmlHandler) ToCache() router.CacheSetting {
+ return router.ByUrlOnly
+}
+
+func (r *SitemapXmlHandler) CacheDuration() time.Duration {
+ return time.Hour
+}
+
+func (r *SitemapXmlHandler) ToValidateLang() router.LangSetting {
+ return router.NotRequired
+}
+
+func (r *SitemapXmlHandler) ContentType() string {
+ return fiber.MIMEApplicationXMLCharsetUTF8
+}
+
+func (r *SitemapXmlHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ urls := []router.SitemapInfo{}
+ for _, route := range router.Routes {
+ sitemapInfo := route.SitemapInfo(supplements)
+ if sitemapInfo == nil {
+ continue
+ }
+
+ method, match := route.Filter()
+ lastMod, err := supplements.TemplateManager.GetLastModified(method + " " + match)
+ if err != nil {
+ slog.Warn("failed to get last modified for a route",
+ slog.String("error", err.Error()),
+ slog.String("method", method),
+ slog.String("match", match))
+ lastMod = time.Now()
+ }
+
+ for i := range sitemapInfo {
+ if sitemapInfo[i].LastModified.Before(lastMod) {
+ sitemapInfo[i].LastModified = lastMod
+ }
+ if sitemapInfo[i].ChangeFreq == "" {
+ sitemapInfo[i].ChangeFreq = "monthly"
+ }
+ urls = append(urls, sitemapInfo[i])
+ }
+ }
+
+ templateMap["URLs"] = urls
+
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/path-matcher.go b/internal/router/path-matcher.go
new file mode 100644
index 0000000..c8aef08
--- /dev/null
+++ b/internal/router/path-matcher.go
@@ -0,0 +1,48 @@
+package router
+
+import (
+ "github.com/gofiber/fiber/v2"
+ "github.com/valyala/fasthttp"
+)
+
+type PathMatcher struct {
+ app *fiber.App
+}
+
+func NewPathMatcher() *PathMatcher {
+ app := fiber.New(fiber.Config{
+ DisableStartupMessage: true,
+ })
+ return &PathMatcher{app: app}
+}
+
+func (pm *PathMatcher) AddRoute(method, pattern string) {
+ pm.app.Add(method, pattern, func(c *fiber.Ctx) error {
+ c.Locals("pattern", pattern)
+ return nil
+ })
+}
+
+func (pm *PathMatcher) MatchPath(method, path string) (pattern string, params map[string]string, matched bool) {
+ fctx := &fasthttp.RequestCtx{}
+ fctx.Request.Header.SetMethod(method)
+ fctx.Request.SetRequestURI(path)
+
+ ctx := pm.app.AcquireCtx(fctx)
+ defer pm.app.ReleaseCtx(ctx)
+
+ pm.app.Handler()(fctx)
+
+ if ctx.Route() == nil {
+ return "", nil, false
+ }
+
+ pattern = ctx.Locals("pattern").(string)
+
+ params = make(map[string]string)
+ for _, paramName := range ctx.Route().Params {
+ params[paramName] = ctx.Params(paramName)
+ }
+
+ return pattern, params, true
+}
diff --git a/internal/router/router.go b/internal/router/router.go
new file mode 100644
index 0000000..3242759
--- /dev/null
+++ b/internal/router/router.go
@@ -0,0 +1,622 @@
+package router
+
+import (
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net/url"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/internal/blogtrigger"
+ "github.com/SayaAndy/saya-today-web/internal/factgiver"
+ "github.com/SayaAndy/saya-today-web/internal/glightbox"
+ "github.com/SayaAndy/saya-today-web/internal/mailer"
+ "github.com/SayaAndy/saya-today-web/internal/tailwind"
+ "github.com/SayaAndy/saya-today-web/internal/templatemanager"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/dgraph-io/ristretto/v2"
+ "github.com/gofiber/fiber/v2"
+ "github.com/gofiber/fiber/v2/middleware/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
+ 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
+ B2Client *b2.B2Client
+ Localization map[string]*locale.LocaleConfig
+ AvailableLanguages []config.AvailableLanguageConfig
+ ClientCache *ClientCache
+ PageCache *ristretto.Cache[string, []byte]
+ FactGiver *factgiver.FactGiver
+ Mailer *mailer.Mailer
+ BlogTrigger *blogtrigger.BlogTriggerScheduler
+ TemplateManager *templatemanager.TemplateManager
+ MarkdownRenderer goldmark.Markdown
+ Meta config.MetaConfig
+}
+
+type Router struct {
+ supplements *Supplements
+ app *fiber.App
+ templatedRoutes map[string]map[string]Route
+ templatedPathMatcher *PathMatcher
+ canonicalEndpoint string
+}
+
+func NewRouter(cfg *config.Config) (*Router, error) {
+ supplements := &Supplements{
+ AvailableLanguages: cfg.AvailableLanguages,
+ }
+
+ var err error
+
+ supplements.DB, err = sql.Open(cfg.Auth.Db.Type, cfg.Auth.Db.Cfg.DSN)
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize db: %w", err)
+ }
+
+ driver, err := sqlite3.WithInstance(supplements.DB, &sqlite3.Config{})
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize driver for migrating db: %w", err)
+ }
+
+ m, err := migrate.NewWithDatabaseInstance(
+ "file://migrations",
+ cfg.Auth.Db.Type, driver)
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize migration client: %w", err)
+ }
+
+ if err = m.Up(); err != nil && err == errors.New("no change") {
+ return nil, fmt.Errorf("fail to apply migrations: %w", err)
+ }
+ slog.Debug("successfully applied migrations")
+
+ supplements.B2Client, err = b2.NewB2Client(&cfg.BlogPages.Storage.Config)
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize b2 client: %w", err)
+ }
+
+ supplements.Localization = make(map[string]*locale.LocaleConfig, len(cfg.AvailableLanguages))
+ for _, lang := range cfg.AvailableLanguages {
+ localeCfg, err := locale.InitConfig(cfg.LocalePath + lang.LocFile)
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize a locale: %w", err)
+ }
+ supplements.Localization[lang.Name] = localeCfg
+ }
+
+ supplements.MarkdownRenderer = goldmark.New(
+ goldmark.WithExtensions(
+ glightbox.NewGLightboxExtension(),
+ tailwind.NewTailwindExtension(),
+ ),
+ goldmark.WithParserOptions(
+ parser.WithAutoHeadingID(),
+ parser.WithAttribute(),
+ ),
+ goldmark.WithRenderer(
+ renderer.NewRenderer(
+ renderer.WithNodeRenderers(
+ util.Prioritized(tailwind.NewCustomLinkRenderer(html.WithUnsafe(), html.WithXHTML()), 50),
+ util.Prioritized(html.NewRenderer(html.WithXHTML()), 100),
+ ),
+ ),
+ ),
+ )
+
+ supplements.ClientCache, err = NewClientCache(supplements.DB, []byte(cfg.Auth.Salt))
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize client cache: %w", err)
+ }
+
+ supplements.PageCache, err = ristretto.NewCache(&ristretto.Config[string, []byte]{
+ NumCounters: 1e6, // 1,000,000
+ MaxCost: 1 << 29, // 512 MB
+ BufferItems: 64, // number of keys per Get buffer.
+ })
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize page cache: %w", err)
+ }
+
+ supplements.FactGiver, err = factgiver.NewFactGiver(&cfg.FactGiver, supplements.AvailableLanguages)
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize fact giver: %w", err)
+ }
+
+ supplements.Mailer, err = mailer.NewMailer(supplements.DB, cfg.Mail.ClientHost, cfg.Mail.MailHost,
+ cfg.Mail.PublicName, cfg.Mail.MailAddress, cfg.Mail.Username, cfg.Mail.Password, []byte(cfg.Mail.Salt), supplements.Localization)
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize mailer: %w", err)
+ }
+
+ supplements.BlogTrigger, err = blogtrigger.NewBlogTriggerScheduler(supplements.B2Client, cfg.AvailableLanguages, cfg.Mail.Trigger.OnNewPost,
+ func(bp []*b2.BlogPage) error {
+ for _, post := range bp {
+ if err := supplements.Mailer.NewPost(post); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize blog trigger: %w", err)
+ }
+
+ supplements.TemplateManager, err = templatemanager.NewTemplateManager()
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize template manager: %w", err)
+ }
+
+ supplements.Meta = cfg.Meta
+
+ enablePrintRoutes := false
+ if cfg.LogLevel <= slog.LevelDebug {
+ enablePrintRoutes = true
+ }
+
+ app := fiber.New(fiber.Config{
+ EnablePrintRoutes: enablePrintRoutes,
+ ProxyHeader: "X-Forwarded-For",
+ })
+
+ app.Use(cors.New(cors.Config{
+ AllowOrigins: "https://f003.backblazeb2.com",
+ AllowMethods: "GET,POST,OPTIONS",
+ AllowHeaders: "Origin, Content-Type, Accept",
+ }))
+
+ 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}, nil
+}
+
+func (r *Router) InitRoutes() (err error) {
+ r.supplements.TemplateManager.Add("general-page", "views/layouts/general-page.html")
+ for _, route := range Routes {
+ method, match := route.Filter()
+
+ if err = r.supplements.TemplateManager.Add(method+" "+match, route.TemplatesToInject()...); err != nil {
+ return fmt.Errorf("failed to add '%s %s' route into template manager: %w", method, match, err)
+ }
+
+ if route.IsTemplated() {
+ if _, ok := r.templatedRoutes[method]; !ok {
+ r.templatedRoutes[method] = make(map[string]Route)
+ }
+ r.templatedRoutes[method][match] = route
+ r.templatedPathMatcher.AddRoute(method, match)
+
+ currentRoute := route
+ r.app.Add(method, match, func(c *fiber.Ctx) error {
+ lang, err := r.getAndValidateLang(c, currentRoute.ToValidateLang())
+ if err != nil {
+ return err
+ }
+ err = r.generalPage(c, currentRoute, lang)
+ return err
+ })
+ } else {
+ currentRoute := route
+ r.app.Add(method, match, func(c *fiber.Ctx) error {
+ lang, err := r.getAndValidateLang(c, currentRoute.ToValidateLang())
+ if err != nil {
+ return err
+ }
+
+ var cacheKey string
+ trimmedPath := strings.Trim(c.Path(), "/")
+ queryString := c.Request().URI().QueryString()
+
+ switch currentRoute.ToCache() {
+ case ByUrlOnly:
+ cacheKey = fmt.Sprintf("%s.full-page.%s", method, trimmedPath)
+ case ByUrlAndQuery:
+ cacheKey = fmt.Sprintf("%s.full-page.%s.%s", method, trimmedPath, queryString)
+ }
+
+ defaultMap := fiber.Map{
+ "L": r.supplements.Localization[lang],
+ "Lang": lang,
+ "Path": trimmedPath,
+ "QueryString": queryString,
+ "CanonicalEndpoint": r.canonicalEndpoint,
+ }
+
+ 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))
+ }
+ err := r.generalPageSegment(c, part)
+ return err
+ })
+
+ for _, segment := range segments {
+ r.supplements.TemplateManager.Add("general-page-"+segment, "views/partials/general-page-"+segment+".html")
+ }
+
+ r.app.Static("/", "./static")
+
+ // Routes = make([]Route, 0)
+
+ return nil
+}
+
+func (r *Router) Listen(endpoint string) error {
+ if err := r.app.Listen(endpoint); err != nil {
+ return fmt.Errorf("error while running fiber server: %w", err)
+ }
+ return nil
+}
+
+func (r *Router) Close() (err error) {
+ allErrors := make([]error, 0)
+ 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, "/")
+ cacheKey := ""
+ queryString := c.Request().URI().QueryString()
+
+ switch route.ToCache() {
+ case ByUrlOnly:
+ cacheKey = fmt.Sprintf("%s.general-page.%s", method, trimmedPath)
+ case ByUrlAndQuery:
+ cacheKey = fmt.Sprintf("%s.general-page.%s.%s", method, trimmedPath, queryString)
+ }
+
+ if val, ok := r.supplements.PageCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, route.ContentType())
+ return c.Status(fiber.StatusOK).Send(val)
+ }
+
+ valueMap := fiber.Map{
+ "L": r.supplements.Localization[lang],
+ "Lang": lang,
+ "QueryString": queryString,
+ "CanonicalEndpoint": r.canonicalEndpoint,
+ }
+
+ 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)
+ }
+
+ content, err := r.supplements.TemplateManager.Render("general-page", valueMap)
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ go r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration())
+ c.Set(fiber.HeaderContentType, 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, "/")
+ switch route.ToCache() {
+ case ByUrlOnly:
+ cacheKey = fmt.Sprintf("%s.%s.%s", method, part, trimmedPath)
+ case ByUrlAndQuery:
+ cacheKey = fmt.Sprintf("%s.%s.%s.%s", method, part, trimmedPath, queryString)
+ }
+
+ if route.ToCache() != Disabled {
+ if val, ok := r.supplements.PageCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, route.ContentType())
+ return c.Status(fiber.StatusOK).Send(val)
+ }
+ }
+
+ var statusCode int
+ defaultMap := fiber.Map{
+ "L": r.supplements.Localization[lang],
+ "Lang": lang,
+ "Path": strings.Trim(path, "/"),
+ "QueryString": queryString,
+ "CanonicalEndpoint": r.canonicalEndpoint,
+ }
+
+ 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
+ }
+ }
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return "", c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("lang value is invalid: '%s' is not considered an available language", lang))
+}
+
+func GetPathFromReferer(c *fiber.Ctx) (path string, pathParts []string, queryString string, err error) {
+ referer := c.Get("Referer")
+ if referer == "" {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return "", nil, "", c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
+ }
+ urlStruct, err := url.ParseRequestURI(referer)
+ if err != nil {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return "", nil, "", c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
+ }
+
+ path = urlStruct.EscapedPath()
+ pathParts = strings.Split(strings.Trim(path, "/"), "/")
+ queryString = urlStruct.RawQuery
+ return
+}