summaryrefslogtreecommitdiff
diff options
from:
to:
context:
space:
mode:
authorGravatar SayaAndy <saya.andy@posteo.com> 2025-10-28 23:04:53 +0700
committerGravatar SayaAndy <saya.andy@posteo.com> 2025-10-28 23:04:53 +0700
commit1862f232eb70105340a91e74a5630c233a411bca (patch)
tree464c18fed0c21b63b65425f48a3a790229461058
parent629e275e5270ed146f9cd6dba25383cf80022909 (diff)
downloadweb-1862f232eb70105340a91e74a5630c233a411bca.tar.gz
web-1862f232eb70105340a91e74a5630c233a411bca.zip
refactor: make structurized implementation of routes + separate identity of router
feat: update go to 1.25 feat: update all dependencies debug: display routes when debug logs turned on
-rw-r--r--Dockerfile4
-rw-r--r--go.mod30
-rw-r--r--go.sum62
-rw-r--r--internal/router/api-v1-blog-search.go128
-rw-r--r--internal/router/api-v1-email-is-in-verification.go58
-rw-r--r--internal/router/api-v1-email-send-verification-code.go98
-rw-r--r--internal/router/api-v1-email-verify.go52
-rw-r--r--internal/router/api-v1-general-page-body.go243
-rw-r--r--internal/router/api-v1-general-page-bottom-embeds.go86
-rw-r--r--internal/router/api-v1-general-page-footer.go86
-rw-r--r--internal/router/api-v1-general-page-header.go98
-rw-r--r--internal/router/api-v1-general-page-top-embeds.go86
-rw-r--r--internal/router/api-v1-general-page.go59
-rw-r--r--internal/router/api-v1-like.go122
-rw-r--r--internal/router/api-v1-subs.go67
-rw-r--r--internal/router/api-v1-tz.go37
-rw-r--r--internal/router/basic-handler.go62
-rw-r--r--internal/router/client-cache.go2
-rw-r--r--internal/router/handlers/api-v1-blog-search.go126
-rw-r--r--internal/router/handlers/api-v1-email-is-in-verification.go59
-rw-r--r--internal/router/handlers/api-v1-email-send-verification-code.go97
-rw-r--r--internal/router/handlers/api-v1-email-verify.go62
-rw-r--r--internal/router/handlers/api-v1-like-get.go63
-rw-r--r--internal/router/handlers/api-v1-like-put.go74
-rw-r--r--internal/router/handlers/api-v1-subs-put.go65
-rw-r--r--internal/router/handlers/api-v1-tz-get.go65
-rw-r--r--internal/router/handlers/lang-blog-title.go113
-rw-r--r--internal/router/handlers/lang-blog.go112
-rw-r--r--internal/router/handlers/lang-map.go95
-rw-r--r--internal/router/handlers/lang-user-unsubscribe.go68
-rw-r--r--internal/router/handlers/lang-user.go78
-rw-r--r--internal/router/handlers/lang.go56
-rw-r--r--internal/router/handlers/root.go47
-rw-r--r--internal/router/lang-map.go88
-rw-r--r--internal/router/lang-user-unsubscribe.go71
-rw-r--r--internal/router/page-cache.go5
-rw-r--r--internal/router/path-matcher.go48
-rw-r--r--internal/router/router.go555
-rw-r--r--internal/router/tm.go18
-rw-r--r--internal/templatemanager/templatemanager.go30
-rw-r--r--main.go186
-rw-r--r--views/layouts/general-page.html14
-rw-r--r--views/partials/general-page-body.html1
-rw-r--r--views/partials/general-page-header.html1
44 files changed, 1947 insertions, 1630 deletions
diff --git a/Dockerfile b/Dockerfile
index eeee6c5..7230149 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.24.6-alpine3.22 AS build-stage
+FROM golang:1.25.3-alpine3.22 AS build-stage
RUN apk add --no-cache sqlite-dev musl-dev gcc
@@ -6,7 +6,7 @@ WORKDIR /builddir
COPY . .
RUN CGO_ENABLED=1 go build -o sayana-web .
-FROM alpine:3.22.1 AS runtime-stage
+FROM alpine:3.22.2 AS runtime-stage
ENV ENVIRONMENT=""
ENV AUTH_SALT=""
diff --git a/go.mod b/go.mod
index 4ea7ede..83f1af9 100644
--- a/go.mod
+++ b/go.mod
@@ -1,43 +1,43 @@
module github.com/SayaAndy/saya-today-web
-go 1.24.6
+go 1.25
require (
github.com/Backblaze/blazer v0.7.2
- github.com/go-playground/validator/v10 v10.27.0
+ github.com/dgraph-io/ristretto/v2 v2.3.0
+ github.com/go-co-op/gocron/v2 v2.17.0
+ github.com/go-playground/validator/v10 v10.28.0
github.com/gofiber/fiber/v2 v2.52.9
- github.com/golang-migrate/migrate/v4 v4.18.3
+ github.com/golang-migrate/migrate/v4 v4.19.0
github.com/mattn/go-sqlite3 v1.14.32
+ github.com/valyala/fasthttp v1.68.0
+ github.com/wneessen/go-mail v0.7.2
github.com/yuin/goldmark v1.7.13
- golang.org/x/crypto v0.41.0
+ golang.org/x/crypto v0.43.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect
+ github.com/clipperhouse/stringish v0.1.1 // indirect
+ github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
- github.com/go-co-op/gocron/v2 v2.17.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
- github.com/klauspost/compress v1.18.0 // indirect
+ github.com/klauspost/compress v1.18.1 // indirect
+ github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // 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.4.7 // indirect
+ github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
- github.com/stretchr/testify v1.11.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
- github.com/valyala/fasthttp v1.65.0 // indirect
- github.com/wneessen/go-mail v0.7.2 // indirect
- go.uber.org/atomic v1.7.0 // indirect
- golang.org/x/sys v0.35.0 // indirect
- golang.org/x/text v0.29.0 // indirect
+ golang.org/x/sys v0.37.0 // indirect
+ golang.org/x/text v0.30.0 // indirect
)
diff --git a/go.sum b/go.sum
index dd213f1..a95204e 100644
--- a/go.sum
+++ b/go.sum
@@ -4,11 +4,17 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
+github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
+github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
+github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
+github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
@@ -21,12 +27,12 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
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=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
-github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
+github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
+github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
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/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/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE=
+github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0=
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/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -36,8 +42,12 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
-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/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
+github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -46,46 +56,40 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
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/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
-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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8=
-github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4=
+github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
+github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
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=
-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=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
+golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
-golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
-golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
-golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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
deleted file mode 100644
index 5b1d14d..0000000
--- a/internal/router/api-v1-blog-search.go
+++ /dev/null
@@ -1,128 +0,0 @@
-package router
-
-import (
- "encoding/json"
- "fmt"
- "log/slog"
- "net/url"
- "regexp"
- "slices"
- "strings"
- "time"
-
- "github.com/SayaAndy/saya-today-web/config"
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- assert(0, tm.Add("catalogue-blog-cards", "views/partials/catalogue-blog-cards.html", "views/partials/catalogue-blog-card-tags.html"))
-}
-
-func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- sort := c.Query("sort")
- lang := c.Query("lang")
- tz := c.Query("tz")
-
- for _, availableLang := range langs {
- if availableLang.Name == lang {
- goto langIsAvailable
- }
- }
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang))
-
- langIsAvailable:
- loc, err := time.LoadLocation(tz)
- if err != nil {
- loc = time.UTC
- slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz))
- }
-
- cacheKey := "blog-search." + lang + ".pages-list"
- var pages []*b2.BlogPage
- if pagesBytes, ok := PCache.Get(cacheKey); pagesBytes != nil || ok {
- json.Unmarshal(pagesBytes, &pages)
- } else {
- pages, err = b2Client.Scan(lang + "/")
- if err != nil {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrInternalServerError.Code).SendString(fmt.Sprintf("failed to scan pages for '%s' lang: %v", lang, err))
- }
- pagesBytes, _ := json.Marshal(pages)
- PCache.SetWithTTL(cacheKey, pagesBytes, int64(len(pagesBytes)), 5*time.Minute)
- }
-
- encodedQuery := c.Request().URI().QueryString()
- re, err := regexp.Compile(`tags\[\]=([\w]+)`)
- if err != nil {
- slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error()))
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering")
- }
- decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
- matches := re.FindAllStringSubmatch(decodedQuery, -1)
-
- tags := make([]string, 0, len(matches))
- for _, match := range matches {
- tags = append(tags, string(match[1]))
- }
-
- pageMeta := make([]fiber.Map, 0, len(pages))
- for _, page := range pages {
- for _, tag := range page.Metadata.Tags {
- if len(tags) == 0 || slices.Contains(tags, tag) {
- pageMeta = append(pageMeta, fiber.Map{
- "Link": page.Link,
- "ArticleLink": "/" + lang + "/blog/" + page.FileName,
- "Title": page.Metadata.Title,
- "PublishedTime": page.Metadata.PublishedTime.In(loc).Format("2006-01-02 15:04:05 -07:00"),
- "ActionDate": page.Metadata.ActionDate,
- "ShortDescription": page.Metadata.ShortDescription,
- "Thumbnail": page.Metadata.Thumbnail,
- "Tags": page.Metadata.Tags,
- "LikeCount": CCache.GetLikeCount(page.FileName),
- "ViewCount": CCache.GetViewCount(page.FileName),
- })
- break
- }
- }
- }
-
- slices.SortFunc(pageMeta, func(a, b fiber.Map) int {
- switch sort {
- case "titleAsc":
- return strings.Compare(a["Title"].(string), b["Title"].(string))
- case "titleDesc":
- return strings.Compare(b["Title"].(string), a["Title"].(string))
- case "actionDateAsc":
- return strings.Compare(a["ActionDate"].(string), b["ActionDate"].(string))
- case "actionDateDesc":
- return strings.Compare(b["ActionDate"].(string), a["ActionDate"].(string))
- case "publicationDateAsc":
- publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string))
- publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string))
- return publishedTimeA.Compare(publishedTimeB)
- case "publicationDateDesc":
- publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string))
- publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string))
- return publishedTimeB.Compare(publishedTimeA)
- }
- return 0
- })
-
- content, err := tm.Render("catalogue-blog-cards", fiber.Map{
- "BlogPages": pageMeta,
- "L": l[lang],
- })
- if err != nil {
- slog.Warn("failed to generate div", slog.String("page", "/"+lang+"/blog"), slog.String("error", err.Error()))
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
-
- return c.Type("html").Send(content)
- }
-}
diff --git a/internal/router/api-v1-email-is-in-verification.go b/internal/router/api-v1-email-is-in-verification.go
deleted file mode 100644
index 83c783c..0000000
--- a/internal/router/api-v1-email-is-in-verification.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package router
-
-import (
- "fmt"
- "net/url"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- assert(0, tm.Add("personal-page-status", "views/partials/personal-page-status.html"))
-}
-
-func Api_V1_Email_IsInVerification(l map[string]*locale.LocaleConfig) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- id := c.IP()
- lang := "en"
-
- var referer, path string
- var pathParts []string
- var urlStruct *url.URL
- var err error
-
- referer = c.Get("Referer", "")
- if referer == "" {
- goto skipFetchingLang
- }
-
- urlStruct, err = url.ParseRequestURI(referer)
- if err != nil {
- goto skipFetchingLang
- }
-
- path = urlStruct.EscapedPath()
- pathParts = strings.Split(strings.Trim(path, "/"), "/")
- if len(pathParts) == 0 {
- goto skipFetchingLang
- }
-
- lang = pathParts[0]
-
- skipFetchingLang:
- isAllowed, whenAllowed, codeExpiry := Mailer.IsAllowedToRetryVerification(id)
- if !isAllowed {
- return api_v1_email_sendStatusHtml(c, "email-message", l, "Neutral", fiber.StatusOK, lang, strings.ReplaceAll(l[lang].UserProfile.DelayTilVerification, "{}", whenAllowed.Format("2006-01-02 15:04:05 MST")), map[string]string{
- "striked-end-time": fmt.Sprint(whenAllowed.UnixMilli()),
- "code-expiry-time": fmt.Sprint(codeExpiry.UnixMilli()),
- })
- }
- return api_v1_email_sendStatusHtml(c, "email-message", l, "OK", fiber.StatusOK, lang, "", map[string]string{
- "code-expiry-time": fmt.Sprint(codeExpiry.UnixMilli()),
- })
- }
-}
diff --git a/internal/router/api-v1-email-send-verification-code.go b/internal/router/api-v1-email-send-verification-code.go
deleted file mode 100644
index 9372eb3..0000000
--- a/internal/router/api-v1-email-send-verification-code.go
+++ /dev/null
@@ -1,98 +0,0 @@
-package router
-
-import (
- "fmt"
- "html/template"
- "log/slog"
- "net/url"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- assert(0, tm.Add("personal-page-status", "views/partials/personal-page-status.html"))
-}
-
-func Api_V1_Email_SendVerificationCode(l map[string]*locale.LocaleConfig) 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) != 2 {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/user'")
- }
-
- lang := pathParts[0]
- id := c.IP()
-
- email := c.FormValue("email")
- if email == "" {
- return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.EmailEmpty, map[string]string{})
- }
-
- isTaken, err := Mailer.MailIsTaken(email)
- if err != nil {
- slog.Error("failed to check if address is already taken", slog.String("error", err.Error()))
- return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.VerificationCodeSendingError, map[string]string{})
- }
- if isTaken {
- return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.EmailTaken, map[string]string{})
- }
-
- if isAllowed, whenAllowed, _ := Mailer.IsAllowedToRetryVerification(id); !isAllowed {
- return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, strings.ReplaceAll(l[lang].UserProfile.DelayTilVerification, "{}", whenAllowed.Format("2006-01-02 15:04:05 MST")), map[string]string{
- "striked-end-time": fmt.Sprint(whenAllowed.UnixMilli()),
- })
- }
-
- if previousEmail, _, _ := Mailer.GetInfo(Mailer.GetHash(id)); previousEmail == email {
- return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.EmailAlreadyValidated, map[string]string{})
- }
-
- if err = Mailer.SendVerificationCode(id, email, lang); err != nil {
- slog.Error("failed to send a verification code", slog.String("error", err.Error()))
- return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.VerificationCodeSendingError, map[string]string{})
- }
-
- _, endTime, codeExpiry := Mailer.IsAllowedToRetryVerification(id)
- return api_v1_email_sendStatusHtml(c, "email-message", l, "OK", fiber.StatusOK, lang, strings.ReplaceAll(l[lang].UserProfile.VerificationCodeSent, "{}", endTime.Format("2006-01-02 15:04:05 MST")), map[string]string{
- "striked-end-time": fmt.Sprint(endTime.UnixMilli()),
- "code-expiry-time": fmt.Sprint(codeExpiry.UnixMilli()),
- })
- }
-}
-
-func api_v1_email_sendStatusHtml(c *fiber.Ctx, divId string, l map[string]*locale.LocaleConfig, status string, code int, lang string, message string, dataAttributes map[string]string) error {
- sterileDataset := make(map[string]interface{})
- for k, v := range dataAttributes {
- sterileDataset[k] = template.HTMLAttr(fmt.Sprintf("data-%s=\"%s\"", k, template.HTMLEscapeString(v)))
- }
-
- content, err := tm.Render("personal-page-status", fiber.Map{
- "L": l[lang],
- "Status": status,
- "Message": message,
- "StatusId": divId,
- "DataAttributes": sterileDataset,
- })
- if err != nil {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- slog.Error("failed to render the email status message", slog.String("error", err.Error()), slog.String("div_id", divId), slog.String("message", message))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("Failed to render the email status message, please ask administrator for a more detailed cause")
- }
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(code).Send(content)
-}
diff --git a/internal/router/api-v1-email-verify.go b/internal/router/api-v1-email-verify.go
deleted file mode 100644
index 7fbfdec..0000000
--- a/internal/router/api-v1-email-verify.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package router
-
-import (
- "fmt"
- "log/slog"
- "net/url"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- assert(0, tm.Add("personal-page-status", "views/partials/personal-page-status.html"))
-}
-
-func Api_V1_Email_Verify(l map[string]*locale.LocaleConfig) 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) != 2 {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/user'")
- }
-
- lang := pathParts[0]
-
- verificationCode := c.FormValue("email_code")
- if verificationCode == "" {
- return api_v1_email_sendStatusHtml(c, "verification-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.VerificationEmpty, map[string]string{})
- }
-
- if err = Mailer.Verify(verificationCode, lang); err != nil {
- slog.Error("verification code is invalid", slog.String("verification_code", verificationCode), slog.String("error", err.Error()))
- return api_v1_email_sendStatusHtml(c, "verification-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.VerificationFailed, map[string]string{})
- }
- return api_v1_email_sendStatusHtml(c, "verification-message", l, "OK", fiber.StatusOK, lang, l[lang].UserProfile.VerificationSuccess+"\n\n"+l[lang].UserProfile.RefreshPage, map[string]string{
- "hide-verification-panel": "true",
- })
- }
-}
diff --git a/internal/router/api-v1-general-page-body.go b/internal/router/api-v1-general-page-body.go
deleted file mode 100644
index b5567b7..0000000
--- a/internal/router/api-v1-general-page-body.go
+++ /dev/null
@@ -1,243 +0,0 @@
-package router
-
-import (
- "bytes"
- "fmt"
- "html/template"
- "log/slog"
- "math/rand"
- "net/url"
- "regexp"
- "slices"
- "strings"
- "time"
-
- "github.com/SayaAndy/saya-today-web/config"
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/internal/blogtrigger"
- "github.com/SayaAndy/saya-today-web/internal/factgiver"
- "github.com/SayaAndy/saya-today-web/internal/frontmatter"
- "github.com/SayaAndy/saya-today-web/internal/mailer"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
- "github.com/yuin/goldmark"
-)
-
-var FactGiver *factgiver.FactGiver
-var Mailer *mailer.Mailer
-var BlogTrigger *blogtrigger.BlogTriggerScheduler
-
-func init() {
- assert(0, tm.Add("general-page-body", "views/partials/general-page-body.html"))
-}
-
-func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client, md goldmark.Markdown) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- referer := c.Get("Referer", "")
- if referer == "" {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
- }
- urlStruct, err := url.ParseRequestURI(referer)
- if err != nil {
- return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
- }
-
- path := urlStruct.EscapedPath()
- trimmedPath := strings.Trim(path, "/")
-
- cacheKey := fmt.Sprintf("body.%s", trimmedPath)
- if val, ok := PCache.Get(cacheKey); !strings.HasSuffix(trimmedPath, "user") && val != nil && ok {
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(val)
- }
-
- lang := ""
- pathParts := strings.Split(trimmedPath, "/")
- if len(pathParts) == 1 && pathParts[0] == "" {
- pathParts = []string{}
- }
- if len(pathParts) > 0 {
- lang = pathParts[0]
- for _, availableLang := range langs {
- if availableLang.Name == lang {
- goto langIsAvailable
- }
- }
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
- }
-
- langIsAvailable:
- values := fiber.Map{
- "L": l[lang],
- "Lang": lang,
- "Path": strings.Trim(path, "/"),
- "QueryString": string(c.Request().URI().QueryString()),
- }
- var additionalTemplates []string
-
- if len(pathParts) == 2 && pathParts[1] == "blog" {
- querySort := c.Query("sort")
- if querySort == "" {
- querySort = "publicationDateDesc"
- }
-
- encodedQuery := c.Request().URI().QueryString()
- re, err := regexp.Compile(`tags\[\]=([\w]+)`)
- if err != nil {
- slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering")
- }
- decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
- matches := re.FindAllStringSubmatch(decodedQuery, -1)
-
- queryTags := make([]string, 0, len(matches))
- for _, match := range matches {
- queryTags = append(queryTags, string(match[1]))
- }
-
- tagsArray, err := getTags(b2Client, lang)
- if err != nil {
- slog.Warn("failed to get the available tags", slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to gather available tags")
- }
-
- values["Tags"] = tagsArray
- values["QuerySort"] = querySort
- values["QueryTags"] = strings.Join(queryTags, ",")
- values["Title"] = l[lang].BlogSearch.Header
-
- additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
- } else if len(pathParts) == 2 && pathParts[1] == "user" {
- values["Title"] = l[lang].UserProfile.Header
-
- email, _, err := Mailer.GetInfo(Mailer.GetHash(c.IP()))
- if err != nil {
- slog.Error("get info from mailer about a client", slog.String("error", err.Error()))
- }
-
- tagsArray, err := getTags(b2Client, lang)
- if err != nil {
- slog.Warn("failed to get the available tags", slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to gather available tags")
- }
-
- subscriptionType, tags, err := Mailer.GetSubscriptions(c.IP())
- if err != nil {
- slog.Warn("failed to get the user subscriptions", slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to get the user subscriptions")
- }
-
- switch subscriptionType {
- case mailer.None:
- values["TagsPicked"] = "none"
- case mailer.All:
- values["TagsPicked"] = "all"
- case mailer.Specific:
- values["TagsPicked"] = "specific"
- }
-
- values["TagsPickedList"] = tags
-
- values["Email"] = email
- values["EmailCode"] = c.Query("email_code")
- values["ExistingTags"] = tagsArray
-
- additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
- } else if len(pathParts) == 3 && pathParts[1] == "blog" {
- metadata, parsedMarkdown, err := readBlogPost(md, b2Client, lang+"/"+pathParts[2])
- if err != nil {
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("failed to find '%s' post", pathParts[2]))
- }
-
- geolocationParts := strings.Split(metadata.Geolocation, " ")
- var x, y, areaError string
- if len(geolocationParts) >= 2 {
- x = geolocationParts[0]
- y = geolocationParts[1]
- }
- if len(geolocationParts) >= 3 {
- areaError = geolocationParts[2]
- }
-
- values["MapLocationX"] = x
- values["MapLocationY"] = y
- values["MapLocationAreaMeters"] = areaError
- values["Title"] = metadata.Title
- values["ParsedMarkdown"] = template.HTML(parsedMarkdown)
- values["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00")
- values["ActionDate"] = metadata.ActionDate
-
- additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
-
- go CCache.View(c.IP(), pathParts[2])
- } else if len(pathParts) == 1 {
- values["Title"] = l[lang].HomePage.Header
- values["FilledHeartCount"] = uint(40)
- values["OutlineHeartCount"] = uint(40)
- values["GifName"] = fmt.Sprintf("otter-%d.gif", rand.Int()%3+1)
- values["FunFacts"] = FactGiver.Give(lang)
- additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
- } else if len(pathParts) == 0 {
- values["Title"] = "Choose Your Language"
- values["AvailableLanguages"] = langs
- additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html")
- }
-
- content, err := tm.Render("general-page-body", values, additionalTemplates...)
- if err != nil {
- slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
-
- go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(content)
- }
-}
-
-func readBlogPost(md goldmark.Markdown, b2Client *b2.B2Client, sourceName string) (metadata *frontmatter.Metadata, html string, err error) {
- metadata, markdown, err := b2Client.ReadFrontmatter(sourceName + ".md")
- if err != nil {
- return nil, "", fmt.Errorf("failed to read a frontmatter file: %w", err)
- }
-
- var buf bytes.Buffer
- if err := md.Convert(markdown, &buf); err != nil {
- return nil, "", fmt.Errorf("convert source context from md to html: %w", err)
- }
-
- return metadata, buf.String(), nil
-}
-
-type Tag struct {
- Name string `json:"Name" yaml:"name"`
- Count int `json:"Count" yaml:"count"`
-}
-
-func getTags(b2Client *b2.B2Client, lang string) (tags []Tag, err error) {
- pages, err := b2Client.Scan(lang + "/")
- if err != nil {
- slog.Warn("failed to scan pages via b2", slog.String("error", err.Error()))
- return nil, fmt.Errorf("failed to scan pages via b2: %w", err)
- }
-
- tagsMap := make(map[string]int)
- for _, page := range pages {
- for _, tag := range page.Metadata.Tags {
- tagsMap[tag]++
- }
- }
- slog.Debug("enlist pages for catalogue", slog.Int("tag_count", len(tagsMap)), slog.Int("page_count", len(pages)), slog.String("lang", lang))
-
- tagsArray := make([]Tag, 0, len(tagsMap))
- for tag, count := range tagsMap {
- tagsArray = append(tagsArray, Tag{tag, count})
- }
- slices.SortFunc(tagsArray, func(a Tag, b Tag) int {
- return strings.Compare(a.Name, b.Name)
- })
-
- return tagsArray, nil
-}
diff --git a/internal/router/api-v1-general-page-bottom-embeds.go b/internal/router/api-v1-general-page-bottom-embeds.go
deleted file mode 100644
index b408187..0000000
--- a/internal/router/api-v1-general-page-bottom-embeds.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package router
-
-import (
- "fmt"
- "log/slog"
- "net/url"
- "strings"
- "time"
-
- "github.com/SayaAndy/saya-today-web/config"
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- assert(0, tm.Add("general-page-bottom-embeds", "views/partials/general-page-bottom-embeds.html"))
-}
-
-func Api_V1_GeneralPage_BottomEmbeds(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- referer := c.Get("Referer", "")
- if referer == "" {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
- }
- urlStruct, err := url.ParseRequestURI(referer)
- if err != nil {
- return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
- }
-
- path := urlStruct.EscapedPath()
- cacheKey := fmt.Sprintf("bottom-embeds.%s", path)
- if val, ok := PCache.Get(cacheKey); val != nil && ok {
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(val)
- }
-
- lang := ""
- pathParts := strings.Split(strings.Trim(path, "/"), "/")
- if len(pathParts) == 1 && pathParts[0] == "" {
- pathParts = []string{}
- }
- if len(pathParts) > 0 {
- lang = pathParts[0]
- for _, availableLang := range langs {
- if availableLang.Name == lang {
- goto langIsAvailable
- }
- }
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
- }
-
- langIsAvailable:
- values := fiber.Map{
- "L": l[lang],
- "Lang": lang,
- "Path": strings.Trim(path, "/"),
- "QueryString": string(c.Request().URI().QueryString()),
- }
- var additionalTemplates []string
-
- if len(pathParts) == 2 && pathParts[1] == "blog" {
- additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
- } else if len(pathParts) == 2 && pathParts[1] == "user" {
- additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
- } else if len(pathParts) == 3 && pathParts[1] == "blog" {
- additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
- } else if len(pathParts) == 1 {
- additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
- } else if len(pathParts) == 0 {
- additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html")
- }
-
- content, err := tm.Render("general-page-bottom-embeds", values, additionalTemplates...)
- if err != nil {
- slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
-
- go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(content)
- }
-}
diff --git a/internal/router/api-v1-general-page-footer.go b/internal/router/api-v1-general-page-footer.go
deleted file mode 100644
index 99d899a..0000000
--- a/internal/router/api-v1-general-page-footer.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package router
-
-import (
- "fmt"
- "log/slog"
- "net/url"
- "strings"
- "time"
-
- "github.com/SayaAndy/saya-today-web/config"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- assert(0, tm.Add("general-page-footer", "views/partials/general-page-footer.html"))
-}
-
-func Api_V1_GeneralPage_Footer(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- referer := c.Get("Referer", "")
- if referer == "" {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
- }
- urlStruct, err := url.ParseRequestURI(referer)
- if err != nil {
- return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
- }
-
- path := urlStruct.EscapedPath()
-
- cacheKey := fmt.Sprintf("footer.%s", path)
- if val, ok := PCache.Get(cacheKey); val != nil && ok {
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(val)
- }
-
- lang := ""
- pathParts := strings.Split(strings.Trim(path, "/"), "/")
- if len(pathParts) == 1 && pathParts[0] == "" {
- pathParts = []string{}
- }
- if len(pathParts) > 0 {
- lang = pathParts[0]
- for _, availableLang := range langs {
- if availableLang.Name == lang {
- goto langIsAvailable
- }
- }
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
- }
-
- langIsAvailable:
- values := fiber.Map{
- "L": l[lang],
- "Lang": lang,
- "Path": strings.Trim(path, "/"),
- "QueryString": string(c.Request().URI().QueryString()),
- }
- var additionalTemplates []string
-
- if len(pathParts) == 2 && pathParts[1] == "blog" {
- additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
- } else if len(pathParts) == 2 && pathParts[1] == "user" {
- additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
- } else if len(pathParts) == 3 && pathParts[1] == "blog" {
- additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
- } else if len(pathParts) == 1 {
- additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
- } else if len(pathParts) == 0 {
- additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html")
- }
-
- content, err := tm.Render("general-page-footer", values, additionalTemplates...)
- if err != nil {
- slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
-
- go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(content)
- }
-}
diff --git a/internal/router/api-v1-general-page-header.go b/internal/router/api-v1-general-page-header.go
deleted file mode 100644
index 768417d..0000000
--- a/internal/router/api-v1-general-page-header.go
+++ /dev/null
@@ -1,98 +0,0 @@
-package router
-
-import (
- "fmt"
- "log/slog"
- "net/url"
- "strings"
- "time"
-
- "github.com/SayaAndy/saya-today-web/config"
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- assert(0, tm.Add("general-page-header", "views/partials/general-page-header.html"))
-}
-
-func Api_V1_GeneralPage_Header(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- referer := c.Get("Referer", "")
- if referer == "" {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
- }
- urlStruct, err := url.ParseRequestURI(referer)
- if err != nil {
- return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
- }
-
- path := urlStruct.EscapedPath()
-
- cacheKey := fmt.Sprintf("header.%s", path)
- if val, ok := PCache.Get(cacheKey); val != nil && ok {
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(val)
- }
-
- lang := ""
- pathParts := strings.Split(strings.Trim(path, "/"), "/")
- if len(pathParts) == 1 && pathParts[0] == "" {
- pathParts = []string{}
- }
- if len(pathParts) > 0 {
- lang = pathParts[0]
- for _, availableLang := range langs {
- if availableLang.Name == lang {
- goto langIsAvailable
- }
- }
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
- }
-
- langIsAvailable:
- values := fiber.Map{
- "L": l[lang],
- "Lang": lang,
- "Path": strings.Trim(path, "/"),
- "QueryString": string(c.Request().URI().QueryString()),
- }
- var additionalTemplates []string
-
- if len(pathParts) == 2 && pathParts[1] == "blog" {
- values["Title"] = l[lang].BlogSearch.Header
- additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
- } else if len(pathParts) == 2 && pathParts[1] == "user" {
- values["Title"] = l[lang].UserProfile.Header
- additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
- } else if len(pathParts) == 3 && pathParts[1] == "blog" {
- metadata, _, err := b2Client.ReadFrontmatter(lang + "/" + pathParts[2] + ".md")
- if err != nil {
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("could not read '%s' for content", path))
- }
- values["Title"] = metadata.Title
- values["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00")
- values["ActionDate"] = metadata.ActionDate
- additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
- } else if len(pathParts) == 1 {
- values["Title"] = l[lang].HomePage.Header
- additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
- } else if len(pathParts) == 0 {
- values["Title"] = "Choose Your Language"
- additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html")
- }
-
- content, err := tm.Render("general-page-header", values, additionalTemplates...)
- if err != nil {
- slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
-
- go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(content)
- }
-}
diff --git a/internal/router/api-v1-general-page-top-embeds.go b/internal/router/api-v1-general-page-top-embeds.go
deleted file mode 100644
index f3e480b..0000000
--- a/internal/router/api-v1-general-page-top-embeds.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package router
-
-import (
- "fmt"
- "log/slog"
- "net/url"
- "strings"
- "time"
-
- "github.com/SayaAndy/saya-today-web/config"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- assert(0, tm.Add("general-page-top-embeds", "views/partials/general-page-top-embeds.html"))
-}
-
-func Api_V1_GeneralPage_TopEmbeds(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- referer := c.Get("Referer", "")
- if referer == "" {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
- }
- urlStruct, err := url.ParseRequestURI(referer)
- if err != nil {
- return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
- }
-
- path := urlStruct.EscapedPath()
-
- cacheKey := fmt.Sprintf("top-embeds.%s", path)
- if val, ok := PCache.Get(cacheKey); val != nil && ok {
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(val)
- }
-
- lang := ""
- pathParts := strings.Split(strings.Trim(path, "/"), "/")
- if len(pathParts) == 1 && pathParts[0] == "" {
- pathParts = []string{}
- }
- if len(pathParts) > 0 {
- lang = pathParts[0]
- for _, availableLang := range langs {
- if availableLang.Name == lang {
- goto langIsAvailable
- }
- }
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
- }
-
- langIsAvailable:
- values := fiber.Map{
- "L": l[lang],
- "Lang": lang,
- "Path": strings.Trim(path, "/"),
- "QueryString": string(c.Request().URI().QueryString()),
- }
- var additionalTemplates []string
-
- if len(pathParts) == 2 && pathParts[1] == "blog" {
- additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
- } else if len(pathParts) == 2 && pathParts[1] == "user" {
- additionalTemplates = append(additionalTemplates, "views/pages/user-page.html")
- } else if len(pathParts) == 3 && pathParts[1] == "blog" {
- additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
- } else if len(pathParts) == 1 {
- additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
- } else if len(pathParts) == 0 {
- additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html")
- }
-
- content, err := tm.Render("general-page-top-embeds", values, additionalTemplates...)
- if err != nil {
- slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
-
- go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(content)
- }
-}
diff --git a/internal/router/api-v1-general-page.go b/internal/router/api-v1-general-page.go
deleted file mode 100644
index c8b4362..0000000
--- a/internal/router/api-v1-general-page.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package router
-
-import (
- "fmt"
- "log/slog"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/config"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- assert(0, tm.Add("general-page", "views/layouts/general-page.html"))
-}
-
-func Api_V1_GeneralPage(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- path := c.Path()
-
- lang := ""
- pathParts := strings.Split(strings.Trim(path, "/"), "/")
- if len(pathParts) == 1 && pathParts[0] == "" {
- pathParts = []string{}
- }
- if len(pathParts) > 0 {
- lang = pathParts[0]
- for _, availableLang := range langs {
- if availableLang.Name == lang {
- goto langIsAvailable
- }
- }
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
- }
-
- langIsAvailable:
- queryString := string(c.Request().URI().QueryString())
- cacheKey := fmt.Sprintf("general-page.%s?%s", lang, queryString)
- if val, ok := PCache.Get(cacheKey); val != nil && ok {
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(val)
- }
-
- content, err := tm.Render("general-page", fiber.Map{
- "L": l[lang],
- "Lang": lang,
- "QueryString": queryString,
- })
- if err != nil {
- slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
-
- go PCache.Set(cacheKey, content, int64(len(content)))
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Type("html").Send(content)
- }
-}
diff --git a/internal/router/api-v1-like.go b/internal/router/api-v1-like.go
deleted file mode 100644
index 323c457..0000000
--- a/internal/router/api-v1-like.go
+++ /dev/null
@@ -1,122 +0,0 @@
-package router
-
-import (
- "fmt"
- "log/slog"
- "net/url"
- "strconv"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- assert(0, tm.Add("blog-page-like-button", "views/partials/blog-page-like-button.html"))
-}
-
-func Api_V1_Like_Put(l map[string]*locale.LocaleConfig, b2 *b2.B2Client) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- referer := c.Get("Referer", "")
- if referer == "" {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
- }
- urlStruct, err := url.ParseRequestURI(referer)
- if err != nil {
- return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
- }
-
- path := urlStruct.EscapedPath()
- pathParts := strings.Split(strings.Trim(path, "/"), "/")
- if len(pathParts) != 3 {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/blog/{page}'")
- }
-
- lang, page := pathParts[0], pathParts[2]
-
- pageLink := lang + "/" + page + ".md"
- if pages, _ := b2.Scan(pageLink); len(pages) == 0 {
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server did not find '%s' article", pageLink))
- }
-
- newLikeStatus, err := strconv.ParseBool(c.FormValue("like", "true"))
- if err != nil {
- return c.Status(fiber.ErrBadRequest.Code).SendString("invalid 'like' value")
- }
-
- ip := c.IP()
- if newLikeStatus {
- CCache.LikeOn(ip, page)
- } else {
- CCache.LikeOff(ip, page)
- }
-
- slog.Debug("someone pressed the like button!", slog.String("ip", ip), slog.String("page", page), slog.String("new_like_status", fmt.Sprint(newLikeStatus)))
- if c.Get("HX-Request", "false") == "true" {
- content, err := tm.Render("blog-page-like-button", fiber.Map{
- "L": l[lang],
- "Liked": newLikeStatus,
- "LikedCount": CCache.GetLikeCount(page),
- })
- if err != nil {
- slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Send(content)
- }
-
- return c.Status(fiber.StatusOK).SendString(fmt.Sprint(newLikeStatus))
- }
-}
-
-func Api_V1_Like_Get(l map[string]*locale.LocaleConfig, b2 *b2.B2Client) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- referer := c.Get("Referer", "")
- if referer == "" {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
- }
- urlStruct, err := url.ParseRequestURI(referer)
- if err != nil {
- return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
- }
-
- path := urlStruct.EscapedPath()
- pathParts := strings.Split(strings.Trim(path, "/"), "/")
- if len(pathParts) != 3 {
- return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/blog/{page}'")
- }
-
- lang, page := pathParts[0], pathParts[2]
-
- pageLink := lang + "/" + page + ".md"
- if pages, _ := b2.Scan(pageLink); len(pages) == 0 {
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server did not find '%s' article", pageLink))
- }
-
- ip := c.IP()
- likeStatus := CCache.GetLikeStatus(ip, page)
-
- slog.Debug("someone requested the like status!", slog.String("ip", ip), slog.String("page", page), slog.Bool("like_status", likeStatus))
- if c.Get("HX-Request", "false") == "true" {
- content, err := tm.Render("blog-page-like-button", fiber.Map{
- "L": l[lang],
- "Liked": likeStatus,
- "LikedCount": CCache.GetLikeCount(page),
- })
- if err != nil {
- slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
- }
- c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
- return c.Status(fiber.StatusOK).Send(content)
- }
-
- return c.Status(fiber.StatusOK).SendString(fmt.Sprint(likeStatus))
- }
-}
diff --git a/internal/router/api-v1-subs.go b/internal/router/api-v1-subs.go
deleted file mode 100644
index 72f16e0..0000000
--- a/internal/router/api-v1-subs.go
+++ /dev/null
@@ -1,67 +0,0 @@
-package router
-
-import (
- "net/url"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/internal/mailer"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- assert(0, tm.Add("personal-page-status", "views/partials/personal-page-status.html"))
-}
-
-func Api_V1_Subs_Put(l map[string]*locale.LocaleConfig) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- id := c.IP()
- lang := "en"
-
- var referer, path string
- var pathParts []string
- var urlStruct *url.URL
- var err error
-
- referer = c.Get("Referer", "")
- if referer == "" {
- goto skipFetchingLang
- }
-
- urlStruct, err = url.ParseRequestURI(referer)
- if err != nil {
- goto skipFetchingLang
- }
-
- path = urlStruct.EscapedPath()
- pathParts = strings.Split(strings.Trim(path, "/"), "/")
- if len(pathParts) == 0 {
- goto skipFetchingLang
- }
-
- lang = pathParts[0]
-
- skipFetchingLang:
- subscriptionType := c.FormValue("tags")
- var subscriptionTypeEnum mailer.SubscriptionType
- switch subscriptionType {
- case "all":
- subscriptionTypeEnum = mailer.All
- case "none":
- subscriptionTypeEnum = mailer.None
- case "specific":
- subscriptionTypeEnum = mailer.Specific
- default:
- return api_v1_email_sendStatusHtml(c, "subs-message", l, "Failed", fiber.StatusUnprocessableEntity, lang, l[lang].UserProfile.SubscribeInvalidType, map[string]string{})
- }
-
- specificTags := c.FormValue("tags_picked")
- if err = Mailer.Subscribe(Mailer.GetHash(id), subscriptionTypeEnum, specificTags); err != nil {
- return api_v1_email_sendStatusHtml(c, "subs-message", l, "Failed", fiber.StatusUnprocessableEntity, lang, l[lang].UserProfile.FailedToSubscribe, map[string]string{})
- }
-
- return api_v1_email_sendStatusHtml(c, "subs-message", l, "OK", fiber.StatusOK, lang, l[lang].UserProfile.SubscribedSuccessfully, map[string]string{})
- }
-}
diff --git a/internal/router/api-v1-tz.go b/internal/router/api-v1-tz.go
deleted file mode 100644
index 4e4e462..0000000
--- a/internal/router/api-v1-tz.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package router
-
-import (
- "log/slog"
- "time"
-
- "github.com/gofiber/fiber/v2"
-)
-
-func Api_V1_TZ() func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- timestampString := c.Query("timestamp")
- tz := c.Query("tz")
-
- loc, err := time.LoadLocation(tz)
- if err != nil {
- loc = time.UTC
- slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz))
- }
-
- var format string
- var timestamp time.Time
- for _, format = range []string{"2006-01-02 15:04:05 -07:00", time.RFC3339} {
- if timestamp, err = time.Parse(format, timestampString); err == nil {
- break
- }
- }
-
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
-
- if err != nil {
- return c.Status(fiber.StatusBadRequest).SendString("invalid timestamp format")
- }
-
- return c.Status(fiber.StatusOK).SendString(timestamp.In(loc).Format(format))
- }
-}
diff --git a/internal/router/basic-handler.go b/internal/router/basic-handler.go
new file mode 100644
index 0000000..a859d10
--- /dev/null
+++ b/internal/router/basic-handler.go
@@ -0,0 +1,62 @@
+package router
+
+import (
+ "time"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+type BasicHandler struct{}
+
+var _ Route = &BasicHandler{}
+
+func (r *BasicHandler) Filter() (method string, path string) {
+ panic("handler did not implement Filter method")
+}
+
+func (r *BasicHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *BasicHandler) ToCache() CacheSetting {
+ return Disabled
+}
+
+func (r *BasicHandler) CacheDuration() time.Duration {
+ return 5 * time.Minute
+}
+
+func (r *BasicHandler) ToValidateLang() LangSetting {
+ return NotRequired
+}
+
+func (r *BasicHandler) TemplatesToInject() []string {
+ return []string{}
+}
+
+func (r *BasicHandler) Render(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ if !r.IsTemplated() {
+ panic("handler did not implement Render method (while being non-templated)")
+ }
+ return fiber.StatusNoContent, nil
+}
+
+func (r *BasicHandler) RenderHeader(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ return fiber.StatusNoContent, nil
+}
+
+func (r *BasicHandler) RenderBody(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ return fiber.StatusNoContent, nil
+}
+
+func (r *BasicHandler) RenderFooter(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ return fiber.StatusNoContent, nil
+}
+
+func (r *BasicHandler) RenderTopEmbeds(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ return fiber.StatusNoContent, nil
+}
+
+func (r *BasicHandler) RenderBottomEmbeds(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ return fiber.StatusNoContent, nil
+}
diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go
index e7cda30..7c28b9e 100644
--- a/internal/router/client-cache.go
+++ b/internal/router/client-cache.go
@@ -24,8 +24,6 @@ type ClientCache struct {
db *sql.DB
}
-var CCache *ClientCache
-
func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) {
tx, err := db.Begin()
if err != nil {
diff --git a/internal/router/handlers/api-v1-blog-search.go b/internal/router/handlers/api-v1-blog-search.go
new file mode 100644
index 0000000..a0316e2
--- /dev/null
+++ b/internal/router/handlers/api-v1-blog-search.go
@@ -0,0 +1,126 @@
+package handlers
+
+import (
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/url"
+ "regexp"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type BlogSearchHandler struct {
+ router.BasicHandler
+ getTagsQuery *regexp.Regexp
+}
+
+func init() {
+ router.Routes = append(router.Routes, &BlogSearchHandler{getTagsQuery: regexp.MustCompile(`tags\[\]=([\w]+)`)})
+}
+
+func (r *BlogSearchHandler) Filter() (method string, path string) {
+ return "GET", "/api/v1/blog-search"
+}
+
+func (r *BlogSearchHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *BlogSearchHandler) TemplatesToInject() []string {
+ return []string{"views/partials/catalogue-blog-cards.html", "views/partials/catalogue-blog-card-tags.html"}
+}
+
+func (r *BlogSearchHandler) ToCache() router.CacheSetting {
+ return router.Disabled
+}
+
+func (r *BlogSearchHandler) ToValidateLang() router.LangSetting {
+ return router.InForm
+}
+
+func (r *BlogSearchHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ sort := c.Query("sort")
+ tz := c.Query("tz")
+
+ loc, err := time.LoadLocation(tz)
+ if err != nil {
+ loc = time.UTC
+ slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz))
+ }
+
+ cacheKey := "blog-search." + lang + ".pages-list"
+
+ var pages []*b2.BlogPage
+ if pagesBytes, ok := supplements.PageCache.Get(cacheKey); pagesBytes != nil || ok {
+ json.Unmarshal(pagesBytes, &pages)
+ } else {
+ pages, err = supplements.B2Client.Scan(lang + "/")
+ if err != nil {
+ return fiber.StatusInternalServerError, fmt.Errorf("failed to scan pages for '%s' lang: %w", lang, err)
+ }
+ pagesBytes, _ := json.Marshal(pages)
+ supplements.PageCache.SetWithTTL(cacheKey, pagesBytes, int64(len(pagesBytes)), r.CacheDuration())
+ }
+
+ encodedQuery := c.Request().URI().QueryString()
+ decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
+ matches := r.getTagsQuery.FindAllStringSubmatch(decodedQuery, -1)
+
+ tags := make([]string, 0, len(matches))
+ for _, match := range matches {
+ tags = append(tags, string(match[1]))
+ }
+
+ pageMeta := make([]fiber.Map, 0, len(pages))
+ for _, page := range pages {
+ for _, tag := range page.Metadata.Tags {
+ if len(tags) == 0 || slices.Contains(tags, tag) {
+ pageMeta = append(pageMeta, fiber.Map{
+ "Link": page.Link,
+ "ArticleLink": "/" + lang + "/blog/" + page.FileName,
+ "Title": page.Metadata.Title,
+ "PublishedTime": page.Metadata.PublishedTime.In(loc).Format("2006-01-02 15:04:05 -07:00"),
+ "ActionDate": page.Metadata.ActionDate,
+ "ShortDescription": page.Metadata.ShortDescription,
+ "Thumbnail": page.Metadata.Thumbnail,
+ "Tags": page.Metadata.Tags,
+ "LikeCount": supplements.ClientCache.GetLikeCount(page.FileName),
+ "ViewCount": supplements.ClientCache.GetViewCount(page.FileName),
+ })
+ break
+ }
+ }
+ }
+
+ slices.SortFunc(pageMeta, func(a, b fiber.Map) int {
+ switch sort {
+ case "titleAsc":
+ return strings.Compare(a["Title"].(string), b["Title"].(string))
+ case "titleDesc":
+ return strings.Compare(b["Title"].(string), a["Title"].(string))
+ case "actionDateAsc":
+ return strings.Compare(a["ActionDate"].(string), b["ActionDate"].(string))
+ case "actionDateDesc":
+ return strings.Compare(b["ActionDate"].(string), a["ActionDate"].(string))
+ case "publicationDateAsc":
+ publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string))
+ publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string))
+ return publishedTimeA.Compare(publishedTimeB)
+ case "publicationDateDesc":
+ publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string))
+ publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string))
+ return publishedTimeB.Compare(publishedTimeA)
+ }
+ return 0
+ })
+
+ templateMap["BlogPages"] = pageMeta
+
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/api-v1-email-is-in-verification.go b/internal/router/handlers/api-v1-email-is-in-verification.go
new file mode 100644
index 0000000..4f05f48
--- /dev/null
+++ b/internal/router/handlers/api-v1-email-is-in-verification.go
@@ -0,0 +1,59 @@
+package handlers
+
+import (
+ "fmt"
+ "html/template"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type OngoingVerificationHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &OngoingVerificationHandler{})
+}
+
+func (r *OngoingVerificationHandler) Filter() (method string, path string) {
+ return "GET", "/api/v1/email/is-in-verification"
+}
+
+func (r *OngoingVerificationHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *OngoingVerificationHandler) TemplatesToInject() []string {
+ return []string{"views/partials/personal-page-status.html"}
+}
+
+func (r *OngoingVerificationHandler) ToCache() router.CacheSetting {
+ return router.Disabled
+}
+
+func (r *OngoingVerificationHandler) ToValidateLang() router.LangSetting {
+ return router.InReferer
+}
+
+func (r *OngoingVerificationHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ isAllowed, whenAllowed, codeExpiry := supplements.Mailer.IsAllowedToRetryVerification(c.IP())
+
+ sterileDataset := make(map[string]any)
+ sterileDataset["code-expiry-time"] = template.HTMLAttr(fmt.Sprintf("data-code-expiry-time=\"%d\"", codeExpiry.UnixMilli()))
+
+ templateMap["StatusId"] = "email-message"
+ templateMap["DataAttributes"] = sterileDataset
+
+ if !isAllowed {
+ sterileDataset["striked-end-time"] = template.HTMLAttr(fmt.Sprintf("data-striked-end-time=\"%d\"", whenAllowed.UnixMilli()))
+ templateMap["Status"] = "Neutral"
+ templateMap["Message"] = strings.ReplaceAll(supplements.Localization[lang].UserProfile.DelayTilVerification, "{}", whenAllowed.Format("2006-01-02 15:04:05 MST"))
+ } else {
+ templateMap["Status"] = "OK"
+ templateMap["Message"] = ""
+ }
+
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/api-v1-email-send-verification-code.go b/internal/router/handlers/api-v1-email-send-verification-code.go
new file mode 100644
index 0000000..20c5fb6
--- /dev/null
+++ b/internal/router/handlers/api-v1-email-send-verification-code.go
@@ -0,0 +1,97 @@
+package handlers
+
+import (
+ "fmt"
+ "html/template"
+ "log/slog"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type SendVerificationCodeHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &SendVerificationCodeHandler{})
+}
+
+func (r *SendVerificationCodeHandler) Filter() (method string, path string) {
+ return "POST", "/api/v1/email/send-verification-code"
+}
+
+func (r *SendVerificationCodeHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *SendVerificationCodeHandler) TemplatesToInject() []string {
+ return []string{"views/partials/personal-page-status.html"}
+}
+
+func (r *SendVerificationCodeHandler) ToCache() router.CacheSetting {
+ return router.Disabled
+}
+
+func (r *SendVerificationCodeHandler) ToValidateLang() router.LangSetting {
+ return router.InReferer
+}
+
+func (r *SendVerificationCodeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ id := c.IP()
+ templateMap["StatusId"] = "email-message"
+
+ email := c.FormValue("email")
+ if email == "" {
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.EmailEmpty
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ isTaken, err := supplements.Mailer.MailIsTaken(email)
+ if err != nil {
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationCodeSendingError
+ slog.Error("failed to check if address is already taken", slog.String("error", err.Error()))
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ if isTaken {
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.EmailTaken
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ if isAllowed, whenAllowed, _ := supplements.Mailer.IsAllowedToRetryVerification(id); !isAllowed {
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = strings.ReplaceAll(supplements.Localization[lang].UserProfile.DelayTilVerification, "{}", whenAllowed.Format("2006-01-02 15:04:05 MST"))
+ templateMap["DataAttributes"] = map[string]any{
+ "striked-end-time": template.HTMLAttr(fmt.Sprintf("data-striked-end-time=\"%d\"", whenAllowed.UnixMilli())),
+ }
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ if previousEmail, _, _ := supplements.Mailer.GetInfo(supplements.Mailer.GetHash(id)); previousEmail == email {
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.EmailAlreadyValidated
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ if err = supplements.Mailer.SendVerificationCode(id, email, lang); err != nil {
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationCodeSendingError
+ slog.Error("failed to send a verification code", slog.String("error", err.Error()))
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ _, endTime, codeExpiry := supplements.Mailer.IsAllowedToRetryVerification(id)
+ templateMap["Status"] = "OK"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationCodeSent
+ templateMap["DataAttributes"] = map[string]any{
+ "striked-end-time": template.HTMLAttr(fmt.Sprintf("data-striked-end-time=\"%d\"", endTime.UnixMilli())),
+ "code-expiry-time": template.HTMLAttr(fmt.Sprintf("data-code-expiry-time=\"%d\"", codeExpiry.UnixMilli())),
+ }
+
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/api-v1-email-verify.go b/internal/router/handlers/api-v1-email-verify.go
new file mode 100644
index 0000000..c9ec969
--- /dev/null
+++ b/internal/router/handlers/api-v1-email-verify.go
@@ -0,0 +1,62 @@
+package handlers
+
+import (
+ "html/template"
+ "log/slog"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type VerifyCodeHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &VerifyCodeHandler{})
+}
+
+func (r *VerifyCodeHandler) Filter() (method string, path string) {
+ return "POST", "/api/v1/email/verify"
+}
+
+func (r *VerifyCodeHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *VerifyCodeHandler) TemplatesToInject() []string {
+ return []string{"views/partials/personal-page-status.html"}
+}
+
+func (r *VerifyCodeHandler) ToCache() router.CacheSetting {
+ return router.Disabled
+}
+
+func (r *VerifyCodeHandler) ToValidateLang() router.LangSetting {
+ return router.InReferer
+}
+
+func (r *VerifyCodeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ verificationCode := c.FormValue("email_code")
+ templateMap["StatusId"] = "verification-message"
+
+ if verificationCode == "" {
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationEmpty
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ if err = supplements.Mailer.Verify(verificationCode, lang); err != nil {
+ slog.Warn("verification code is invalid", slog.String("verification_code", verificationCode), slog.String("error", err.Error()))
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationFailed
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ templateMap["Status"] = "OK"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.VerificationSuccess
+ templateMap["DataAttributes"] = map[string]any{
+ "hide-verification-panel": template.HTMLAttr("data-code-expiry-time=\"true\""),
+ }
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/api-v1-like-get.go b/internal/router/handlers/api-v1-like-get.go
new file mode 100644
index 0000000..e9c176e
--- /dev/null
+++ b/internal/router/handlers/api-v1-like-get.go
@@ -0,0 +1,63 @@
+package handlers
+
+import (
+ "fmt"
+ "log/slog"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type GetLikeHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &GetLikeHandler{})
+}
+
+func (r *GetLikeHandler) Filter() (method string, path string) {
+ return "GET", "/api/v1/like"
+}
+
+func (r *GetLikeHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *GetLikeHandler) TemplatesToInject() []string {
+ return []string{"views/partials/blog-page-like-button.html"}
+}
+
+func (r *GetLikeHandler) ToCache() router.CacheSetting {
+ return router.Disabled
+}
+
+func (r *GetLikeHandler) ToValidateLang() router.LangSetting {
+ return router.InReferer
+}
+
+func (r *GetLikeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ path, pathParts, _, err := router.GetPathFromReferer(c)
+ if err != nil {
+ return fiber.StatusBadRequest, fmt.Errorf("error getting path from referer: %w", err)
+ }
+
+ if len(pathParts) != 3 && pathParts[1] != "blog" {
+ return fiber.StatusBadRequest, fmt.Errorf("invalid path format: expected '/:lang/blog/:page', got '%s'", path)
+ }
+
+ page := pathParts[2]
+ pageLink := lang + "/" + page + ".md"
+ if pages, _ := supplements.B2Client.Scan(pageLink); len(pages) == 0 {
+ return fiber.StatusNotFound, fmt.Errorf("server did not find '%s' article", pageLink)
+ }
+
+ ip := c.IP()
+ likeStatus := supplements.ClientCache.GetLikeStatus(ip, page)
+
+ slog.Debug("someone requested the like status!", slog.String("ip", ip), slog.String("page", page), slog.Bool("like_status", likeStatus))
+ templateMap["Liked"] = likeStatus
+ templateMap["LikedCount"] = supplements.ClientCache.GetLikeCount(page)
+
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/api-v1-like-put.go b/internal/router/handlers/api-v1-like-put.go
new file mode 100644
index 0000000..ea4ef4b
--- /dev/null
+++ b/internal/router/handlers/api-v1-like-put.go
@@ -0,0 +1,74 @@
+package handlers
+
+import (
+ "fmt"
+ "log/slog"
+ "strconv"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type PutLikeHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &PutLikeHandler{})
+}
+
+func (r *PutLikeHandler) Filter() (method string, path string) {
+ return "PUT", "/api/v1/like"
+}
+
+func (r *PutLikeHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *PutLikeHandler) TemplatesToInject() []string {
+ return []string{"views/partials/blog-page-like-button.html"}
+}
+
+func (r *PutLikeHandler) ToCache() router.CacheSetting {
+ return router.Disabled
+}
+
+func (r *PutLikeHandler) ToValidateLang() router.LangSetting {
+ return router.InReferer
+}
+
+func (r *PutLikeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ path, pathParts, _, err := router.GetPathFromReferer(c)
+ if err != nil {
+ return fiber.StatusBadRequest, fmt.Errorf("error getting path from referer: %w", err)
+ }
+
+ if len(pathParts) != 3 && pathParts[1] != "blog" {
+ return fiber.StatusBadRequest, fmt.Errorf("invalid path format: expected '/:lang/blog/:page', got '%s'", path)
+ }
+
+ page := pathParts[2]
+ pageLink := lang + "/" + page + ".md"
+ if pages, _ := supplements.B2Client.Scan(pageLink); len(pages) == 0 {
+ return fiber.StatusNotFound, fmt.Errorf("server did not find '%s' article", pageLink)
+ }
+
+ newLikeStatus, err := strconv.ParseBool(c.FormValue("like", "true"))
+ if err != nil {
+ return fiber.StatusBadRequest, fmt.Errorf("invalid like value '%s'", c.FormValue("like"))
+ }
+
+ ip := c.IP()
+ if newLikeStatus {
+ supplements.ClientCache.LikeOn(ip, page)
+ } else {
+ supplements.ClientCache.LikeOff(ip, page)
+ }
+
+ slog.Debug("someone pressed the like button!", slog.String("ip", ip), slog.String("page", page), slog.String("new_like_status", fmt.Sprint(newLikeStatus)))
+ templateMap["Liked"] = newLikeStatus
+ templateMap["LikedCount"] = supplements.ClientCache.GetLikeCount(page)
+ templateMap["StatusId"] = "email-message"
+
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/api-v1-subs-put.go b/internal/router/handlers/api-v1-subs-put.go
new file mode 100644
index 0000000..144abe5
--- /dev/null
+++ b/internal/router/handlers/api-v1-subs-put.go
@@ -0,0 +1,65 @@
+package handlers
+
+import (
+ "github.com/SayaAndy/saya-today-web/internal/mailer"
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type PutSubsHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &PutSubsHandler{})
+}
+
+func (r *PutSubsHandler) Filter() (method string, path string) {
+ return "PUT", "/api/v1/subs"
+}
+
+func (r *PutSubsHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *PutSubsHandler) TemplatesToInject() []string {
+ return []string{"views/partials/personal-page-status.html"}
+}
+
+func (r *PutSubsHandler) ToCache() router.CacheSetting {
+ return router.Disabled
+}
+
+func (r *PutSubsHandler) ToValidateLang() router.LangSetting {
+ return router.InReferer
+}
+
+func (r *PutSubsHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ templateMap["StatusId"] = "subs-message"
+
+ subscriptionType := c.FormValue("tags")
+ var subscriptionTypeEnum mailer.SubscriptionType
+ switch subscriptionType {
+ case "all":
+ subscriptionTypeEnum = mailer.All
+ case "none":
+ subscriptionTypeEnum = mailer.None
+ case "specific":
+ subscriptionTypeEnum = mailer.Specific
+ default:
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.SubscribeInvalidType
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ specificTags := c.FormValue("tags_picked")
+ if err = supplements.Mailer.Subscribe(supplements.Mailer.GetHash(c.IP()), subscriptionTypeEnum, specificTags); err != nil {
+ templateMap["Status"] = "Failed"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.FailedToSubscribe
+ return fiber.StatusUnprocessableEntity, nil
+ }
+
+ templateMap["Status"] = "OK"
+ templateMap["Message"] = supplements.Localization[lang].UserProfile.SubscribedSuccessfully
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/api-v1-tz-get.go b/internal/router/handlers/api-v1-tz-get.go
new file mode 100644
index 0000000..8b08113
--- /dev/null
+++ b/internal/router/handlers/api-v1-tz-get.go
@@ -0,0 +1,65 @@
+package handlers
+
+import (
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type GetTimezoneHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &GetTimezoneHandler{})
+}
+
+func (r *GetTimezoneHandler) Filter() (method string, path string) {
+ return "GET", "/api/v1/tz"
+}
+
+func (r *GetTimezoneHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *GetTimezoneHandler) TemplatesToInject() []string {
+ return []string{}
+}
+
+func (r *GetTimezoneHandler) ToCache() router.CacheSetting {
+ return router.Disabled
+}
+
+func (r *GetTimezoneHandler) ToValidateLang() router.LangSetting {
+ return router.NotRequired
+}
+
+func (r *GetTimezoneHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ timestampString := c.Query("timestamp")
+ tz := c.Query("tz")
+
+ loc, err := time.LoadLocation(tz)
+ if err != nil {
+ loc = time.UTC
+ slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz))
+ }
+
+ var format string
+ var timestamp time.Time
+ for _, format = range []string{"2006-01-02 15:04:05 -07:00", time.RFC3339} {
+ if timestamp, err = time.Parse(format, timestampString); err == nil {
+ break
+ }
+ }
+
+ if err != nil {
+ return fiber.StatusBadRequest, fmt.Errorf("invalid timestamp format for '%s'", timestampString)
+ }
+
+ templateMap["Output"] = []byte(timestamp.In(loc).Format("2006-01-02 15:04:05 -07:00"))
+
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/lang-blog-title.go b/internal/router/handlers/lang-blog-title.go
new file mode 100644
index 0000000..f1be2cd
--- /dev/null
+++ b/internal/router/handlers/lang-blog-title.go
@@ -0,0 +1,113 @@
+package handlers
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/internal/frontmatter"
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+ "github.com/yuin/goldmark"
+)
+
+func init() {
+ router.Routes = append(router.Routes, &BlogPageHandler{})
+}
+
+type BlogPageHandler struct {
+ router.BasicHandler
+}
+
+func (r *BlogPageHandler) Filter() (method string, path string) {
+ return "GET", "/:lang/blog/:title"
+}
+
+func (r *BlogPageHandler) IsTemplated() bool {
+ return true
+}
+
+func (r *BlogPageHandler) TemplatesToInject() []string {
+ return []string{"views/pages/blog-page.html"}
+}
+
+func (r *BlogPageHandler) ToCache() router.CacheSetting {
+ return router.ByUrlOnly
+}
+
+func (r *BlogPageHandler) CacheDuration() time.Duration {
+ return 15 * time.Minute
+}
+
+func (r *BlogPageHandler) ToValidateLang() router.LangSetting {
+ return router.InPath
+}
+
+func (r *BlogPageHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ _, pathParts, _, err := router.GetPathFromReferer(c)
+ if err != nil {
+ return fiber.StatusBadRequest, fmt.Errorf("failed to get path from referer: %w", err)
+ }
+
+ metadata, parsedMarkdown, err := readBlogPost(supplements.MarkdownRenderer, supplements.B2Client, lang+"/"+pathParts[2])
+ if err != nil {
+ return fiber.StatusNotFound, fmt.Errorf("failed to find '%s' post: %w", pathParts[2], err)
+ }
+
+ geolocationParts := strings.Split(metadata.Geolocation, " ")
+ var x, y, areaError string
+ if len(geolocationParts) >= 2 {
+ x = geolocationParts[0]
+ y = geolocationParts[1]
+ }
+ if len(geolocationParts) >= 3 {
+ areaError = geolocationParts[2]
+ }
+
+ templateMap["MapLocationX"] = x
+ templateMap["MapLocationY"] = y
+ templateMap["MapLocationAreaMeters"] = areaError
+ templateMap["Title"] = metadata.Title
+ templateMap["ParsedMarkdown"] = template.HTML(parsedMarkdown)
+ templateMap["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00")
+ templateMap["ActionDate"] = metadata.ActionDate
+
+ go supplements.ClientCache.View(c.IP(), pathParts[2])
+
+ return fiber.StatusOK, nil
+}
+
+func (r *BlogPageHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ path, pathParts, _, err := router.GetPathFromReferer(c)
+ if err != nil {
+ return fiber.StatusBadRequest, fmt.Errorf("failed to get path from referer: %w", err)
+ }
+
+ metadata, _, err := supplements.B2Client.ReadFrontmatter(lang + "/" + pathParts[2] + ".md")
+ if err != nil {
+ return fiber.StatusNotFound, fmt.Errorf("could not read '%s' for metadata: %w", path, err)
+ }
+
+ templateMap["Title"] = metadata.Title
+ templateMap["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00")
+ templateMap["ActionDate"] = metadata.ActionDate
+
+ return fiber.StatusOK, nil
+}
+
+func readBlogPost(md goldmark.Markdown, b2Client *b2.B2Client, sourceName string) (metadata *frontmatter.Metadata, html string, err error) {
+ metadata, markdown, err := b2Client.ReadFrontmatter(sourceName + ".md")
+ if err != nil {
+ return nil, "", fmt.Errorf("failed to read a frontmatter file: %w", err)
+ }
+
+ var buf bytes.Buffer
+ if err := md.Convert(markdown, &buf); err != nil {
+ return nil, "", fmt.Errorf("convert source context from md to html: %w", err)
+ }
+
+ return metadata, buf.String(), nil
+}
diff --git a/internal/router/handlers/lang-blog.go b/internal/router/handlers/lang-blog.go
new file mode 100644
index 0000000..e4c20c6
--- /dev/null
+++ b/internal/router/handlers/lang-blog.go
@@ -0,0 +1,112 @@
+package handlers
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "regexp"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ router.Routes = append(router.Routes, &CatalogueHandler{getTagsQuery: regexp.MustCompile(`tags\[\]=([\w]+)`)})
+}
+
+type CatalogueHandler struct {
+ router.BasicHandler
+ getTagsQuery *regexp.Regexp
+}
+
+func (r *CatalogueHandler) Filter() (method string, path string) {
+ return "GET", "/:lang/blog"
+}
+
+func (r *CatalogueHandler) IsTemplated() bool {
+ return true
+}
+
+func (r *CatalogueHandler) TemplatesToInject() []string {
+ return []string{"views/pages/blog-catalogue.html"}
+}
+
+func (r *CatalogueHandler) ToCache() router.CacheSetting {
+ return router.ByUrlAndQuery
+}
+
+func (r *CatalogueHandler) CacheDuration() time.Duration {
+ return 5 * time.Minute
+}
+
+func (r *CatalogueHandler) ToValidateLang() router.LangSetting {
+ return router.InPath
+}
+
+func (r *CatalogueHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ querySort := c.Query("sort")
+ if querySort == "" {
+ querySort = "publicationDateDesc"
+ }
+
+ encodedQuery := c.Request().URI().QueryString()
+ decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
+ matches := r.getTagsQuery.FindAllStringSubmatch(decodedQuery, -1)
+
+ queryTags := make([]string, 0, len(matches))
+ for _, match := range matches {
+ queryTags = append(queryTags, string(match[1]))
+ }
+
+ tagsArray, err := getTags(supplements.B2Client, lang)
+ if err != nil {
+ return fiber.StatusInternalServerError, fmt.Errorf("failed to get the available tags")
+ }
+
+ templateMap["Tags"] = tagsArray
+ templateMap["QuerySort"] = querySort
+ templateMap["QueryTags"] = strings.Join(queryTags, ",")
+ templateMap["Title"] = supplements.Localization[lang].BlogSearch.Header
+
+ return fiber.StatusOK, nil
+}
+
+func (r *CatalogueHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ templateMap["Title"] = supplements.Localization[lang].BlogSearch.Header
+ return fiber.StatusOK, nil
+}
+
+type Tag struct {
+ Name string `json:"Name" yaml:"name"`
+ Count int `json:"Count" yaml:"count"`
+}
+
+func getTags(b2Client *b2.B2Client, lang string) (tags []Tag, err error) {
+ pages, err := b2Client.Scan(lang + "/")
+ if err != nil {
+ slog.Warn("failed to scan pages via b2", slog.String("error", err.Error()))
+ return nil, fmt.Errorf("failed to scan pages via b2: %w", err)
+ }
+
+ tagsMap := make(map[string]int)
+ for _, page := range pages {
+ for _, tag := range page.Metadata.Tags {
+ tagsMap[tag]++
+ }
+ }
+ slog.Debug("enlist pages for catalogue", slog.Int("tag_count", len(tagsMap)), slog.Int("page_count", len(pages)), slog.String("lang", lang))
+
+ tagsArray := make([]Tag, 0, len(tagsMap))
+ for tag, count := range tagsMap {
+ tagsArray = append(tagsArray, Tag{tag, count})
+ }
+ slices.SortFunc(tagsArray, func(a Tag, b Tag) int {
+ return strings.Compare(a.Name, b.Name)
+ })
+
+ return tagsArray, nil
+}
diff --git a/internal/router/handlers/lang-map.go b/internal/router/handlers/lang-map.go
new file mode 100644
index 0000000..dbf1437
--- /dev/null
+++ b/internal/router/handlers/lang-map.go
@@ -0,0 +1,95 @@
+package handlers
+
+import (
+ "fmt"
+ "log/slog"
+ "strconv"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type MapHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &MapHandler{})
+}
+
+func (r *MapHandler) Filter() (method string, path string) {
+ return "GET", "/:lang/map"
+}
+
+func (r *MapHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *MapHandler) TemplatesToInject() []string {
+ return []string{"views/pages/global-map.html"}
+}
+
+func (r *MapHandler) ToCache() router.CacheSetting {
+ return router.Disabled
+}
+
+func (r *MapHandler) ToValidateLang() router.LangSetting {
+ return router.InPath
+}
+
+func (r *MapHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ pages, err := supplements.B2Client.Scan(lang + "/")
+ status := fiber.StatusOK
+ if err != nil {
+ slog.Error("received an error while scanning b2 pages",
+ slog.String("error", err.Error()),
+ slog.String("lang", lang),
+ )
+ status = fiber.StatusPartialContent
+ pages = []*b2.BlogPage{}
+ }
+
+ type MapMarker struct {
+ Title string `json:"Title"`
+ PageLink string `json:"PageLink"`
+ Lat float64 `json:"Lat"`
+ Long float64 `json:"Long"`
+ AccuracyMeters int64 `json:"AccuracyMeters"`
+ Thumbnail string `json:"Thumbnail"`
+ }
+
+ mapMarkers := make([]*MapMarker, 0, len(pages))
+ for _, page := range pages {
+ geolocationParts := strings.Split(page.Metadata.Geolocation, " ")
+ if len(geolocationParts) < 2 {
+ continue
+ }
+
+ var x, y float64
+ var areaError int64
+ if len(geolocationParts) >= 2 {
+ x, _ = strconv.ParseFloat(geolocationParts[0], 64)
+ y, _ = strconv.ParseFloat(geolocationParts[1], 64)
+ }
+ if len(geolocationParts) >= 3 {
+ areaError, _ = strconv.ParseInt(geolocationParts[2], 10, 64)
+ }
+
+ mapMarkers = append(mapMarkers, &MapMarker{
+ Title: page.Metadata.Title,
+ PageLink: fmt.Sprintf("/%s/blog/%s", lang, page.FileName),
+ Lat: x,
+ Long: y,
+ AccuracyMeters: areaError,
+ Thumbnail: page.Metadata.Thumbnail,
+ })
+ }
+
+ templateMap["MapMarkers"] = mapMarkers
+ templateMap["MapLocationLat"] = 45.4507
+ templateMap["MapLocationLong"] = 68.8319
+
+ return status, nil
+}
diff --git a/internal/router/handlers/lang-user-unsubscribe.go b/internal/router/handlers/lang-user-unsubscribe.go
new file mode 100644
index 0000000..4fbd244
--- /dev/null
+++ b/internal/router/handlers/lang-user-unsubscribe.go
@@ -0,0 +1,68 @@
+package handlers
+
+import (
+ "log/slog"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+type UnsubscribeHandler struct {
+ router.BasicHandler
+}
+
+func init() {
+ router.Routes = append(router.Routes, &UnsubscribeHandler{})
+}
+
+func (r *UnsubscribeHandler) Filter() (method string, path string) {
+ return "GET", "/:lang/user/unsubscribe"
+}
+
+func (r *UnsubscribeHandler) IsTemplated() bool {
+ return false
+}
+
+func (r *UnsubscribeHandler) TemplatesToInject() []string {
+ return []string{"views/pages/unsubscribe-page.html"}
+}
+
+func (r *UnsubscribeHandler) ToValidateLang() router.LangSetting {
+ return router.InPath
+}
+
+func (r *UnsubscribeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ var statusEmoji, statusText, statusColor string
+ var status int
+
+ unsubscribeCode := c.FormValue("code")
+ if unsubscribeCode == "" {
+ statusColor = "0, 0, 255"
+ statusEmoji = "(╭ರ_•́)"
+ statusText = supplements.Localization[lang].UnsubscribePage.UnsetCode
+ status = fiber.ErrBadRequest.Code
+ } else if clientError, serverError := supplements.Mailer.Unsubscribe(unsubscribeCode); clientError != nil {
+ slog.Info("got a client error when unsubscribing", slog.String("error", clientError.Error()))
+ statusColor = "255, 0, 0"
+ statusEmoji = "(͠≖~≖ ͡ )"
+ statusText = supplements.Localization[lang].UnsubscribePage.InvalidCode
+ status = fiber.ErrBadRequest.Code
+ } else if serverError != nil {
+ slog.Error("got a server error when unsubscribing", slog.String("error", serverError.Error()))
+ statusColor = "255, 128, 0"
+ statusEmoji = "( ˶°ㅁ°) !!"
+ statusText = supplements.Localization[lang].UnsubscribePage.OnServerError
+ status = fiber.ErrInternalServerError.Code
+ } else {
+ statusColor = "0, 255, 0"
+ statusEmoji = "♡⸜(˶˃ ᵕ ˂˶)⸝♡"
+ statusText = supplements.Localization[lang].UnsubscribePage.Success
+ status = fiber.StatusOK
+ }
+
+ templateMap["StatusColor"] = statusColor
+ templateMap["StatusEmoji"] = statusEmoji
+ templateMap["StatusText"] = statusText
+
+ return status, nil
+}
diff --git a/internal/router/handlers/lang-user.go b/internal/router/handlers/lang-user.go
new file mode 100644
index 0000000..db566fe
--- /dev/null
+++ b/internal/router/handlers/lang-user.go
@@ -0,0 +1,78 @@
+package handlers
+
+import (
+ "fmt"
+ "log/slog"
+
+ "github.com/SayaAndy/saya-today-web/internal/mailer"
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ router.Routes = append(router.Routes, &UserHandler{})
+}
+
+type UserHandler struct {
+ router.BasicHandler
+}
+
+func (r *UserHandler) Filter() (method string, path string) {
+ return "GET", "/:lang/user"
+}
+
+func (r *UserHandler) IsTemplated() bool {
+ return true
+}
+
+func (r *UserHandler) TemplatesToInject() []string {
+ return []string{"views/pages/user-page.html"}
+}
+
+func (r *UserHandler) ToCache() router.CacheSetting {
+ return router.Disabled
+}
+
+func (r *UserHandler) ToValidateLang() router.LangSetting {
+ return router.InPath
+}
+
+func (r *UserHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ templateMap["Title"] = supplements.Localization[lang].UserProfile.Header
+
+ email, _, err := supplements.Mailer.GetInfo(supplements.Mailer.GetHash(c.IP()))
+ if err != nil {
+ slog.Error("get info from mailer about a client", slog.String("error", err.Error()))
+ }
+
+ tagsArray, err := getTags(supplements.B2Client, lang)
+ if err != nil {
+ return fiber.ErrInternalServerError.Code, fmt.Errorf("failed to get the available tags")
+ }
+
+ subscriptionType, tags, err := supplements.Mailer.GetSubscriptions(c.IP())
+ if err != nil {
+ return fiber.ErrInternalServerError.Code, fmt.Errorf("failed to get the user subscriptions")
+ }
+
+ switch subscriptionType {
+ case mailer.None:
+ templateMap["TagsPicked"] = "none"
+ case mailer.All:
+ templateMap["TagsPicked"] = "all"
+ case mailer.Specific:
+ templateMap["TagsPicked"] = "specific"
+ }
+
+ templateMap["TagsPickedList"] = tags
+
+ templateMap["Email"] = email
+ templateMap["EmailCode"] = c.Query("email_code")
+ templateMap["ExistingTags"] = tagsArray
+ return fiber.StatusOK, nil
+}
+
+func (r *UserHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ templateMap["Title"] = supplements.Localization[lang].UserProfile.Header
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/lang.go b/internal/router/handlers/lang.go
new file mode 100644
index 0000000..fc4deb6
--- /dev/null
+++ b/internal/router/handlers/lang.go
@@ -0,0 +1,56 @@
+package handlers
+
+import (
+ "fmt"
+ "math/rand/v2"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ router.Routes = append(router.Routes, &HomeHandler{})
+}
+
+type HomeHandler struct {
+ router.BasicHandler
+}
+
+func (r *HomeHandler) Filter() (method string, path string) {
+ return "GET", "/:lang<len(2)>"
+}
+
+func (r *HomeHandler) IsTemplated() bool {
+ return true
+}
+
+func (r *HomeHandler) TemplatesToInject() []string {
+ return []string{"views/pages/home-page.html"}
+}
+
+func (r *HomeHandler) ToCache() router.CacheSetting {
+ return router.ByUrlOnly
+}
+
+func (r *HomeHandler) CacheDuration() time.Duration {
+ return 10 * time.Minute
+}
+
+func (r *HomeHandler) ToValidateLang() router.LangSetting {
+ return router.InPath
+}
+
+func (r *HomeHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ templateMap["Title"] = supplements.Localization[lang].HomePage.Header
+ templateMap["FilledHeartCount"] = uint(40)
+ templateMap["OutlineHeartCount"] = uint(40)
+ templateMap["GifName"] = fmt.Sprintf("otter-%d.gif", rand.Int()%3+1)
+ templateMap["FunFacts"] = supplements.FactGiver.Give(lang)
+ return fiber.StatusOK, nil
+}
+
+func (r *HomeHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) {
+ templateMap["Title"] = supplements.Localization[lang].HomePage.Header
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/handlers/root.go b/internal/router/handlers/root.go
new file mode 100644
index 0000000..c8c4cd4
--- /dev/null
+++ b/internal/router/handlers/root.go
@@ -0,0 +1,47 @@
+package handlers
+
+import (
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/router"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ router.Routes = append(router.Routes, &RootHandler{})
+}
+
+type RootHandler struct {
+ router.BasicHandler
+}
+
+func (r *RootHandler) Filter() (method string, path string) {
+ return "GET", "/"
+}
+
+func (r *RootHandler) IsTemplated() bool {
+ return true
+}
+
+func (r *RootHandler) TemplatesToInject() []string {
+ return []string{"views/pages/language-pick.html"}
+}
+
+func (r *RootHandler) ToCache() router.CacheSetting {
+ return router.ByUrlOnly
+}
+
+func (r *RootHandler) CacheDuration() time.Duration {
+ return 24 * time.Hour
+}
+
+func (r *RootHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, _ string, templateMap fiber.Map) (statusCode int, err error) {
+ templateMap["Title"] = "Choose Your Language"
+ templateMap["AvailableLanguages"] = supplements.AvailableLanguages
+ return fiber.StatusOK, nil
+}
+
+func (r *RootHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, _ string, templateMap fiber.Map) (statusCode int, err error) {
+ templateMap["Title"] = "Choose Your Language"
+ return fiber.StatusOK, nil
+}
diff --git a/internal/router/lang-map.go b/internal/router/lang-map.go
deleted file mode 100644
index 619b05b..0000000
--- a/internal/router/lang-map.go
+++ /dev/null
@@ -1,88 +0,0 @@
-package router
-
-import (
- "fmt"
- "log/slog"
- "strconv"
- "strings"
-
- "github.com/SayaAndy/saya-today-web/config"
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- assert(0, tm.Add("global-map", "views/pages/global-map.html"))
-}
-
-func Lang_Map(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- lang := c.Params("lang")
- for _, availableLang := range langs {
- if availableLang.Name == lang {
- goto langIsAvailable
- }
- }
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang))
-
- langIsAvailable:
- pages, err := b2Client.Scan(lang + "/")
- status := fiber.StatusOK
- if err != nil {
- status = fiber.StatusPartialContent
- pages = []*b2.BlogPage{}
- }
-
- type MapMarker struct {
- Title string `json:"Title"`
- PageLink string `json:"PageLink"`
- Lat float64 `json:"Lat"`
- Long float64 `json:"Long"`
- AccuracyMeters int64 `json:"AccuracyMeters"`
- Thumbnail string `json:"Thumbnail"`
- }
-
- mapMarkers := make([]*MapMarker, 0, len(pages))
- for _, page := range pages {
- geolocationParts := strings.Split(page.Metadata.Geolocation, " ")
- if len(geolocationParts) < 2 {
- continue
- }
-
- var x, y float64
- var areaError int64
- if len(geolocationParts) >= 2 {
- x, _ = strconv.ParseFloat(geolocationParts[0], 64)
- y, _ = strconv.ParseFloat(geolocationParts[1], 64)
- }
- if len(geolocationParts) >= 3 {
- areaError, _ = strconv.ParseInt(geolocationParts[2], 10, 64)
- }
-
- mapMarkers = append(mapMarkers, &MapMarker{
- Title: page.Metadata.Title,
- PageLink: fmt.Sprintf("/%s/blog/%s", lang, page.FileName),
- Lat: x,
- Long: y,
- AccuracyMeters: areaError,
- Thumbnail: page.Metadata.Thumbnail,
- })
- }
-
- content, err := tm.Render("global-map", fiber.Map{
- "Lang": lang,
- "L": l[lang],
- "MapLocationLat": 45.4507,
- "MapLocationLong": 68.8319,
- "MapMarkers": mapMarkers,
- })
- if err != nil {
- slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/map"), slog.String("error", err.Error()))
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page")
- }
-
- return c.Type("html").Status(status).Send(content)
- }
-}
diff --git a/internal/router/lang-user-unsubscribe.go b/internal/router/lang-user-unsubscribe.go
deleted file mode 100644
index c39f5e7..0000000
--- a/internal/router/lang-user-unsubscribe.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package router
-
-import (
- "fmt"
- "html/template"
- "log/slog"
-
- "github.com/SayaAndy/saya-today-web/config"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/gofiber/fiber/v2"
-)
-
-func init() {
- assert(0, tm.Add("unsubscribe-page", "views/pages/unsubscribe-page.html"))
-}
-
-func Lang_User_Unsubscribe(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) func(c *fiber.Ctx) error {
- return func(c *fiber.Ctx) error {
- lang := c.Params("lang")
- for _, availableLang := range langs {
- if availableLang.Name == lang {
- goto langIsAvailable
- }
- }
- return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang))
-
- langIsAvailable:
- var statusEmoji, statusText, statusColor string
- var status int
-
- unsubscribeCode := c.FormValue("code")
- if unsubscribeCode == "" {
- statusColor = "0, 0, 255"
- statusEmoji = "(╭ರ_•́)"
- statusText = l[lang].UnsubscribePage.UnsetCode
- status = fiber.ErrBadRequest.Code
- } else if clientError, serverError := Mailer.Unsubscribe(unsubscribeCode); clientError != nil {
- slog.Info("got a client error when unsubscribing", slog.String("error", clientError.Error()))
- statusColor = "255, 0, 0"
- statusEmoji = "(͠≖~≖ ͡ )"
- statusText = l[lang].UnsubscribePage.InvalidCode
- status = fiber.ErrBadRequest.Code
- } else if serverError != nil {
- slog.Error("got a server error when unsubscribing", slog.String("error", serverError.Error()))
- statusColor = "255, 128, 0"
- statusEmoji = "( ˶°ㅁ°) !!"
- statusText = l[lang].UnsubscribePage.OnServerError
- status = fiber.ErrInternalServerError.Code
- } else {
- statusColor = "0, 255, 0"
- statusEmoji = "♡⸜(˶˃ ᵕ ˂˶)⸝♡"
- statusText = l[lang].UnsubscribePage.Success
- status = fiber.StatusOK
- }
-
- content, err := tm.Render("unsubscribe-page", fiber.Map{
- "Lang": lang,
- "L": l[lang],
- "StatusEmoji": statusEmoji,
- "StatusText": statusText,
- "StatusColor": template.HTML(statusColor),
- })
- if err != nil {
- slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/user/unsubscribe"), slog.String("error", err.Error()))
- c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
- return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page")
- }
-
- return c.Type("html").Status(status).Send(content)
- }
-}
diff --git a/internal/router/page-cache.go b/internal/router/page-cache.go
deleted file mode 100644
index 01e4792..0000000
--- a/internal/router/page-cache.go
+++ /dev/null
@@ -1,5 +0,0 @@
-package router
-
-import "github.com/dgraph-io/ristretto/v2"
-
-var PCache *ristretto.Cache[string, []byte]
diff --git a/internal/router/path-matcher.go b/internal/router/path-matcher.go
new file mode 100644
index 0000000..c8aef08
--- /dev/null
+++ b/internal/router/path-matcher.go
@@ -0,0 +1,48 @@
+package router
+
+import (
+ "github.com/gofiber/fiber/v2"
+ "github.com/valyala/fasthttp"
+)
+
+type PathMatcher struct {
+ app *fiber.App
+}
+
+func NewPathMatcher() *PathMatcher {
+ app := fiber.New(fiber.Config{
+ DisableStartupMessage: true,
+ })
+ return &PathMatcher{app: app}
+}
+
+func (pm *PathMatcher) AddRoute(method, pattern string) {
+ pm.app.Add(method, pattern, func(c *fiber.Ctx) error {
+ c.Locals("pattern", pattern)
+ return nil
+ })
+}
+
+func (pm *PathMatcher) MatchPath(method, path string) (pattern string, params map[string]string, matched bool) {
+ fctx := &fasthttp.RequestCtx{}
+ fctx.Request.Header.SetMethod(method)
+ fctx.Request.SetRequestURI(path)
+
+ ctx := pm.app.AcquireCtx(fctx)
+ defer pm.app.ReleaseCtx(ctx)
+
+ pm.app.Handler()(fctx)
+
+ if ctx.Route() == nil {
+ return "", nil, false
+ }
+
+ pattern = ctx.Locals("pattern").(string)
+
+ params = make(map[string]string)
+ for _, paramName := range ctx.Route().Params {
+ params[paramName] = ctx.Params(paramName)
+ }
+
+ return pattern, params, true
+}
diff --git a/internal/router/router.go b/internal/router/router.go
new file mode 100644
index 0000000..700fbbf
--- /dev/null
+++ b/internal/router/router.go
@@ -0,0 +1,555 @@
+package router
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/url"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/internal/blogtrigger"
+ "github.com/SayaAndy/saya-today-web/internal/factgiver"
+ "github.com/SayaAndy/saya-today-web/internal/glightbox"
+ "github.com/SayaAndy/saya-today-web/internal/mailer"
+ "github.com/SayaAndy/saya-today-web/internal/tailwind"
+ "github.com/SayaAndy/saya-today-web/internal/templatemanager"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/dgraph-io/ristretto/v2"
+ "github.com/gofiber/fiber/v2"
+ "github.com/gofiber/fiber/v2/middleware/cors"
+ "github.com/golang-migrate/migrate/v4"
+ "github.com/golang-migrate/migrate/v4/database/sqlite3"
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/renderer"
+ "github.com/yuin/goldmark/renderer/html"
+ "github.com/yuin/goldmark/util"
+)
+
+type CacheSetting int
+
+const (
+ Disabled CacheSetting = iota
+ ByUrlOnly
+ ByUrlAndQuery
+)
+
+type LangSetting int
+
+const (
+ NotRequired LangSetting = iota
+ InPath
+ InForm
+ InReferer
+)
+
+var (
+ Routes = make([]Route, 0)
+)
+
+type Route interface {
+ Filter() (method string, path string)
+ IsTemplated() bool
+ ToCache() CacheSetting
+ CacheDuration() time.Duration
+ ToValidateLang() LangSetting
+ TemplatesToInject() []string
+ Render(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error)
+ RenderHeader(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error)
+ RenderBody(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error)
+ RenderFooter(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error)
+ RenderTopEmbeds(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error)
+ RenderBottomEmbeds(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error)
+}
+
+type Supplements struct {
+ DB *sql.DB
+ B2Client *b2.B2Client
+ Localization map[string]*locale.LocaleConfig
+ AvailableLanguages []config.AvailableLanguageConfig
+ ClientCache *ClientCache
+ PageCache *ristretto.Cache[string, []byte]
+ FactGiver *factgiver.FactGiver
+ Mailer *mailer.Mailer
+ BlogTrigger *blogtrigger.BlogTriggerScheduler
+ TemplateManager *templatemanager.TemplateManager
+ MarkdownRenderer goldmark.Markdown
+}
+
+type Router struct {
+ supplements *Supplements
+ app *fiber.App
+ templatedRoutes map[string]map[string]Route
+ templatedPathMatcher *PathMatcher
+}
+
+func NewRouter(cfg *config.Config) (*Router, error) {
+ supplements := &Supplements{
+ AvailableLanguages: cfg.AvailableLanguages,
+ }
+
+ var err error
+
+ supplements.DB, err = sql.Open(cfg.Auth.Db.Type, cfg.Auth.Db.Cfg.DSN)
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize db: %w", err)
+ }
+
+ driver, err := sqlite3.WithInstance(supplements.DB, &sqlite3.Config{})
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize driver for migrating db: %w", err)
+ }
+
+ m, err := migrate.NewWithDatabaseInstance(
+ "file://migrations",
+ cfg.Auth.Db.Type, driver)
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize migration client: %w", err)
+ }
+
+ if err = m.Up(); err != nil && err == errors.New("no change") {
+ return nil, fmt.Errorf("fail to apply migrations: %w", err)
+ }
+ slog.Debug("successfully applied migrations")
+
+ supplements.B2Client, err = b2.NewB2Client(&cfg.BlogPages.Storage.Config)
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize b2 client: %w", err)
+ }
+
+ supplements.Localization = make(map[string]*locale.LocaleConfig, len(cfg.AvailableLanguages))
+ for _, lang := range cfg.AvailableLanguages {
+ localeCfg, err := locale.InitConfig(cfg.LocalePath + lang.LocFile)
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize a locale: %w", err)
+ }
+ supplements.Localization[lang.Name] = localeCfg
+ }
+
+ supplements.MarkdownRenderer = goldmark.New(
+ goldmark.WithExtensions(
+ glightbox.NewGLightboxExtension(),
+ tailwind.NewTailwindExtension(),
+ ),
+ goldmark.WithParserOptions(
+ parser.WithAutoHeadingID(),
+ parser.WithAttribute(),
+ ),
+ goldmark.WithRenderer(
+ renderer.NewRenderer(
+ renderer.WithNodeRenderers(
+ util.Prioritized(tailwind.NewCustomLinkRenderer(html.WithUnsafe(), html.WithXHTML()), 50),
+ util.Prioritized(html.NewRenderer(html.WithXHTML()), 100),
+ ),
+ ),
+ ),
+ )
+
+ supplements.ClientCache, err = NewClientCache(supplements.DB, []byte(cfg.Auth.Salt))
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize client cache: %w", err)
+ }
+
+ supplements.PageCache, err = ristretto.NewCache(&ristretto.Config[string, []byte]{
+ NumCounters: 1e6, // 1,000,000
+ MaxCost: 1 << 29, // 512 MB
+ BufferItems: 64, // number of keys per Get buffer.
+ })
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize page cache: %w", err)
+ }
+
+ supplements.FactGiver, err = factgiver.NewFactGiver(&cfg.FactGiver, supplements.AvailableLanguages)
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize fact giver: %w", err)
+ }
+
+ supplements.Mailer, err = mailer.NewMailer(supplements.DB, cfg.Mail.ClientHost, cfg.Mail.MailHost,
+ cfg.Mail.PublicName, cfg.Mail.MailAddress, cfg.Mail.Username, cfg.Mail.Password, []byte(cfg.Mail.Salt), supplements.Localization)
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize mailer: %w", err)
+ }
+
+ supplements.BlogTrigger, err = blogtrigger.NewBlogTriggerScheduler(supplements.B2Client, cfg.AvailableLanguages, cfg.Mail.Trigger.OnNewPost,
+ func(bp []*b2.BlogPage) error {
+ for _, post := range bp {
+ if err := supplements.Mailer.NewPost(post); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize blog trigger: %w", err)
+ }
+
+ supplements.TemplateManager, err = templatemanager.NewTemplateManager()
+ if err != nil {
+ return nil, fmt.Errorf("fail to initialize template manager: %w", err)
+ }
+
+ enablePrintRoutes := false
+ if cfg.LogLevel <= slog.LevelDebug {
+ enablePrintRoutes = true
+ }
+
+ app := fiber.New(fiber.Config{
+ EnablePrintRoutes: enablePrintRoutes,
+ ProxyHeader: "X-Forwarded-For",
+ })
+
+ app.Use(cors.New(cors.Config{
+ AllowOrigins: "https://f003.backblazeb2.com",
+ AllowMethods: "GET,POST,OPTIONS",
+ AllowHeaders: "Origin, Content-Type, Accept",
+ }))
+
+ templatedRoutes := make(map[string]map[string]Route)
+ templatedPathMatcher := NewPathMatcher()
+
+ return &Router{supplements, app, templatedRoutes, templatedPathMatcher}, nil
+}
+
+func (r *Router) InitRoutes() (err error) {
+ r.supplements.TemplateManager.Add("general-page", "views/layouts/general-page.html")
+ for _, route := range Routes {
+ method, match := route.Filter()
+
+ if err = r.supplements.TemplateManager.Add(method+" "+match, route.TemplatesToInject()...); err != nil {
+ return fmt.Errorf("failed to add '%s %s' route into template manager: %w", method, match, err)
+ }
+
+ if route.IsTemplated() {
+ if _, ok := r.templatedRoutes[method]; !ok {
+ r.templatedRoutes[method] = make(map[string]Route)
+ }
+ r.templatedRoutes[method][match] = route
+ r.templatedPathMatcher.AddRoute(method, match)
+
+ currentRoute := route
+ r.app.Add(method, match, func(c *fiber.Ctx) error {
+ lang, err := r.getAndValidateLang(c, currentRoute.ToValidateLang())
+ if err != nil {
+ return err
+ }
+ err = r.generalPage(c, currentRoute, lang)
+ return err
+ })
+ } else {
+ currentRoute := route
+ r.app.Add(method, match, func(c *fiber.Ctx) error {
+ lang, err := r.getAndValidateLang(c, currentRoute.ToValidateLang())
+ if err != nil {
+ return err
+ }
+
+ var cacheKey string
+ trimmedPath := strings.Trim(c.Path(), "/")
+ queryString := c.Request().URI().QueryString()
+
+ switch currentRoute.ToCache() {
+ case ByUrlOnly:
+ cacheKey = fmt.Sprintf("%s.full-page.%s", method, trimmedPath)
+ case ByUrlAndQuery:
+ cacheKey = fmt.Sprintf("%s.full-page.%s.%s", method, trimmedPath, queryString)
+ }
+
+ defaultMap := fiber.Map{
+ "L": r.supplements.Localization[lang],
+ "Lang": lang,
+ "Path": trimmedPath,
+ "QueryString": queryString,
+ }
+
+ statusCode, err := currentRoute.Render(c, r.supplements, lang, defaultMap)
+ method := c.Method()
+ _, match := currentRoute.Filter()
+ if err != nil {
+ slog.Error("failed to finish rendering a page",
+ slog.Int("status_code", statusCode),
+ slog.String("method", method),
+ slog.String("path", c.Path()),
+ slog.String("match", match),
+ slog.String("query", string(queryString)),
+ slog.String("error", err.Error()),
+ )
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(statusCode).SendString(err.Error())
+ }
+
+ content, err := r.supplements.TemplateManager.Render(method+" "+match, defaultMap)
+ if err != nil {
+ slog.Error("failed to generate div",
+ slog.String("method", method),
+ slog.String("path", c.Path()),
+ slog.String("match", match),
+ slog.String("query", string(queryString)),
+ slog.String("error", err.Error()),
+ )
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+ if val, ok := defaultMap["Output"]; ok && len(content) == 0 {
+ content = val.([]byte)
+ }
+
+ if statusCode >= 200 && statusCode < 300 && route.ToCache() != Disabled {
+ go r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration())
+ }
+
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(statusCode).Type("html").Send(content)
+ })
+ }
+ }
+
+ segments := []string{"header", "body", "footer", "top-embeds", "bottom-embeds"}
+
+ r.app.Get("/api/v1/general-page/:part", func(c *fiber.Ctx) error {
+ part := c.Params("part")
+ if !slices.Contains(segments, part) {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("unknown segment '%s'", part))
+ }
+ err := r.generalPageSegment(c, part)
+ return err
+ })
+
+ for _, segment := range segments {
+ r.supplements.TemplateManager.Add("general-page-"+segment, "views/partials/general-page-"+segment+".html")
+ }
+
+ r.app.Static("/", "./static")
+
+ Routes = make([]Route, 0)
+
+ return nil
+}
+
+func (r *Router) Listen(endpoint string) error {
+ if err := r.app.Listen(endpoint); err != nil {
+ return fmt.Errorf("error while running fiber server: %w", err)
+ }
+ return nil
+}
+
+func (r *Router) Close() (err error) {
+ allErrors := make([]error, 0)
+ if err = r.supplements.BlogTrigger.Close(); err != nil {
+ allErrors = append(allErrors, fmt.Errorf("fail to shutdown blog trigger scheduler: %w", err))
+ }
+ if err = r.app.Shutdown(); err != nil {
+ allErrors = append(allErrors, fmt.Errorf("fail to shutdown fiber server: %w", err))
+ }
+ if err = r.supplements.ClientCache.Close(); err != nil {
+ allErrors = append(allErrors, fmt.Errorf("fail to dump cache: %w", err))
+ }
+ if err = r.supplements.DB.Close(); err != nil {
+ allErrors = append(allErrors, fmt.Errorf("fail to close db connection: %w", err))
+ }
+ r.supplements.PageCache.Close()
+ return errors.Join(allErrors...)
+}
+
+func (r *Router) generalPage(c *fiber.Ctx, route Route, lang string) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ path := c.Path()
+ method := c.Method()
+ trimmedPath := strings.Trim(path, "/")
+ cacheKey := ""
+ queryString := c.Request().URI().QueryString()
+
+ switch route.ToCache() {
+ case ByUrlOnly:
+ cacheKey = fmt.Sprintf("%s.general-page.%s", method, trimmedPath)
+ case ByUrlAndQuery:
+ cacheKey = fmt.Sprintf("%s.general-page.%s.%s", method, trimmedPath, queryString)
+ }
+
+ if val, ok := r.supplements.PageCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ content, err := r.supplements.TemplateManager.Render("general-page", fiber.Map{
+ "L": r.supplements.Localization[lang],
+ "Lang": lang,
+ "QueryString": queryString,
+ })
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ go r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration())
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+}
+
+func (r *Router) generalPageSegment(c *fiber.Ctx, part string) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ path, pathParts, queryString, err := GetPathFromReferer(c)
+ if err != nil {
+ return err
+ }
+
+ method := c.Method()
+ pattern, _, matched := r.templatedPathMatcher.MatchPath(method, path)
+ if !matched {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("fail to get templated page for '%s %s': not found", method, path))
+ }
+ route := r.templatedRoutes[method][pattern]
+
+ lang := ""
+
+ switch route.ToValidateLang() {
+ case InPath, InReferer:
+ if len(pathParts) > 0 {
+ lang = pathParts[0]
+ }
+ if _, err := r.getAndValidateLang(c, NotRequired, lang); err != nil {
+ return err
+ }
+ case InForm:
+ if lang, err = r.getAndValidateLang(c, InForm); err != nil {
+ return err
+ }
+ }
+
+ cacheKey := ""
+ trimmedPath := strings.Trim(path, "/")
+ switch route.ToCache() {
+ case ByUrlOnly:
+ cacheKey = fmt.Sprintf("%s.%s.%s", method, part, trimmedPath)
+ case ByUrlAndQuery:
+ cacheKey = fmt.Sprintf("%s.%s.%s.%s", method, part, trimmedPath, queryString)
+ }
+
+ if route.ToCache() != Disabled {
+ if val, ok := r.supplements.PageCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+ }
+
+ var statusCode int
+ defaultMap := fiber.Map{
+ "L": r.supplements.Localization[lang],
+ "Lang": lang,
+ "Path": strings.Trim(path, "/"),
+ "QueryString": queryString,
+ }
+
+ switch part {
+ case "body":
+ statusCode, err = route.RenderBody(c, r.supplements, lang, defaultMap)
+ case "header":
+ statusCode, err = route.RenderHeader(c, r.supplements, lang, defaultMap)
+ case "footer":
+ statusCode, err = route.RenderFooter(c, r.supplements, lang, defaultMap)
+ case "top-embeds":
+ statusCode, err = route.RenderTopEmbeds(c, r.supplements, lang, defaultMap)
+ case "bottom-embeds":
+ statusCode, err = route.RenderBottomEmbeds(c, r.supplements, lang, defaultMap)
+ }
+ if err != nil {
+ slog.Error(err.Error(),
+ slog.String("method", method),
+ slog.String("path", path),
+ slog.String("query", string(queryString)),
+ slog.String("segment", part),
+ slog.String("error", err.Error()),
+ )
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(statusCode).SendString(err.Error())
+ }
+
+ content, err := r.supplements.TemplateManager.Render("general-page-"+part, defaultMap, route.TemplatesToInject()...)
+ if err != nil {
+ slog.Error("failed to generate div",
+ slog.String("method", method),
+ slog.String("path", path),
+ slog.String("query", string(queryString)),
+ slog.String("segment", part),
+ slog.String("error", err.Error()),
+ )
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ if statusCode >= 200 && statusCode < 300 && route.ToCache() != Disabled {
+ go r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration())
+ }
+
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(statusCode).Type("html").Send(content)
+}
+
+func (r *Router) getAndValidateLang(c *fiber.Ctx, langSetting LangSetting, defaultLang ...string) (string, error) {
+ var lang string
+ if len(defaultLang) > 0 {
+ lang = defaultLang[0]
+ }
+
+ switch langSetting {
+ case NotRequired:
+ return lang, nil
+
+ case InPath:
+ path := c.Path()
+
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 1 && pathParts[0] == "" {
+ pathParts = []string{}
+ }
+ if len(pathParts) > 0 && len(pathParts[0]) == 2 {
+ lang = pathParts[0]
+ }
+
+ case InForm:
+ lang = c.FormValue("lang")
+
+ case InReferer:
+ _, pathParts, _, err := GetPathFromReferer(c)
+ if err != nil {
+ return "", err
+ }
+
+ if len(pathParts) >= 1 {
+ lang = pathParts[0]
+ }
+ }
+
+ for _, availableLang := range r.supplements.AvailableLanguages {
+ if availableLang.Name == lang {
+ return lang, nil
+ }
+ }
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return "", c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("lang value is invalid: '%s' is not considered an available language", lang))
+}
+
+func GetPathFromReferer(c *fiber.Ctx) (path string, pathParts []string, queryString string, err error) {
+ referer := c.Get("Referer")
+ if referer == "" {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return "", nil, "", c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
+ }
+ urlStruct, err := url.ParseRequestURI(referer)
+ if err != nil {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return "", nil, "", c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
+ }
+
+ path = urlStruct.EscapedPath()
+ pathParts = strings.Split(strings.Trim(path, "/"), "/")
+ queryString = urlStruct.RawQuery
+ return
+}
diff --git a/internal/router/tm.go b/internal/router/tm.go
deleted file mode 100644
index e8095ee..0000000
--- a/internal/router/tm.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package router
-
-import (
- "log/slog"
- "os"
-
- "github.com/SayaAndy/saya-today-web/internal/templatemanager"
-)
-
-var tm = assert(templatemanager.NewTemplateManager())
-
-func assert[T any](t T, err error) T {
- if err != nil {
- slog.Error("fail to initialize template manager", slog.String("error", err.Error()))
- os.Exit(1)
- }
- return t
-}
diff --git a/internal/templatemanager/templatemanager.go b/internal/templatemanager/templatemanager.go
index bafdee5..75309e4 100644
--- a/internal/templatemanager/templatemanager.go
+++ b/internal/templatemanager/templatemanager.go
@@ -39,10 +39,18 @@ func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager
for _, tmplStruct := range templates {
tmpl := template.New(tmplStruct.Name).Funcs(templateFuncMap)
+ if len(tmplStruct.Files) == 0 {
+ templateMap[tmplStruct.Name] = templateManagerRender{
+ Main: "",
+ Tmpl: tmpl,
+ }
+ continue
+ }
tmpl, err := tmpl.ParseFiles(tmplStruct.Files...)
if err != nil {
return nil, err
}
+
templateMap[tmplStruct.Name] = templateManagerRender{
Main: filepath.Base(tmplStruct.Files[0]),
Tmpl: tmpl,
@@ -57,12 +65,16 @@ func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager
func (tm *TemplateManager) Render(name string, data any, files ...string) ([]byte, error) {
tmpl, exists := tm.templates[name]
if !exists {
- return nil, fmt.Errorf("template %s is not found", name)
+ return nil, fmt.Errorf("template %s not found", name)
}
var err error
var tempTmpl *template.Template
+ var mainTmpl string = tmpl.Main
if len(files) == 0 {
+ if mainTmpl == "" {
+ return []byte{}, nil
+ }
tempTmpl = tmpl.Tmpl
} else {
tempTmpl, err = tmpl.Tmpl.Clone()
@@ -73,20 +85,27 @@ func (tm *TemplateManager) Render(name string, data any, files ...string) ([]byt
if err != nil {
return nil, fmt.Errorf("couldn't include additional files in template rendering: %w", err)
}
+ if mainTmpl == "" {
+ mainTmpl = filepath.Base(files[0])
+ }
}
var buf bytes.Buffer
- err = tempTmpl.ExecuteTemplate(&buf, tmpl.Main, data)
+ err = tempTmpl.ExecuteTemplate(&buf, mainTmpl, data)
return buf.Bytes(), err
}
func (tm *TemplateManager) Add(name string, files ...string) error {
+ tmpl := template.New(name).Funcs(templateFuncMap)
+
if len(files) == 0 {
- return fmt.Errorf("you can't add template without any files")
+ tm.templates[name] = templateManagerRender{
+ Main: "",
+ Tmpl: tmpl,
+ }
+ return nil
}
- tmpl := template.New(name).Funcs(templateFuncMap)
-
tmpl, err := tmpl.ParseFiles(files...)
if err != nil {
return fmt.Errorf("failed to add template into manager: %w", err)
@@ -96,5 +115,6 @@ func (tm *TemplateManager) Add(name string, files ...string) error {
Main: filepath.Base(files[0]),
Tmpl: tmpl,
}
+
return nil
}
diff --git a/main.go b/main.go
index 174854d..3691e5b 100644
--- a/main.go
+++ b/main.go
@@ -1,8 +1,6 @@
package main
import (
- "database/sql"
- "errors"
"flag"
"log/slog"
"os"
@@ -10,52 +8,15 @@ import (
"syscall"
"github.com/SayaAndy/saya-today-web/config"
- "github.com/SayaAndy/saya-today-web/internal/b2"
- "github.com/SayaAndy/saya-today-web/internal/blogtrigger"
- "github.com/SayaAndy/saya-today-web/internal/factgiver"
- "github.com/SayaAndy/saya-today-web/internal/glightbox"
- "github.com/SayaAndy/saya-today-web/internal/mailer"
"github.com/SayaAndy/saya-today-web/internal/router"
- "github.com/SayaAndy/saya-today-web/internal/tailwind"
- "github.com/SayaAndy/saya-today-web/locale"
- "github.com/dgraph-io/ristretto/v2"
- "github.com/gofiber/fiber/v2"
- "github.com/gofiber/fiber/v2/middleware/cors"
- "github.com/golang-migrate/migrate/v4"
- "github.com/golang-migrate/migrate/v4/database/sqlite3"
- "github.com/yuin/goldmark"
- "github.com/yuin/goldmark/parser"
- "github.com/yuin/goldmark/renderer"
- "github.com/yuin/goldmark/renderer/html"
- "github.com/yuin/goldmark/util"
+ _ "github.com/SayaAndy/saya-today-web/internal/router/handlers"
_ "github.com/golang-migrate/migrate/v4/source/file"
_ "github.com/mattn/go-sqlite3"
)
var (
- md = goldmark.New(
- goldmark.WithExtensions(
- glightbox.NewGLightboxExtension(),
- tailwind.NewTailwindExtension(),
- ),
- goldmark.WithParserOptions(
- parser.WithAutoHeadingID(),
- parser.WithAttribute(),
- ),
- goldmark.WithRenderer(
- renderer.NewRenderer(
- renderer.WithNodeRenderers(
- util.Prioritized(tailwind.NewCustomLinkRenderer(html.WithUnsafe(), html.WithXHTML()), 50),
- util.Prioritized(html.NewRenderer(html.WithXHTML()), 100),
- ),
- ),
- ),
- )
- b2Client *b2.B2Client
- configPath = flag.String("c", "config.yaml", "Path to the configuration file (in YAML format)")
- availableLanguages = make([]config.AvailableLanguageConfig, 0)
- localization = make(map[string]*locale.LocaleConfig)
+ configPath = flag.String("c", "config.yaml", "Path to the configuration file (in YAML format)")
)
func main() {
@@ -72,148 +33,39 @@ 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)
+ app, err := router.NewRouter(cfg)
if err != nil {
- slog.Error("fail to initialize db", slog.String("error", err.Error()))
+ slog.Error("fail to initialize router app", 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()))
- os.Exit(1)
- }
-
- for _, lang := range cfg.AvailableLanguages {
- localeCfg, err := locale.InitConfig(cfg.LocalePath + lang.LocFile)
- if err != nil {
- slog.Warn("fail to initialize a locale", slog.String("locale", lang.Name), slog.String("error", err.Error()))
- continue
- }
- availableLanguages = append(availableLanguages, lang)
- localization[lang.Name] = localeCfg
- }
-
- app := fiber.New(fiber.Config{
- ProxyHeader: "X-Forwarded-For",
- })
-
- app.Use(cors.New(cors.Config{
- AllowOrigins: "https://f003.backblazeb2.com",
- AllowMethods: "GET,POST,OPTIONS",
- AllowHeaders: "Origin, Content-Type, Accept",
- }))
-
- router.CCache, err = router.NewClientCache(db, []byte(cfg.Auth.Salt))
- if err != nil {
- slog.Error("fail to initialize client cache", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- router.PCache, err = ristretto.NewCache(&ristretto.Config[string, []byte]{
- NumCounters: 1e6, // 1,000,000
- MaxCost: 1 << 29, // 512 MB
- BufferItems: 64, // number of keys per Get buffer.
- })
- if err != nil {
- slog.Error("fail to initialize page cache", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- router.FactGiver, err = factgiver.NewFactGiver(&cfg.FactGiver, availableLanguages)
- if err != nil {
- slog.Error("fail to initialize fact giver", slog.String("error", err.Error()))
+ if err = app.InitRoutes(); err != nil {
+ slog.Error("fail to initialize routes inside router app", slog.String("error", err.Error()))
os.Exit(1)
}
- router.Mailer, err = mailer.NewMailer(db, cfg.Mail.ClientHost, cfg.Mail.MailHost,
- cfg.Mail.PublicName, cfg.Mail.MailAddress, cfg.Mail.Username, cfg.Mail.Password, []byte(cfg.Mail.Salt), localization)
- if err != nil {
- slog.Error("fail to initialize mailer", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- router.BlogTrigger, err = blogtrigger.NewBlogTriggerScheduler(b2Client, cfg.AvailableLanguages, cfg.Mail.Trigger.OnNewPost, func(bp []*b2.BlogPage) error {
- for _, post := range bp {
- if err := router.Mailer.NewPost(post); err != nil {
- return err
- }
- }
- return nil
- })
- if err != nil {
- slog.Error("fail to initialize blog trigger", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- app.Get("/", router.Api_V1_GeneralPage(localization, availableLanguages))
- app.Get("/:lang<len(2)>", router.Api_V1_GeneralPage(localization, availableLanguages))
- app.Get("/:lang/map", router.Lang_Map(localization, availableLanguages, b2Client))
- app.Get("/:lang/user", router.Api_V1_GeneralPage(localization, availableLanguages))
- app.Get("/:lang/user/unsubscribe", router.Lang_User_Unsubscribe(localization, availableLanguages))
- app.Get("/:lang/blog", router.Api_V1_GeneralPage(localization, availableLanguages))
- app.Get("/:lang/blog/:title", router.Api_V1_GeneralPage(localization, availableLanguages))
-
- 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(localization, b2Client))
- app.Put("/api/v1/like", router.Api_V1_Like_Put(localization, b2Client))
- app.Get("/api/v1/general-page/header", router.Api_V1_GeneralPage_Header(localization, availableLanguages, b2Client))
- app.Get("/api/v1/general-page/body", router.Api_V1_GeneralPage_Body(localization, availableLanguages, b2Client, md))
- app.Get("/api/v1/general-page/footer", router.Api_V1_GeneralPage_Footer(localization, availableLanguages))
- app.Get("/api/v1/general-page/top-embeds", router.Api_V1_GeneralPage_TopEmbeds(localization, availableLanguages))
- app.Get("/api/v1/general-page/bottom-embeds", router.Api_V1_GeneralPage_BottomEmbeds(localization, availableLanguages, b2Client))
- app.Post("/api/v1/email/send-verification-code", router.Api_V1_Email_SendVerificationCode(localization))
- app.Post("/api/v1/email/verify", router.Api_V1_Email_Verify(localization))
- app.Get("/api/v1/email/is-in-verification", router.Api_V1_Email_IsInVerification(localization))
- app.Put("/api/v1/subs", router.Api_V1_Subs_Put(localization))
-
- app.Static("/", "./static")
+ routerShutdown := make(chan struct{}, 1)
go func() {
if err := app.Listen(":3000"); err != nil {
slog.Error("error while running fiber server", slog.String("error", err.Error()))
- panic(err)
+ if nestedErr := app.Close(); nestedErr != nil {
+ slog.Error("error while shutting down fiber server", slog.String("error", err.Error()))
+ }
+ <-routerShutdown
}
}()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
- <-sigChan
- slog.Info("gracefully shutting down...")
- if err = router.BlogTrigger.Close(); err != nil {
- slog.Error("fail to shutdown blog trigger scheduler", slog.String("error", err.Error()))
- }
- 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()))
+ select {
+ case <-sigChan:
+ slog.Info("gracefully shutting down...")
+ if err = app.Close(); err != nil {
+ slog.Error("error while shutting down fiber server", slog.String("error", err.Error()))
+ }
+ case <-routerShutdown:
+ slog.Info("gracefully shutting down...")
}
- router.PCache.Close()
- db.Close()
}
diff --git a/views/layouts/general-page.html b/views/layouts/general-page.html
index a7281e8..62360ad 100644
--- a/views/layouts/general-page.html
+++ b/views/layouts/general-page.html
@@ -172,22 +172,22 @@
<div class="grow bg-split-left"></div>
<div class="flex flex-col [@media(max-height:32rem)]:flex-row">
- <div id="sidebar" class="bg-sidebar flex sticky flex-col z-20 w-dvw xs:w-[90dvw] sm:w-[80dvw] md:w-[75dvw] lg:w-[70dvw] xl:w-[65dvw] 2xl:w-[60dvw] [@media(max-height:32rem)]:w-[5rem] h-[5rem] [@media(max-height:32rem)]:h-dvh shadow-[0_0_1rem_black]">
+ <div id="sidebar" class="bg-sidebar flex sticky flex-col z-20 w-dvw xs:w-[90dvw] sm:w-[80dvw] md:w-[75dvw] lg:w-[70dvw] xl:w-[65dvw] 2xl:w-[60dvw] [@media(max-height:32rem)]:w-20 h-20 [@media(max-height:32rem)]:h-dvh shadow-[0_0_1rem_black]">
<div class="text-[3rem] text-stroke-(--sidebar-stroke-color) text-stroke-[0.1dvw] flex flex-row [@media(max-height:32rem)]:flex-col gap-0 relative z-20">
- <a href="./" id="sidebar-back-button" class="w-[5rem] h-[5rem] hidden">
+ <a href="./" id="sidebar-back-button" class="w-20 h-20 hidden">
<i onclick="return changeUrl('/../', true);" class="fas fa-circle-left flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
</a>
- <a href="/{{ .Lang }}" id="sidebar-home-button" class="w-[5rem] h-[5rem] hidden">
+ <a href="/{{ .Lang }}" id="sidebar-home-button" class="w-20 h-20 hidden">
<i onclick="return changeUrl('/{{ .Lang }}');" class="fas fa-home flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
</a>
- <a href="/{{ .Lang }}/blog" id="sidebar-blog-button" class="w-[5rem] h-[5rem] hidden">
+ <a href="/{{ .Lang }}/blog" id="sidebar-blog-button" class="w-20 h-20 hidden">
<i onclick="return changeUrl('/{{ .Lang }}/blog');" class="fas fa-search flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
</a>
- <a href="/{{ .Lang }}/map" id="sidebar-map-button" class="w-[5rem] h-[5rem] hidden">
+ <a href="/{{ .Lang }}/map" id="sidebar-map-button" class="w-20 h-20 hidden">
<i class="fas fa-map flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
</a>
- <i onclick="toggleThemeWheel();" id="sidebar-theme-button" class="fas fa-swatchbook flex w-[5rem] h-[5rem] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 rounded-lg"></i>
- <a href="/{{ .Lang }}/user" id="sidebar-user-button" class="w-[5rem] h-[5rem] hidden">
+ <i onclick="toggleThemeWheel();" id="sidebar-theme-button" class="fas fa-swatchbook flex w-20 h-20 content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 rounded-lg"></i>
+ <a href="/{{ .Lang }}/user" id="sidebar-user-button" class="w-20 h-20 hidden">
<i onclick="return changeUrl('/{{ .Lang }}/user');" class="fas fa-user flex w-full h-full content-center text-center text-sidebar hover:bg-background-dark transition-colors duration-300 rounded-lg"></i>
</a>
</div>
diff --git a/views/partials/general-page-body.html b/views/partials/general-page-body.html
index c3aa3eb..d697c14 100644
--- a/views/partials/general-page-body.html
+++ b/views/partials/general-page-body.html
@@ -1,2 +1 @@
-<title>{{ .Title }} // SAYA TODAY</title>
{{ block "body" . }}{{ end }} \ No newline at end of file
diff --git a/views/partials/general-page-header.html b/views/partials/general-page-header.html
index a29d40b..90180de 100644
--- a/views/partials/general-page-header.html
+++ b/views/partials/general-page-header.html
@@ -1,3 +1,4 @@
+<title>{{ .Title }} // SAYA TODAY</title>
<div {{ if .PublishedDate }} hx-get="/api/v1/tz" hx-vals='js:{timestamp: "{{ .PublishedDate }}", tz: clientTimeZone}' hx-target="#published-date" hx-swap="innerHTML" hx-trigger="load" {{ end }}
class="flex flex-row mb-2">
<p class="text-4xl font-andika font-light italic text-shadow-[0_2px_2px_var(--main-soft-color)] text-left mt-auto">{{ .Title }}</p>