From 5ed2499a1d8806fe78369b5b492d7990218131c9 Mon Sep 17 00:00:00 2001 From: SayaAndy Date: Tue, 26 Aug 2025 00:39:22 +0700 Subject: feat: client hash cache feat: add local config feat: create a volume for data (for the future) format: unname unused vars --- internal/router/client-cache.go | 46 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 internal/router/client-cache.go (limited to 'internal/router/client-cache.go') diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go new file mode 100644 index 0000000..192d2ed --- /dev/null +++ b/internal/router/client-cache.go @@ -0,0 +1,46 @@ +package router + +import ( + "log/slog" + "sync" + + "golang.org/x/crypto/argon2" +) + +type ClientCache struct { + hashMap map[string][]byte + mutexMap map[string]*sync.Mutex + salt []byte +} + +var CCache *ClientCache + +func NewClientCache(salt []byte) *ClientCache { + return &ClientCache{ + hashMap: make(map[string][]byte), + mutexMap: make(map[string]*sync.Mutex), + salt: salt, + } +} + +func (c *ClientCache) GetHash(id string) []byte { + if val, ok := c.hashMap[id]; ok { + slog.Debug("gave an old hash", slog.String("hash", string(val))) + return val + } + + if _, ok := c.mutexMap[id]; !ok { + c.mutexMap[id] = &sync.Mutex{} + } + c.mutexMap[id].Lock() + defer c.mutexMap[id].Unlock() + + if val, ok := c.hashMap[id]; ok { + slog.Debug("gave a newly generated hash", slog.String("hash", string(val))) + return val + } + + c.hashMap[id] = argon2.IDKey([]byte(id), c.salt, 1, 64*1024, 4, 32) + slog.Debug("generated hash", slog.String("hash", string(c.hashMap[id]))) + return c.hashMap[id] +} -- cgit v1.3.1+13 From 6af3f70cf878b3adaeb63f0fabc61f9e482ca216 Mon Sep 17 00:00:00 2001 From: SayaAndy Date: Tue, 26 Aug 2025 11:13:07 +0700 Subject: refactor: log only num of enlisted pages for catalogue refactor: encode client hash with base64 --- internal/router/client-cache.go | 8 +++++--- internal/router/lang-blog.go | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) (limited to 'internal/router/client-cache.go') diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go index 192d2ed..a4a0cc2 100644 --- a/internal/router/client-cache.go +++ b/internal/router/client-cache.go @@ -1,6 +1,7 @@ package router import ( + "encoding/base64" "log/slog" "sync" @@ -24,8 +25,9 @@ func NewClientCache(salt []byte) *ClientCache { } func (c *ClientCache) GetHash(id string) []byte { + if val, ok := c.hashMap[id]; ok { - slog.Debug("gave an old hash", slog.String("hash", string(val))) + slog.Debug("gave an old hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(val))) return val } @@ -36,11 +38,11 @@ func (c *ClientCache) GetHash(id string) []byte { defer c.mutexMap[id].Unlock() if val, ok := c.hashMap[id]; ok { - slog.Debug("gave a newly generated hash", slog.String("hash", string(val))) + slog.Debug("gave a newly generated hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(val))) return val } c.hashMap[id] = argon2.IDKey([]byte(id), c.salt, 1, 64*1024, 4, 32) - slog.Debug("generated hash", slog.String("hash", string(c.hashMap[id]))) + slog.Debug("generated hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(c.hashMap[id]))) return c.hashMap[id] } diff --git a/internal/router/lang-blog.go b/internal/router/lang-blog.go index 0cbba94..5c302bb 100644 --- a/internal/router/lang-blog.go +++ b/internal/router/lang-blog.go @@ -54,11 +54,11 @@ func Lang_Blog(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B tagsMap := make(map[string]int) for _, page := range pages { - slog.Debug("enlist page for catalogue", slog.Any("page", page), slog.String("endpoint", "/"+lang+"/blog")) for _, tag := range page.Metadata.Tags { tagsMap[tag]++ } } + slog.Debug("enlist pages for catalogue", slog.Int("page_count", len(tagsMap)), slog.String("path", c.Path())) type Tag struct { Name string `json:"Name" yaml:"name"` -- cgit v1.3.1+13 From b0ab2ed92870d8c651267db10d68eb695da2ec62 Mon Sep 17 00:00:00 2001 From: SayaAndy Date: Tue, 26 Aug 2025 23:41:00 +0700 Subject: feat: like button (only inmem-cache for now) --- internal/router/api-v1-like.go | 117 ++++++++++++++++++++++++++++++ internal/router/client-cache.go | 82 +++++++++++++++++---- internal/router/lang-blog-title.go | 1 - main.go | 2 + static/input.css | 36 +++++++++ views/layouts/general-page.html | 14 ++-- views/pages/blog-catalogue.html | 2 +- views/pages/blog-page.html | 6 +- views/pages/global-map.html | 2 - views/partials/blog-page-like-button.html | 6 ++ 10 files changed, 241 insertions(+), 27 deletions(-) create mode 100644 internal/router/api-v1-like.go create mode 100644 views/partials/blog-page-like-button.html (limited to 'internal/router/client-cache.go') diff --git a/internal/router/api-v1-like.go b/internal/router/api-v1-like.go new file mode 100644 index 0000000..1732c77 --- /dev/null +++ b/internal/router/api-v1-like.go @@ -0,0 +1,117 @@ +package router + +import ( + "fmt" + "log/slog" + "net/url" + "strconv" + "strings" + + "github.com/SayaAndy/saya-today-web/internal/b2" + "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(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() + newLikeStatus, err := strconv.ParseBool(c.FormValue("like", "true")) + if err != nil { + return c.Status(fiber.ErrBadRequest.Code).SendString("invalid 'like' value") + } + + 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{ + "Liked": newLikeStatus, + }) + 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(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{ + "Liked": likeStatus, + }) + 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/client-cache.go b/internal/router/client-cache.go index a4a0cc2..3104a66 100644 --- a/internal/router/client-cache.go +++ b/internal/router/client-cache.go @@ -9,40 +9,90 @@ import ( ) type ClientCache struct { - hashMap map[string][]byte - mutexMap map[string]*sync.Mutex - salt []byte + hashMap map[string]string + mutexLikeMap map[string]*sync.Mutex + mutexHashMap map[string]*sync.Mutex + likePageMap map[string]map[string]struct{} + salt []byte } var CCache *ClientCache func NewClientCache(salt []byte) *ClientCache { return &ClientCache{ - hashMap: make(map[string][]byte), - mutexMap: make(map[string]*sync.Mutex), - salt: salt, + hashMap: make(map[string]string), + mutexLikeMap: make(map[string]*sync.Mutex), + mutexHashMap: make(map[string]*sync.Mutex), + likePageMap: make(map[string]map[string]struct{}), + salt: salt, } } -func (c *ClientCache) GetHash(id string) []byte { - +func (c *ClientCache) GetHash(id string) string { if val, ok := c.hashMap[id]; ok { - slog.Debug("gave an old hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(val))) + slog.Debug("gave an old hash", slog.String("hash", val)) return val } - if _, ok := c.mutexMap[id]; !ok { - c.mutexMap[id] = &sync.Mutex{} + if _, ok := c.mutexHashMap[id]; !ok { + c.mutexHashMap[id] = &sync.Mutex{} } - c.mutexMap[id].Lock() - defer c.mutexMap[id].Unlock() + c.mutexHashMap[id].Lock() + defer c.mutexHashMap[id].Unlock() if val, ok := c.hashMap[id]; ok { - slog.Debug("gave a newly generated hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(val))) + slog.Debug("gave a newly generated hash", slog.String("hash", val)) return val } - c.hashMap[id] = argon2.IDKey([]byte(id), c.salt, 1, 64*1024, 4, 32) - slog.Debug("generated hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(c.hashMap[id]))) + 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) GetLikeStatus(id string, page string) bool { + if _, ok := c.likePageMap[page]; !ok { + return false + } + _, ok := c.likePageMap[page][c.GetHash(id)] + return ok +} + +func (c *ClientCache) LikeOn(id string, page string) (alreadyLiked bool) { + if _, ok := c.mutexLikeMap[id]; !ok { + c.mutexLikeMap[id] = &sync.Mutex{} + } + c.mutexLikeMap[id].Lock() + defer c.mutexLikeMap[id].Unlock() + + hash := c.GetHash(id) + + 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) { + if _, ok := c.mutexLikeMap[id]; !ok { + c.mutexLikeMap[id] = &sync.Mutex{} + } + c.mutexLikeMap[id].Lock() + defer c.mutexLikeMap[id].Unlock() + + if _, ok := c.likePageMap[page]; !ok { + return true + } + hash := c.GetHash(id) + if _, ok := c.likePageMap[page][hash]; !ok { + return true + } + + delete(c.likePageMap[page], hash) + return false +} diff --git a/internal/router/lang-blog-title.go b/internal/router/lang-blog-title.go index 5b80ec8..bafb0d2 100644 --- a/internal/router/lang-blog-title.go +++ b/internal/router/lang-blog-title.go @@ -24,7 +24,6 @@ func Lang_Blog_Title(l map[string]*locale.LocaleConfig, langs []string, b2Client return func(c *fiber.Ctx) error { ip := c.IP() slog.Debug("client entering blog page", slog.String("ip", ip), slog.String("page", c.Path())) - go CCache.GetHash(ip) lang := c.Params("lang") if !slices.Contains(langs, lang) { diff --git a/main.go b/main.go index 9f95c04..1853721 100644 --- a/main.go +++ b/main.go @@ -88,6 +88,8 @@ func main() { app.Get("/:lang/blog/:title", router.Lang_Blog_Title(localization, availableLanguages, b2Client, md)) app.Get("/api/v1/tz", router.Api_V1_TZ()) app.Get("/api/v1/blog-search", router.Api_V1_BlogSearch(localization, availableLanguages, b2Client)) + app.Get("/api/v1/like", router.Api_V1_Like_Get(b2Client)) + app.Put("/api/v1/like", router.Api_V1_Like_Put(b2Client)) app.Static("/", "./static") diff --git a/static/input.css b/static/input.css index 9f834a6..18910eb 100644 --- a/static/input.css +++ b/static/input.css @@ -139,10 +139,18 @@ a:hover { color: var(--main-medium-color); } +.text-main-light { + color: var(--main-light-color); +} + .text-background-dark { color: var(--background-dark-color); } +.text-background-light { + color: var(--background-dark-color); +} + .text-secondary { color: var(--secondary-color); } @@ -151,6 +159,14 @@ a:hover { background-color: var(--main-dark-color); } +.bg-main-medium { + background-color: var(--main-medium-color); +} + +.bg-main-light { + background-color: var(--main-light-color); +} + .bg-background-dark { background-color: var(--background-dark-color); } @@ -167,6 +183,26 @@ a:hover { border-color: var(--main-medium-color); } +.border-main-light { + border-color: var(--main-light-color); +} + +.border-background-dark { + border-color: var(--background-dark-color); +} + +.border-background-light { + border-color: var(--background-light-color); +} + +.border-inset { + border-style: inset; +} + +.border-outset { + border-style: outset; +} + .desktop-sidebar-custom { width: 5vw; background-size: calc(var(--squares-and-triangles-background-size) * 1vw); diff --git a/views/layouts/general-page.html b/views/layouts/general-page.html index cd19bd0..9885d66 100644 --- a/views/layouts/general-page.html +++ b/views/layouts/general-page.html @@ -8,7 +8,7 @@ - {{ block "header" . }}{{ end }} + {{ block "top-embeds" . }}{{ end }} @@ -66,17 +66,19 @@

{{ .Title }}

{{ .PublishedDate }}

+ {{ block "header" . }}{{ end }}
{{ block "body" . }}{{ end }}
-

+ {{ block "footer" . }}{{ end }} +

All the photos on saya.today are licensed under CC BY-SA 4.0 by Saya Andy © {{ .PublishedYear }}

- - - + CC + BY + SA

@@ -209,7 +211,7 @@ }); - {{ block "footer" . }}{{ end }} + {{ block "bottom-embeds" . }}{{ end }} diff --git a/views/pages/blog-catalogue.html b/views/pages/blog-catalogue.html index 9fa8ea7..25b0685 100644 --- a/views/pages/blog-catalogue.html +++ b/views/pages/blog-catalogue.html @@ -56,7 +56,7 @@ {{ end }} -{{ define "footer" }} +{{ define "bottom-embeds" }} diff --git a/views/pages/global-map.html b/views/pages/global-map.html index 6185076..569166f 100644 --- a/views/pages/global-map.html +++ b/views/pages/global-map.html @@ -263,7 +263,5 @@ }); - {{ block "footer" . }}{{ end }} - diff --git a/views/partials/blog-page-like-button.html b/views/partials/blog-page-like-button.html new file mode 100644 index 0000000..50a9d7a --- /dev/null +++ b/views/partials/blog-page-like-button.html @@ -0,0 +1,6 @@ + \ No newline at end of file -- cgit v1.3.1+13 From 26c77e33356a519b3caaed44fd9d7f8722f41918 Mon Sep 17 00:00:00 2001 From: SayaAndy Date: Wed, 27 Aug 2025 19:11:16 +0700 Subject: feat: add and show like count feat: localize like button --- internal/router/api-v1-like.go | 15 ++++++++++----- internal/router/client-cache.go | 7 +++++++ locale/localization.en.yaml | 1 + locale/localization.go | 1 + locale/localization.ru.yaml | 1 + main.go | 4 ++-- views/partials/blog-page-like-button.html | 5 +++-- 7 files changed, 25 insertions(+), 9 deletions(-) (limited to 'internal/router/client-cache.go') diff --git a/internal/router/api-v1-like.go b/internal/router/api-v1-like.go index 1732c77..327dafa 100644 --- a/internal/router/api-v1-like.go +++ b/internal/router/api-v1-like.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/locale" "github.com/gofiber/fiber/v2" ) @@ -15,7 +16,7 @@ func init() { tm.Add("blog-page-like-button", "views/partials/blog-page-like-button.html") } -func Api_V1_Like_Put(b2 *b2.B2Client) func(c *fiber.Ctx) error { +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) @@ -41,12 +42,12 @@ func Api_V1_Like_Put(b2 *b2.B2Client) func(c *fiber.Ctx) error { return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server did not find '%s' article", pageLink)) } - ip := c.IP() 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 { @@ -56,7 +57,9 @@ func Api_V1_Like_Put(b2 *b2.B2Client) func(c *fiber.Ctx) error { 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{ - "Liked": newLikeStatus, + "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())) @@ -70,7 +73,7 @@ func Api_V1_Like_Put(b2 *b2.B2Client) func(c *fiber.Ctx) error { } } -func Api_V1_Like_Get(b2 *b2.B2Client) func(c *fiber.Ctx) error { +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) @@ -102,7 +105,9 @@ func Api_V1_Like_Get(b2 *b2.B2Client) func(c *fiber.Ctx) error { 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{ - "Liked": likeStatus, + "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())) diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go index 3104a66..7d276b1 100644 --- a/internal/router/client-cache.go +++ b/internal/router/client-cache.go @@ -58,6 +58,13 @@ func (c *ClientCache) GetLikeStatus(id string, page string) bool { return ok } +func (c *ClientCache) GetLikeCount(page string) int { + if userSet, ok := c.likePageMap[page]; ok { + return len(userSet) + } + return 0 +} + func (c *ClientCache) LikeOn(id string, page string) (alreadyLiked bool) { if _, ok := c.mutexLikeMap[id]; !ok { c.mutexLikeMap[id] = &sync.Mutex{} diff --git a/locale/localization.en.yaml b/locale/localization.en.yaml index f5ee6c9..48ef9da 100644 --- a/locale/localization.en.yaml +++ b/locale/localization.en.yaml @@ -9,3 +9,4 @@ BlogSearch: ChooseAllTags: 'Choose All' GlobalMap: Header: 'Global Map' +LikeButton: 'Like!' diff --git a/locale/localization.go b/locale/localization.go index 56b6462..b557e08 100644 --- a/locale/localization.go +++ b/locale/localization.go @@ -11,6 +11,7 @@ type LocaleConfig struct { TagsLabel string `yaml:"TagsLabel" json:"TagsLabel"` BlogSearch BlogSearchConfig `yaml:"BlogSearch" json:"BlogSearch"` GlobalMap GlobalMapConfig `yaml:"GlobalMap" json:"GlobalMap"` + LikeButton string `yaml:"LikeButton" json:"LikeButton"` } type BlogSearchConfig struct { diff --git a/locale/localization.ru.yaml b/locale/localization.ru.yaml index c236d1e..d6e9fe4 100644 --- a/locale/localization.ru.yaml +++ b/locale/localization.ru.yaml @@ -9,3 +9,4 @@ BlogSearch: ChooseAllTags: 'Выбрать все' GlobalMap: Header: 'Глобальная карта' +LikeButton: 'Нраица!' diff --git a/main.go b/main.go index 1853721..36463ff 100644 --- a/main.go +++ b/main.go @@ -88,8 +88,8 @@ func main() { app.Get("/:lang/blog/:title", router.Lang_Blog_Title(localization, availableLanguages, b2Client, md)) app.Get("/api/v1/tz", router.Api_V1_TZ()) app.Get("/api/v1/blog-search", router.Api_V1_BlogSearch(localization, availableLanguages, b2Client)) - app.Get("/api/v1/like", router.Api_V1_Like_Get(b2Client)) - app.Put("/api/v1/like", router.Api_V1_Like_Put(b2Client)) + app.Get("/api/v1/like", router.Api_V1_Like_Get(localization, b2Client)) + app.Put("/api/v1/like", router.Api_V1_Like_Put(localization, b2Client)) app.Static("/", "./static") diff --git a/views/partials/blog-page-like-button.html b/views/partials/blog-page-like-button.html index 50a9d7a..afc3027 100644 --- a/views/partials/blog-page-like-button.html +++ b/views/partials/blog-page-like-button.html @@ -1,6 +1,7 @@ \ No newline at end of file -- cgit v1.3.1+13 From 78fe4481099fcf6666f1a259e38d9d828a253347 Mon Sep 17 00:00:00 2001 From: SayaAndy Date: Sat, 30 Aug 2025 00:36:46 +0700 Subject: feat: add persistent storage for likes --- Dockerfile | 9 ++- config/config.go | 12 +++- config/config.local.yaml | 4 +- config/config.prod.yaml | 2 +- config/config.stage.yaml | 2 +- go.mod | 39 ++++++------ go.sum | 72 +++++++++++----------- internal/router/client-cache.go | 99 ++++++++++++++++++++++++++++++- main.go | 58 +++++++++++++++++- migrations/1_create_stats_tables.down.sql | 2 + migrations/1_create_stats_tables.up.sql | 11 ++++ 11 files changed, 246 insertions(+), 64 deletions(-) create mode 100644 migrations/1_create_stats_tables.down.sql create mode 100644 migrations/1_create_stats_tables.up.sql (limited to 'internal/router/client-cache.go') diff --git a/Dockerfile b/Dockerfile index ffe5ac3..4388e5d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,10 @@ -FROM golang:1.24.5-alpine3.22 AS build-stage +FROM golang:1.24.6-alpine3.22 AS build-stage + +RUN apk add --no-cache sqlite-dev WORKDIR /builddir COPY . . -RUN go build -o sayana-web . +RUN CGO_ENABLED=1 go build -o sayana-web . FROM alpine:3.22.1 AS runtime-stage @@ -13,11 +15,12 @@ ENV B2_APPLICATION_KEY="" WORKDIR /app COPY --from=build-stage /builddir/sayana-web /app/sayana-web +COPY --from=build-stage /builddir/migrations /app/migrations COPY --from=build-stage /builddir/static /app/static COPY --from=build-stage /builddir/views /app/views COPY --from=build-stage /builddir/locale/*.yaml /app/locale/ COPY --from=build-stage /builddir/config/config*.yaml /app/config/ -RUN apk add --no-cache tzdata +RUN apk add --no-cache tzdata sqlite ENTRYPOINT /app/sayana-web -c /app/config/config.${ENVIRONMENT}.yaml diff --git a/config/config.go b/config/config.go index fe0ac72..7438fe1 100644 --- a/config/config.go +++ b/config/config.go @@ -41,7 +41,17 @@ type AvailableLanguageConfig struct { } type AuthConfig struct { - Salt string `json:"Salt" yaml:"salt" validate:"required"` + Salt string `json:"Salt" yaml:"salt" validate:"required"` + Db DbConfig `json:"Db" yaml:"db" validate:"required"` +} + +type DbConfig struct { + Type string `json:"Type" yaml:"type" validate:"required,oneof=sqlite3"` + Cfg Sqlite3Config `json:"Config" yaml:"config"` +} + +type Sqlite3Config struct { + DSN string `json:"DSN" yaml:"dsn" validate:"required"` } func LoadConfig(path string, config *Config) error { diff --git a/config/config.local.yaml b/config/config.local.yaml index 330e29c..0b48e47 100644 --- a/config/config.local.yaml +++ b/config/config.local.yaml @@ -20,7 +20,7 @@ availableLanguages: locFile: localization.en.yaml auth: db: - type: sqlite + type: sqlite3 config: - dsn: 'file:/data/auth.db?cache=shared&mode=memory' + dsn: 'file:/tmp/auth.db?cache=shared&mode=rwc' salt: '123' diff --git a/config/config.prod.yaml b/config/config.prod.yaml index 76288fa..e1294c0 100644 --- a/config/config.prod.yaml +++ b/config/config.prod.yaml @@ -20,7 +20,7 @@ availableLanguages: locFile: localization.en.yaml auth: db: - type: sqlite + type: sqlite3 config: dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2' salt: '${AUTH_SALT}' diff --git a/config/config.stage.yaml b/config/config.stage.yaml index f6c16cb..30233c1 100644 --- a/config/config.stage.yaml +++ b/config/config.stage.yaml @@ -20,7 +20,7 @@ availableLanguages: locFile: localization.en.yaml auth: db: - type: sqlite + type: sqlite3 config: dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2' salt: '${AUTH_SALT}' diff --git a/go.mod b/go.mod index e2e916e..71aa20b 100644 --- a/go.mod +++ b/go.mod @@ -1,33 +1,36 @@ module github.com/SayaAndy/saya-today-web -go 1.24.5 +go 1.24.6 -require github.com/gofiber/fiber/v2 v2.52.9 +require ( + github.com/Backblaze/blazer v0.7.2 + github.com/go-playground/validator/v10 v10.27.0 + github.com/gofiber/fiber/v2 v2.52.9 + github.com/golang-migrate/migrate/v4 v4.18.3 + github.com/mattn/go-sqlite3 v1.14.32 + github.com/yuin/goldmark v1.7.13 + golang.org/x/crypto v0.41.0 + gopkg.in/yaml.v3 v3.0.1 +) require ( - github.com/Backblaze/blazer v0.7.2 // indirect - github.com/andybalholm/brotli v1.1.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.27.0 // indirect - github.com/gofiber/template v1.8.3 // indirect - github.com/gofiber/template/html/v2 v2.1.3 // indirect - github.com/gofiber/utils v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/klauspost/compress v1.17.9 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/stretchr/testify v1.10.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.51.0 // indirect - github.com/valyala/tcplisten v1.0.0 // indirect - github.com/yuin/goldmark v1.7.13 // indirect - golang.org/x/crypto v0.41.0 // indirect - golang.org/x/net v0.42.0 // indirect + github.com/valyala/fasthttp v1.65.0 // indirect + go.uber.org/atomic v1.7.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b3ae996..22ef8ff 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,14 @@ github.com/Backblaze/blazer v0.7.2 h1:UWNHMLB+Nf+UmbO2qkVvgriODLEMz4kIyr2Hm+DVXQM= github.com/Backblaze/blazer v0.7.2/go.mod h1:T4y3EYa9IQ5J0PKc/C/J8/CEnSd3qa/lgNw938wZg10= -github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= -github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= -github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= @@ -12,55 +17,56 @@ github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHO github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= -github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc= -github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8= -github.com/gofiber/template/html/v2 v2.1.3 h1:n1LYBtmr9C0V/k/3qBblXyMxV5B0o/gpb6dFLp8ea+o= -github.com/gofiber/template/html/v2 v2.1.3/go.mod h1:U5Fxgc5KpyujU9OqKzy6Kn6Qup6Tm7zdsISR+VpnHRE= -github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM= -github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0= +github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs= +github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= -github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= -github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= -github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8= +github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go index 7d276b1..56cd595 100644 --- a/internal/router/client-cache.go +++ b/internal/router/client-cache.go @@ -1,31 +1,126 @@ package router import ( + "database/sql" "encoding/base64" + "fmt" "log/slog" + "strings" "sync" "golang.org/x/crypto/argon2" ) +type PageLike struct { + PageRef string + UserId string +} + type ClientCache struct { hashMap map[string]string mutexLikeMap map[string]*sync.Mutex mutexHashMap map[string]*sync.Mutex likePageMap map[string]map[string]struct{} salt []byte + db *sql.DB } var CCache *ClientCache -func NewClientCache(salt []byte) *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) + } + + rows, err := tx.Query("select * from blog_likes;") + if err != nil { + tx.Rollback() + return nil, fmt.Errorf("fail to query db for blog_likes to fill cache: %w", err) + } + + likePageMap := make(map[string]map[string]struct{}) + + for rows.Next() { + pageRef := make([]byte, 32) + userId := make([]byte, 32) + if err = rows.Scan(&pageRef, &userId); err != nil { + tx.Rollback() + return nil, fmt.Errorf("fail scanning blog_likes to fill cache: %w", err) + } + pageRefString := string(pageRef) + userIdString := base64.RawStdEncoding.EncodeToString(userId) + if _, ok := likePageMap[pageRefString]; !ok { + likePageMap[pageRefString] = make(map[string]struct{}) + } + likePageMap[pageRefString][userIdString] = struct{}{} + } + + if err = tx.Commit(); err != nil { + return nil, fmt.Errorf("fail to commit transaction in db: %w", err) + } + return &ClientCache{ hashMap: make(map[string]string), mutexLikeMap: make(map[string]*sync.Mutex), mutexHashMap: make(map[string]*sync.Mutex), - likePageMap: make(map[string]map[string]struct{}), + likePageMap: likePageMap, 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) + } + + userIdBytes := make(map[string][]byte) + + sqlStatement := fmt.Sprintf(` + INSERT OR IGNORE INTO blog_likes (page_ref, user_id) + VALUES %s(?, ?); + `, strings.Repeat("(?, ?), ", 99)) + sqlStatementVars := make([]interface{}, 0, 200) + + for pageRef, userSet := range c.likePageMap { + pageRefBytes := []byte(pageRef) + + 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, interface{}(pageRefBytes), interface{}(userIdBytes[userId])) + if len(sqlStatementVars) < 200 { + continue + } + + if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil { + slog.Warn("couldn't insert blog like pairs into db", slog.String("error", err.Error())) + } + + sqlStatementVars = make([]interface{}, 0, 200) + } } + + if len(sqlStatementVars) > 0 { + sqlStatement = fmt.Sprintf(` + INSERT OR IGNORE INTO blog_likes (page_ref, user_id) + VALUES %s(?, ?); + `, strings.Repeat("(?, ?), ", len(sqlStatementVars)/2-1)) + + if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil { + slog.Warn("couldn't insert blog like pairs into db", slog.String("error", err.Error())) + } + } + + return tx.Commit() } func (c *ClientCache) GetHash(id string) string { diff --git a/main.go b/main.go index 36463ff..2f08ba6 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,13 @@ package main import ( + "database/sql" + "errors" "flag" - "log" "log/slog" "os" + "os/signal" + "syscall" "github.com/SayaAndy/saya-today-web/config" "github.com/SayaAndy/saya-today-web/internal/b2" @@ -14,9 +17,14 @@ import ( "github.com/SayaAndy/saya-today-web/locale" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/redirect" + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/sqlite3" "github.com/yuin/goldmark" "github.com/yuin/goldmark/parser" gmhtml "github.com/yuin/goldmark/renderer/html" + + _ "github.com/golang-migrate/migrate/v4/source/file" + _ "github.com/mattn/go-sqlite3" ) var ( @@ -52,6 +60,32 @@ func main() { slog.SetLogLoggerLevel(cfg.LogLevel) slog.Info("starting sayana-web server...") + db, err := sql.Open(cfg.Auth.Db.Type, cfg.Auth.Db.Cfg.DSN) + if err != nil { + slog.Error("fail to initialize db", slog.String("error", err.Error())) + os.Exit(1) + } + + driver, err := sqlite3.WithInstance(db, &sqlite3.Config{}) + if err != nil { + slog.Error("fail to initialize driver for migrating db", slog.String("error", err.Error())) + os.Exit(1) + } + + m, err := migrate.NewWithDatabaseInstance( + "file://migrations", + cfg.Auth.Db.Type, driver) + if err != nil { + slog.Error("fail to initialize migration client", slog.String("error", err.Error())) + os.Exit(1) + } + + if err = m.Up(); err != nil && err == errors.New("no change") { + slog.Error("fail to apply migrations", slog.String("error", err.Error())) + os.Exit(1) + } + slog.Info("successfully applied migrations") + b2Client, err = b2.NewB2Client(&cfg.BlogPages.Storage.Config) if err != nil { slog.Error("fail to initialize b2 client", slog.String("error", err.Error())) @@ -80,7 +114,11 @@ func main() { StatusCode: 301, })) - router.CCache = router.NewClientCache([]byte(cfg.Auth.Salt)) + router.CCache, err = router.NewClientCache(db, []byte(cfg.Auth.Salt)) + if err != nil { + slog.Error("fail to initialize cache", slog.String("error", err.Error())) + os.Exit(1) + } app.Get("/", router.Root(cfg.AvailableLanguages)) app.Get("/:lang/map", router.Lang_Map(localization, availableLanguages, b2Client)) @@ -93,5 +131,19 @@ func main() { app.Static("/", "./static") - log.Fatal(app.Listen(":3000")) + go func() { + if err := app.Listen(":3000"); err != nil { + slog.Error("error while running fiber server", slog.String("error", err.Error())) + panic(err) + } + }() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + <-sigChan + slog.Info("gracefully shutting down...") + app.Shutdown() + router.CCache.Close() + db.Close() } diff --git a/migrations/1_create_stats_tables.down.sql b/migrations/1_create_stats_tables.down.sql new file mode 100644 index 0000000..ee4593c --- /dev/null +++ b/migrations/1_create_stats_tables.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS blog_views; +DROP TABLE IF EXISTS blog_likes; diff --git a/migrations/1_create_stats_tables.up.sql b/migrations/1_create_stats_tables.up.sql new file mode 100644 index 0000000..8852268 --- /dev/null +++ b/migrations/1_create_stats_tables.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS blog_likes ( + page_ref VARCHAR(32) NOT NULL, + user_id VARCHAR(32) NOT NULL, + PRIMARY KEY (page_ref, user_id) +) WITHOUT ROWID; + +CREATE TABLE IF NOT EXISTS blog_views ( + page_ref VARCHAR(32) NOT NULL, + user_id VARCHAR(32) NOT NULL, + PRIMARY KEY (page_ref, user_id) +) WITHOUT ROWID; -- cgit v1.3.1+13 From c33d3b275f2580bff2d510715448c14be1d5d5b5 Mon Sep 17 00:00:00 2001 From: SayaAndy Date: Sat, 30 Aug 2025 13:58:46 +0700 Subject: fix: truncate likes table prior to dumping cache --- internal/router/client-cache.go | 5 +++++ main.go | 11 +++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) (limited to 'internal/router/client-cache.go') diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go index 56cd595..b930d5a 100644 --- a/internal/router/client-cache.go +++ b/internal/router/client-cache.go @@ -76,6 +76,11 @@ func (c *ClientCache) Close() error { return fmt.Errorf("fail to init transaction with db to dump cache: %w", err) } + if _, err = tx.Exec("delete from blog_likes;"); err != nil { + tx.Rollback() + return fmt.Errorf("fail to truncate table blog_likes: %w", err) + } + userIdBytes := make(map[string][]byte) sqlStatement := fmt.Sprintf(` diff --git a/main.go b/main.go index 2f08ba6..020f8ef 100644 --- a/main.go +++ b/main.go @@ -143,7 +143,14 @@ func main() { <-sigChan slog.Info("gracefully shutting down...") - app.Shutdown() - router.CCache.Close() + if err = app.Shutdown(); err != nil { + slog.Error("fail to shutdown fiber server", slog.String("error", err.Error())) + } + if err = router.CCache.Close(); err != nil { + slog.Error("fail to dump cache", slog.String("error", err.Error())) + } + if err = db.Close(); err != nil { + slog.Error("fail to close db connection", slog.String("error", err.Error())) + } db.Close() } -- cgit v1.3.1+13 From 218fc8b0d470a3df8a412f7ab32925e74ca4b315 Mon Sep 17 00:00:00 2001 From: SayaAndy Date: Sat, 30 Aug 2025 15:13:27 +0700 Subject: feat: like count shown on blog search fix: corrupted page links in cache --- internal/router/api-v1-blog-search.go | 1 + internal/router/client-cache.go | 94 ++++++++++++++++++++------------ internal/router/lang-blog.go | 2 +- views/partials/catalogue-blog-cards.html | 3 + 4 files changed, 64 insertions(+), 36 deletions(-) (limited to 'internal/router/client-cache.go') diff --git a/internal/router/api-v1-blog-search.go b/internal/router/api-v1-blog-search.go index a809783..e22fabe 100644 --- a/internal/router/api-v1-blog-search.go +++ b/internal/router/api-v1-blog-search.go @@ -69,6 +69,7 @@ func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []string, b2Clie "ShortDescription": page.Metadata.ShortDescription, "Thumbnail": page.Metadata.Thumbnail, "Tags": page.Metadata.Tags, + "LikeCount": CCache.GetLikeCount(page.FileName), }) break } diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go index b930d5a..81d7641 100644 --- a/internal/router/client-cache.go +++ b/internal/router/client-cache.go @@ -18,11 +18,14 @@ type PageLike struct { type ClientCache struct { hashMap map[string]string - mutexLikeMap map[string]*sync.Mutex - mutexHashMap map[string]*sync.Mutex - likePageMap map[string]map[string]struct{} - salt []byte - db *sql.DB + hashMapMutex sync.RWMutex + + likePageMap map[string]map[string]struct{} + pageMutexMap map[string]*sync.RWMutex + pageMutexMapMutex sync.Mutex + + salt []byte + db *sql.DB } var CCache *ClientCache @@ -40,20 +43,21 @@ func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) { } likePageMap := make(map[string]map[string]struct{}) + pageMutexMap := make(map[string]*sync.RWMutex) for rows.Next() { - pageRef := make([]byte, 32) - userId := make([]byte, 32) + 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) } - pageRefString := string(pageRef) userIdString := base64.RawStdEncoding.EncodeToString(userId) - if _, ok := likePageMap[pageRefString]; !ok { - likePageMap[pageRefString] = make(map[string]struct{}) + if _, ok := likePageMap[pageRef]; !ok { + likePageMap[pageRef] = make(map[string]struct{}) + pageMutexMap[pageRef] = &sync.RWMutex{} } - likePageMap[pageRefString][userIdString] = struct{}{} + likePageMap[pageRef][userIdString] = struct{}{} } if err = tx.Commit(); err != nil { @@ -62,9 +66,8 @@ func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) { return &ClientCache{ hashMap: make(map[string]string), - mutexLikeMap: make(map[string]*sync.Mutex), - mutexHashMap: make(map[string]*sync.Mutex), likePageMap: likePageMap, + pageMutexMap: pageMutexMap, salt: salt, db: db, }, nil @@ -87,11 +90,9 @@ func (c *ClientCache) Close() error { INSERT OR IGNORE INTO blog_likes (page_ref, user_id) VALUES %s(?, ?); `, strings.Repeat("(?, ?), ", 99)) - sqlStatementVars := make([]interface{}, 0, 200) + sqlStatementVars := make([]any, 0, 200) for pageRef, userSet := range c.likePageMap { - pageRefBytes := []byte(pageRef) - for userId := range userSet { if _, ok := userIdBytes[userId]; !ok { userIdBytes[userId], err = base64.RawStdEncoding.DecodeString(userId) @@ -101,7 +102,7 @@ func (c *ClientCache) Close() error { } } - sqlStatementVars = append(sqlStatementVars, interface{}(pageRefBytes), interface{}(userIdBytes[userId])) + sqlStatementVars = append(sqlStatementVars, any(pageRef), any(userIdBytes[userId])) if len(sqlStatementVars) < 200 { continue } @@ -110,7 +111,7 @@ func (c *ClientCache) Close() error { slog.Warn("couldn't insert blog like pairs into db", slog.String("error", err.Error())) } - sqlStatementVars = make([]interface{}, 0, 200) + sqlStatementVars = make([]any, 0, 200) } } @@ -129,16 +130,16 @@ func (c *ClientCache) Close() error { } 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() - if _, ok := c.mutexHashMap[id]; !ok { - c.mutexHashMap[id] = &sync.Mutex{} - } - c.mutexHashMap[id].Lock() - defer c.mutexHashMap[id].Unlock() + 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)) @@ -150,7 +151,25 @@ func (c *ClientCache) GetHash(id string) string { 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 } @@ -159,6 +178,12 @@ func (c *ClientCache) GetLikeStatus(id string, page string) bool { } 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) } @@ -166,14 +191,13 @@ func (c *ClientCache) GetLikeCount(page string) int { } func (c *ClientCache) LikeOn(id string, page string) (alreadyLiked bool) { - if _, ok := c.mutexLikeMap[id]; !ok { - c.mutexLikeMap[id] = &sync.Mutex{} - } - c.mutexLikeMap[id].Lock() - defer c.mutexLikeMap[id].Unlock() - + 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{}{} @@ -186,16 +210,16 @@ func (c *ClientCache) LikeOn(id string, page string) (alreadyLiked bool) { } func (c *ClientCache) LikeOff(id string, page string) (alreadyUnliked bool) { - if _, ok := c.mutexLikeMap[id]; !ok { - c.mutexLikeMap[id] = &sync.Mutex{} - } - c.mutexLikeMap[id].Lock() - defer c.mutexLikeMap[id].Unlock() + 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 } - hash := c.GetHash(id) if _, ok := c.likePageMap[page][hash]; !ok { return true } diff --git a/internal/router/lang-blog.go b/internal/router/lang-blog.go index e5220c6..dd3f079 100644 --- a/internal/router/lang-blog.go +++ b/internal/router/lang-blog.go @@ -83,7 +83,7 @@ func Lang_Blog(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B "Title": l[lang].BlogSearch.Header, }) if err != nil { - slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/blog"), slog.String("error", err.Error())) + 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") } diff --git a/views/partials/catalogue-blog-cards.html b/views/partials/catalogue-blog-cards.html index 62e931a..35c7ad7 100644 --- a/views/partials/catalogue-blog-cards.html +++ b/views/partials/catalogue-blog-cards.html @@ -8,6 +8,9 @@ {{ .Title }} // {{ .ActionDate }} + // + + {{ .LikeCount }}

{{ .PublishedTime }}

-- cgit v1.3.1+13