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 --- .github/workflows/build-and-deploy-prod.yml | 2 +- .github/workflows/build-and-deploy-stage.yml | 2 +- Dockerfile | 1 + config/config.go | 5 +++ config/config.local.yaml | 26 ++++++++++++++++ config/config.prod.yaml | 6 ++++ config/config.stage.yaml | 6 ++++ deploy/playbook.yml | 7 +++++ go.mod | 8 ++--- go.sum | 8 +++++ internal/router/api-v1-blog-search.go | 2 +- internal/router/client-cache.go | 46 ++++++++++++++++++++++++++++ internal/router/lang-blog-title.go | 4 +++ internal/router/lang-blog.go | 2 +- main.go | 6 +++- 15 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 config/config.local.yaml create mode 100644 internal/router/client-cache.go diff --git a/.github/workflows/build-and-deploy-prod.yml b/.github/workflows/build-and-deploy-prod.yml index 53e307e..a180d39 100644 --- a/.github/workflows/build-and-deploy-prod.yml +++ b/.github/workflows/build-and-deploy-prod.yml @@ -75,4 +75,4 @@ jobs: ANSIBLE_HOST_KEY_CHECKING: False working-directory: ./deploy run: | - ansible-playbook -i inventory.yml -l prod playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_b2_key_id=${{ secrets.B2_KEY_ID }} -e saya_today_web_b2_application_key=${{ secrets.B2_APPLICATION_KEY }} -e saya_today_web_environment=prod -e saya_today_web_tag=${{ github.ref_name }} + ansible-playbook -i inventory.yml -l prod playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_auth_salt=${{ secrets.AUTH_SALT }} -e saya_today_web_b2_key_id=${{ secrets.B2_KEY_ID }} -e saya_today_web_b2_application_key=${{ secrets.B2_APPLICATION_KEY }} -e saya_today_web_environment=prod -e saya_today_web_tag=${{ github.ref_name }} diff --git a/.github/workflows/build-and-deploy-stage.yml b/.github/workflows/build-and-deploy-stage.yml index 104d9ac..0aea0a4 100644 --- a/.github/workflows/build-and-deploy-stage.yml +++ b/.github/workflows/build-and-deploy-stage.yml @@ -86,4 +86,4 @@ jobs: ANSIBLE_HOST_KEY_CHECKING: False working-directory: ./deploy run: | - ansible-playbook -i inventory.yml -l stage playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_b2_key_id=${{ secrets.B2_KEY_ID }} -e saya_today_web_b2_application_key=${{ secrets.B2_APPLICATION_KEY }} -e saya_today_web_environment=stage -e saya_today_web_tag=commit-${{ steps.ghss_vars.outputs.sha_short }} + ansible-playbook -i inventory.yml -l stage playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_auth_salt=${{ secrets.AUTH_SALT }} -e saya_today_web_b2_key_id=${{ secrets.B2_KEY_ID }} -e saya_today_web_b2_application_key=${{ secrets.B2_APPLICATION_KEY }} -e saya_today_web_environment=stage -e saya_today_web_tag=commit-${{ steps.ghss_vars.outputs.sha_short }} diff --git a/Dockerfile b/Dockerfile index cd3e0eb..ffe5ac3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ RUN go build -o sayana-web . FROM alpine:3.22.1 AS runtime-stage ENV ENVIRONMENT="" +ENV AUTH_SALT="" ENV B2_KEY_ID="" ENV B2_APPLICATION_KEY="" diff --git a/config/config.go b/config/config.go index 8c0321e..fe0ac72 100644 --- a/config/config.go +++ b/config/config.go @@ -13,6 +13,7 @@ type Config struct { BlogPages BlogPagesConfig `json:"BlogPages" yaml:"blogPages" validate:"required"` LocalePath string `json:"LocalePath" yaml:"localePath" validate:"required,filepath"` AvailableLanguages []AvailableLanguageConfig `json:"AvailableLanguages" yaml:"availableLanguages" validate:"required"` + Auth AuthConfig `json:"Auth" yaml:"auth" validate:"required"` } type BlogPagesConfig struct { @@ -39,6 +40,10 @@ type AvailableLanguageConfig struct { LocFile string `json:"LocFile" yaml:"locFile" validate:"required,filepath"` } +type AuthConfig struct { + Salt string `json:"Salt" yaml:"salt" validate:"required"` +} + func LoadConfig(path string, config *Config) error { fileBytes, err := os.ReadFile(path) if err != nil { diff --git a/config/config.local.yaml b/config/config.local.yaml new file mode 100644 index 0000000..330e29c --- /dev/null +++ b/config/config.local.yaml @@ -0,0 +1,26 @@ +logLevel: debug +blogPages: + storage: + type: b2 + config: + bucketName: sayana-pages + region: eu-central-003 + prefix: 'stage-' + keyID: '${B2_KEY_ID}' + applicationKey: '${B2_APPLICATION_KEY}' +localePath: ./locale/ +availableLanguages: + - name: ru + alt: Русский + flag: https://f003.backblazeb2.com/file/sayana-static/flags/ru.svg + locFile: localization.ru.yaml + - name: en + alt: English + flag: https://f003.backblazeb2.com/file/sayana-static/flags/gb.svg + locFile: localization.en.yaml +auth: + db: + type: sqlite + config: + dsn: 'file:/data/auth.db?cache=shared&mode=memory' + salt: '123' diff --git a/config/config.prod.yaml b/config/config.prod.yaml index 2d050bf..76288fa 100644 --- a/config/config.prod.yaml +++ b/config/config.prod.yaml @@ -18,3 +18,9 @@ availableLanguages: alt: English flag: https://f003.backblazeb2.com/file/sayana-static/flags/gb.svg locFile: localization.en.yaml +auth: + db: + type: sqlite + 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 a34a33e..f6c16cb 100644 --- a/config/config.stage.yaml +++ b/config/config.stage.yaml @@ -18,3 +18,9 @@ availableLanguages: alt: English flag: https://f003.backblazeb2.com/file/sayana-static/flags/gb.svg locFile: localization.en.yaml +auth: + db: + type: sqlite + config: + dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2' + salt: '${AUTH_SALT}' diff --git a/deploy/playbook.yml b/deploy/playbook.yml index ca22ac3..dfceaf7 100644 --- a/deploy/playbook.yml +++ b/deploy/playbook.yml @@ -4,6 +4,10 @@ remote_user: svc_github tasks: + - name: Create a volume + community.docker.docker_volume: + name: "{{ saya_today_web_name }}-volume" + - name: Deploy saya-today-web Docker Container community.docker.docker_container: name: "{{ saya_today_web_name }}" @@ -13,10 +17,13 @@ B2_KEY_ID: "{{ saya_today_web_b2_key_id }}" B2_APPLICATION_KEY: "{{ saya_today_web_b2_application_key }}" ENVIRONMENT: "{{ saya_today_web_environment }}" + AUTH_SALT: "{{ saya_today_web_auth_salt }}" network_mode: caddy networks: - name: caddy ipv4_address: "{{ saya_today_web_ipv4_address }}" + volumes: + - "{{ saya_today_web_name }}-volume:/data:rw" restart_policy: unless-stopped no_log: true ... diff --git a/go.mod b/go.mod index c6f267d..e2e916e 100644 --- a/go.mod +++ b/go.mod @@ -25,9 +25,9 @@ require ( 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.33.0 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/net v0.42.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 e418881..b3ae996 100644 --- a/go.sum +++ b/go.sum @@ -43,16 +43,24 @@ 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= +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/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/api-v1-blog-search.go b/internal/router/api-v1-blog-search.go index 51ace85..a809783 100644 --- a/internal/router/api-v1-blog-search.go +++ b/internal/router/api-v1-blog-search.go @@ -48,7 +48,7 @@ func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []string, b2Clie c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering") } - decodedQuery, err := url.QueryUnescape(string(encodedQuery)) + decodedQuery, _ := url.QueryUnescape(string(encodedQuery)) matches := re.FindAllStringSubmatch(decodedQuery, -1) tags := make([]string, 0, len(matches)) 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] +} diff --git a/internal/router/lang-blog-title.go b/internal/router/lang-blog-title.go index b7b5d7e..5b80ec8 100644 --- a/internal/router/lang-blog-title.go +++ b/internal/router/lang-blog-title.go @@ -22,6 +22,10 @@ func init() { func Lang_Blog_Title(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client, md goldmark.Markdown) func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error { + ip := c.IP() + slog.Debug("client entering blog page", slog.String("ip", ip), slog.String("page", c.Path())) + go CCache.GetHash(ip) + lang := c.Params("lang") if !slices.Contains(langs, lang) { c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) diff --git a/internal/router/lang-blog.go b/internal/router/lang-blog.go index 650c4b1..0cbba94 100644 --- a/internal/router/lang-blog.go +++ b/internal/router/lang-blog.go @@ -37,7 +37,7 @@ func Lang_Blog(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering") } - decodedQuery, err := url.QueryUnescape(string(encodedQuery)) + decodedQuery, _ := url.QueryUnescape(string(encodedQuery)) matches := re.FindAllStringSubmatch(decodedQuery, -1) queryTags := make([]string, 0, len(matches)) diff --git a/main.go b/main.go index 566e796..9f95c04 100644 --- a/main.go +++ b/main.go @@ -68,7 +68,9 @@ func main() { localization[lang.Name] = localeCfg } - app := fiber.New(fiber.Config{}) + app := fiber.New(fiber.Config{ + ProxyHeader: "X-Forwarded-For", + }) app.Use(redirect.New(redirect.Config{ Rules: map[string]string{ @@ -78,6 +80,8 @@ func main() { StatusCode: 301, })) + router.CCache = router.NewClientCache([]byte(cfg.Auth.Salt)) + app.Get("/", router.Root(cfg.AvailableLanguages)) app.Get("/:lang/map", router.Lang_Map(localization, availableLanguages, b2Client)) app.Get("/:lang/blog", router.Lang_Blog(localization, availableLanguages, b2Client)) -- 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(-) 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 bbe41e5b06ab540a38c8c6b4e0cf719ab31b8e55 Mon Sep 17 00:00:00 2001 From: SayaAndy Date: Tue, 26 Aug 2025 12:16:54 +0700 Subject: fix: specify tag count and page count on enlist catalogue log --- internal/router/lang-blog.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/router/lang-blog.go b/internal/router/lang-blog.go index 5c302bb..e5220c6 100644 --- a/internal/router/lang-blog.go +++ b/internal/router/lang-blog.go @@ -58,7 +58,7 @@ func Lang_Blog(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B tagsMap[tag]++ } } - slog.Debug("enlist pages for catalogue", slog.Int("page_count", len(tagsMap)), slog.String("path", c.Path())) + slog.Debug("enlist pages for catalogue", slog.Int("tag_count", len(tagsMap)), slog.Int("page_count", len(pages)), slog.String("path", c.Path())) type Tag struct { Name string `json:"Name" yaml:"name"` -- 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 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 ea8c6da9b8bafa1d4e5fe5a77fb306006db7d6f6 Mon Sep 17 00:00:00 2001 From: SayaAndy Date: Wed, 27 Aug 2025 01:37:04 +0700 Subject: feat: update galleries on resize (with smooth animations this time) --- internal/lightgallery/html_renderer.go | 49 ++++++++++++++++++---------------- views/pages/blog-page.html | 16 +++++++++-- 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/internal/lightgallery/html_renderer.go b/internal/lightgallery/html_renderer.go index e20e9ec..17a6f2b 100644 --- a/internal/lightgallery/html_renderer.go +++ b/internal/lightgallery/html_renderer.go @@ -92,32 +92,35 @@ func (r *LightGalleryHTMLRenderer) renderLightGallery(w util.BufWriter, source [ w.WriteString(fmt.Sprintf(` `, galleryID, galleryID, strings.Join(dynamicElements, ","), galleryID)) +`, galleryID, galleryID, strings.Join(dynamicElements, ","), galleryID, galleryID)) } return ast.WalkContinue, nil diff --git a/views/pages/blog-page.html b/views/pages/blog-page.html index 83860d7..d685fda 100644 --- a/views/pages/blog-page.html +++ b/views/pages/blog-page.html @@ -12,6 +12,10 @@ height: 100% !important; } + + {{ end }} {{ define "body" }} @@ -70,10 +74,18 @@ {{ .MapLocationAreaMeters }} )}); + let resizeTimeout; window.addEventListener('resize', function() { - setTimeout(() => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { map.invalidateSize(); - }, 100); + var oldGalleryMap = new Map(galleryMap); + oldGalleryMap.forEach((createFunc, g, map) => { + galleryMap.delete(g); + g.destroy(); + createFunc(); + }); + }, 300); }); {{- end }} -- 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(-) 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 5e40ca4d314a9dc6cb7cb2c69081876f5aa446f3 Mon Sep 17 00:00:00 2001 From: SayaAndy Date: Fri, 29 Aug 2025 02:02:52 +0700 Subject: feat: implement decor of night/ram theme on general pages --- static/input.css | 14 ++++- views/layouts/general-page.html | 126 ++++++++++++++++++++++++++++++++++++---- views/pages/blog-page.html | 6 +- 3 files changed, 131 insertions(+), 15 deletions(-) diff --git a/static/input.css b/static/input.css index 18910eb..7b7da23 100644 --- a/static/input.css +++ b/static/input.css @@ -612,7 +612,7 @@ body[data-theme~="ram"] .leaflet-tile { } } -.star { +.night-star { color: var(--color-yellow-100); animation-name: blink; animation-duration: 5s; @@ -621,6 +621,18 @@ body[data-theme~="ram"] .leaflet-tile { -webkit-user-select: none; } +.ram-frame { + width: 20vmin; + height: 20vmin; + user-select: none; + -webkit-user-select: none; + box-shadow: + inset 1vmin 1vmin var(--background-dark-color), + inset -1vmin -1vmin var(--main-light-color), + 1vmin 1vmin var(--background-dark-color), + -1vmin -1vmin var(--main-dark-color); +} + @keyframes blink { 0% { opacity: 1; diff --git a/views/layouts/general-page.html b/views/layouts/general-page.html index 9885d66..a7cf66e 100644 --- a/views/layouts/general-page.html +++ b/views/layouts/general-page.html @@ -15,6 +15,112 @@ + + {{ block "bottom-embeds" . }}{{ end }} diff --git a/views/pages/blog-page.html b/views/pages/blog-page.html index d685fda..65ee31d 100644 --- a/views/pages/blog-page.html +++ b/views/pages/blog-page.html @@ -74,10 +74,10 @@ {{ .MapLocationAreaMeters }} )}); - let resizeTimeout; + let galleryResizeTimeout; window.addEventListener('resize', function() { - clearTimeout(resizeTimeout); - resizeTimeout = setTimeout(() => { + clearTimeout(galleryResizeTimeout); + galleryResizeTimeout = setTimeout(() => { map.invalidateSize(); var oldGalleryMap = new Map(galleryMap); oldGalleryMap.forEach((createFunc, g, map) => { -- cgit v1.3.1+13 From 753fe7f1d5bf837a85481425bd6bf6b431ffb611 Mon Sep 17 00:00:00 2001 From: SayaAndy Date: Fri, 29 Aug 2025 03:19:12 +0700 Subject: feat: remove box shadow of ram frames --- static/input.css | 5 ----- 1 file changed, 5 deletions(-) diff --git a/static/input.css b/static/input.css index 7b7da23..f254970 100644 --- a/static/input.css +++ b/static/input.css @@ -626,11 +626,6 @@ body[data-theme~="ram"] .leaflet-tile { height: 20vmin; user-select: none; -webkit-user-select: none; - box-shadow: - inset 1vmin 1vmin var(--background-dark-color), - inset -1vmin -1vmin var(--main-light-color), - 1vmin 1vmin var(--background-dark-color), - -1vmin -1vmin var(--main-dark-color); } @keyframes blink { -- 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 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 3d79cbf98ef66d916a1d255857f97368d864ffb6 Mon Sep 17 00:00:00 2001 From: SayaAndy Date: Sat, 30 Aug 2025 00:39:32 +0700 Subject: fix: add gcc to build stage --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 4388e5d..eeee6c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM golang:1.24.6-alpine3.22 AS build-stage -RUN apk add --no-cache sqlite-dev +RUN apk add --no-cache sqlite-dev musl-dev gcc WORKDIR /builddir COPY . . -- 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(-) 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(-) 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 From 1c14a4088b068fefe425cad1acdde2d603bd27a9 Mon Sep 17 00:00:00 2001 From: SayaAndy Date: Sat, 30 Aug 2025 16:00:28 +0700 Subject: feat: always show search buttons out of overflow feat: add favicon --- static/favicon.ico | Bin 0 -> 5694 bytes static/input.css | 4 +++ views/pages/blog-catalogue.html | 75 +++++++++++++++++++++------------------- 3 files changed, 43 insertions(+), 36 deletions(-) create mode 100644 static/favicon.ico diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..33eb226 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/input.css b/static/input.css index f254970..47e7d16 100644 --- a/static/input.css +++ b/static/input.css @@ -581,6 +581,10 @@ body[data-theme~="ram"] .leaflet-tile { width: 100vw; } + .ar-lt-0\.8\:max-h-\[25\%\] { + max-height: 25%; + } + .ar-lt-0\.8\:max-h-\[80\%\] { max-height: 80%; } diff --git a/views/pages/blog-catalogue.html b/views/pages/blog-catalogue.html index 25b0685..1d0a2de 100644 --- a/views/pages/blog-catalogue.html +++ b/views/pages/blog-catalogue.html @@ -1,44 +1,47 @@ {{ define "body" }}
-
- {{ .L.BlogSearch.TagsHeader }} -
-
- + class="tags-list ar-gt-0.8:min-w-[10vh] ar-gt-0.8:max-w-[20vh] ar-gt-0.8:w-fit ar-lt-0.8:w-[100%] ar-lt-0.8:max-h-[25%] flex shrink-0 flex-col ar-gt-0.8:mr-[1.2vmin]"> +
+
+ {{ .L.BlogSearch.TagsHeader }} +
+
+ +
+ {{- range .Tags }} +
+ +
+ {{- end }}
- {{- range .Tags }} -
- +
+
+ {{ .L.BlogSearch.OrderByHeader }} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
- {{- end }} -
-
-
- {{ .L.BlogSearch.OrderByHeader }} -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
+ +
+
+