69 files changed, 1414 insertions, 3299 deletions
diff --git a/.github/workflows/build-and-deploy-prod.yml b/.github/workflows/build-and-deploy-prod.yml index d57e872..a180d39 100644 --- a/.github/workflows/build-and-deploy-prod.yml +++ b/.github/workflows/build-and-deploy-prod.yml @@ -75,4 +75,4 @@ jobs: ANSIBLE_HOST_KEY_CHECKING: False working-directory: ./deploy run: | - ansible-playbook -i inventory.yml -l prod playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_auth_salt="${{ secrets.AUTH_SALT }}" -e saya_today_web_b2_key_id="${{ secrets.B2_KEY_ID }}" -e saya_today_web_b2_application_key="${{ secrets.B2_APPLICATION_KEY }}" -e saya_today_web_environment=prod -e saya_today_web_tag=${{ github.ref_name }} -e saya_today_web_mail_salt="${{ secrets.MAIL_SALT }}" -e saya_today_web_mail_host="${{ secrets.MAIL_HOST }}" -e saya_today_web_mail_address="${{ secrets.MAIL_ADDRESS }}" -e saya_today_web_mail_username="${{ secrets.MAIL_USERNAME }}" -e saya_today_web_mail_password="${{ secrets.MAIL_PASSWORD }}" + ansible-playbook -i inventory.yml -l prod playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_auth_salt=${{ secrets.AUTH_SALT }} -e saya_today_web_b2_key_id=${{ secrets.B2_KEY_ID }} -e saya_today_web_b2_application_key=${{ secrets.B2_APPLICATION_KEY }} -e saya_today_web_environment=prod -e saya_today_web_tag=${{ github.ref_name }} diff --git a/.github/workflows/build-and-deploy-stage.yml b/.github/workflows/build-and-deploy-stage.yml index 16e6563..0aea0a4 100644 --- a/.github/workflows/build-and-deploy-stage.yml +++ b/.github/workflows/build-and-deploy-stage.yml @@ -86,4 +86,4 @@ jobs: ANSIBLE_HOST_KEY_CHECKING: False working-directory: ./deploy run: | - ansible-playbook -i inventory.yml -l stage playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_auth_salt="${{ secrets.AUTH_SALT }}" -e saya_today_web_b2_key_id="${{ secrets.B2_KEY_ID }}" -e saya_today_web_b2_application_key="${{ secrets.B2_APPLICATION_KEY }}" -e saya_today_web_environment=stage -e saya_today_web_tag=commit-${{ steps.ghss_vars.outputs.sha_short }} -e saya_today_web_mail_salt="${{ secrets.MAIL_SALT }}" -e saya_today_web_mail_host="${{ secrets.MAIL_HOST }}" -e saya_today_web_mail_address="${{ secrets.MAIL_ADDRESS }}" -e saya_today_web_mail_username="${{ secrets.MAIL_USERNAME }}" -e saya_today_web_mail_password="${{ secrets.MAIL_PASSWORD }}" + ansible-playbook -i inventory.yml -l stage playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_auth_salt=${{ secrets.AUTH_SALT }} -e saya_today_web_b2_key_id=${{ secrets.B2_KEY_ID }} -e saya_today_web_b2_application_key=${{ secrets.B2_APPLICATION_KEY }} -e saya_today_web_environment=stage -e saya_today_web_tag=commit-${{ steps.ghss_vars.outputs.sha_short }} @@ -1,4 +1,4 @@ -FROM golang:1.25.3-alpine3.22 AS build-stage +FROM golang:1.24.6-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.2 AS runtime-stage +FROM alpine:3.22.1 AS runtime-stage ENV ENVIRONMENT="" ENV AUTH_SALT="" diff --git a/config/config.go b/config/config.go index 1393dd8..f57b5cd 100644 --- a/config/config.go +++ b/config/config.go @@ -15,7 +15,6 @@ type Config struct { LocalePath string `json:"LocalePath" yaml:"localePath" validate:"required,filepath"` AvailableLanguages []AvailableLanguageConfig `json:"AvailableLanguages" yaml:"availableLanguages" validate:"required"` Auth AuthConfig `json:"Auth" yaml:"auth" validate:"required"` - Mail MailConfig `json:"Mail" yaml:"mail" validate:"required"` } type BlogPagesConfig struct { @@ -61,21 +60,6 @@ type Sqlite3Config struct { DSN string `json:"DSN" yaml:"dsn" validate:"required"` } -type MailConfig struct { - ClientHost string `json:"ClientHost" yaml:"clientHost" validate:"required"` - MailHost string `json:"MailHost" yaml:"mailHost" validate:"required"` - PublicName string `json:"PublicName" yaml:"publicName" validate:"required"` - MailAddress string `json:"MailAddress" yaml:"mailAddress" validate:"required"` - Username string `json:"Username" yaml:"username" validate:"required"` - Password string `json:"Password" yaml:"password" validate:"required"` - Salt string `json:"Salt" yaml:"salt" validate:"required"` - Trigger TriggerConfig `json:"Trigger" yaml:"trigger" validate:"required"` -} - -type TriggerConfig struct { - OnNewPost string `json:"OnNewPost" yaml:"onNewPost" validate:"cron,required"` -} - func LoadConfig(path string, config *Config) error { fileBytes, err := os.ReadFile(path) if err != nil { diff --git a/config/config.local.yaml b/config/config.local.yaml index e23c808..6d0abf3 100644 --- a/config/config.local.yaml +++ b/config/config.local.yaml @@ -32,16 +32,5 @@ auth: db: type: sqlite3 config: - # dsn: 'file:/tmp/auth.db?cache=shared&mode=rwc&_journal_mode=WAL' - dsn: 'file:/tmp/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2&_journal_mode=WAL' + dsn: 'file:/tmp/auth.db?cache=shared&mode=rwc' salt: '123' -mail: - clientHost: '127.0.0.1:3000' - mailHost: '${MAIL_HOST}' - publicName: 'LOCAL.SAYA.TODAY' - mailAddress: '${MAIL_ADDRESS}' - username: '${MAIL_USERNAME}' - password: '${MAIL_PASSWORD}' - salt: '${MAIL_SALT}' - trigger: - onNewPost: "* * * * *" diff --git a/config/config.prod.yaml b/config/config.prod.yaml index 0bee79d..38981a8 100644 --- a/config/config.prod.yaml +++ b/config/config.prod.yaml @@ -32,15 +32,5 @@ auth: db: type: sqlite3 config: - dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2&_journal_mode=WAL' + dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2' salt: '${AUTH_SALT}' -mail: - clientHost: '${FQDN}' - mailHost: '${MAIL_HOST}' - publicName: 'SAYA.TODAY' - mailAddress: '${MAIL_ADDRESS}' - username: '${MAIL_USERNAME}' - password: '${MAIL_PASSWORD}' - salt: '${MAIL_SALT}' - trigger: - onNewPost: "0/5 * * * *" diff --git a/config/config.stage.yaml b/config/config.stage.yaml index 1c15dae..b05edaf 100644 --- a/config/config.stage.yaml +++ b/config/config.stage.yaml @@ -32,15 +32,5 @@ auth: db: type: sqlite3 config: - dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2&_journal_mode=WAL' + dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2' salt: '${AUTH_SALT}' -mail: - clientHost: '${FQDN}' - mailHost: '${MAIL_HOST}' - publicName: 'DEMO.SAYA.TODAY' - mailAddress: '${MAIL_ADDRESS}' - username: '${MAIL_USERNAME}' - password: '${MAIL_PASSWORD}' - salt: '${MAIL_SALT}' - trigger: - onNewPost: "0/3 * * * *" diff --git a/deploy/inventory.yml b/deploy/inventory.yml index a1ecf5d..dde952e 100644 --- a/deploy/inventory.yml +++ b/deploy/inventory.yml @@ -7,7 +7,6 @@ all: saya_today_web_name: sayana-web saya_today_web_hostname: sayana-web saya_today_web_ipv4_address: 172.16.0.18 - saya_today_web_listen_address: saya.today stage: hosts: @@ -16,4 +15,3 @@ all: saya_today_web_name: sayana-demo saya_today_web_hostname: sayana-demo saya_today_web_ipv4_address: 172.16.0.17 - saya_today_web_listen_address: demo.saya.today diff --git a/deploy/playbook.yml b/deploy/playbook.yml index 2516f4e..dfceaf7 100644 --- a/deploy/playbook.yml +++ b/deploy/playbook.yml @@ -18,12 +18,6 @@ B2_APPLICATION_KEY: "{{ saya_today_web_b2_application_key }}" ENVIRONMENT: "{{ saya_today_web_environment }}" AUTH_SALT: "{{ saya_today_web_auth_salt }}" - MAIL_HOST: "{{ saya_today_web_mail_host }}" - MAIL_ADDRESS: "{{ saya_today_web_mail_address }}" - MAIL_USERNAME: "{{ saya_today_web_mail_username }}" - MAIL_PASSWORD: "{{ saya_today_web_mail_password }}" - MAIL_SALT: "{{ saya_today_web_mail_salt }}" - FQDN: "{{ saya_today_web_listen_address }}" network_mode: caddy networks: - name: caddy @@ -1,27 +1,22 @@ module github.com/SayaAndy/saya-today-web -go 1.25 +go 1.24.6 require ( github.com/Backblaze/blazer v0.7.2 - 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/go-playground/validator/v10 v10.27.0 github.com/gofiber/fiber/v2 v2.52.9 - github.com/golang-migrate/migrate/v4 v4.19.0 + github.com/golang-migrate/migrate/v4 v4.18.3 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.43.0 + golang.org/x/crypto v0.41.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/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/dgraph-io/ristretto/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-playground/locales v0.14.1 // indirect @@ -29,15 +24,16 @@ require ( 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.1 // indirect - github.com/kr/pretty v0.3.1 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect - github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/stretchr/testify v1.10.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.30.0 // indirect + github.com/valyala/fasthttp v1.65.0 // indirect + go.uber.org/atomic v1.7.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect ) @@ -4,35 +4,27 @@ 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/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/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= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/go-co-op/gocron/v2 v2.17.0 h1:e/oj6fcAM8vOOKZxv2Cgfmjo+s8AXC46po5ZPtaSea4= -github.com/go-co-op/gocron/v2 v2.17.0/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -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/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/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.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE= -github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0= +github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs= +github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -40,14 +32,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/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.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/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -56,40 +42,37 @@ 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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +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-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/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/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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.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/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8= +github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -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= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -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= +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= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/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/b2/client.go b/internal/b2/client.go index d712736..7b745a8 100644 --- a/internal/b2/client.go +++ b/internal/b2/client.go @@ -3,7 +3,6 @@ package b2 import ( "context" "fmt" - "slices" "strings" "time" @@ -35,7 +34,6 @@ func NewB2Client(cfg *config.B2Config) (*B2Client, error) { type BlogPage struct { Link string FileName string - Lang string Metadata *frontmatter.Metadata } @@ -76,9 +74,6 @@ func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) { nameParts := strings.Split(linkParts[len(linkParts)-1], ".") fileName := strings.Join(nameParts[:len(linkParts)-1], ".") - tags := strings.Split(attrs.Info["tags"], ",") - slices.Sort(tags) - filePaths = append(filePaths, &BlogPage{ Link: obj.Name(), FileName: fileName, @@ -88,7 +83,7 @@ func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) { ActionDate: attrs.Info["action-date"], PublishedTime: publishedTime, Thumbnail: attrs.Info["thumbnail"], - Tags: tags, + Tags: strings.Split(attrs.Info["tags"], ","), Geolocation: attrs.Info["geolocation"], }, }) diff --git a/internal/blogtrigger/blogtrigger.go b/internal/blogtrigger/blogtrigger.go deleted file mode 100644 index c5b3097..0000000 --- a/internal/blogtrigger/blogtrigger.go +++ /dev/null @@ -1,72 +0,0 @@ -package blogtrigger - -import ( - "fmt" - "log/slog" - - "github.com/SayaAndy/saya-today-web/config" - "github.com/SayaAndy/saya-today-web/internal/b2" - "github.com/go-co-op/gocron/v2" -) - -type BlogTriggerScheduler struct { - s gocron.Scheduler - knownBlogPages map[string]map[string]*b2.BlogPage - b2Client *b2.B2Client - onTrigger func([]*b2.BlogPage) error -} - -func NewBlogTriggerScheduler(b2Client *b2.B2Client, availableLanguages []config.AvailableLanguageConfig, cron string, onTrigger func([]*b2.BlogPage) error) (*BlogTriggerScheduler, error) { - s, err := gocron.NewScheduler() - if err != nil { - return nil, fmt.Errorf("failed to create new scheduler: %w", err) - } - - knownBlogPages := make(map[string]map[string]*b2.BlogPage, len(availableLanguages)) - for _, lang := range availableLanguages { - knownBlogPages[lang.Name] = make(map[string]*b2.BlogPage) - } - - bts := &BlogTriggerScheduler{s, knownBlogPages, b2Client, onTrigger} - defer bts.s.Start() - - bts.s.NewJob(gocron.CronJob(cron, false), gocron.NewTask(func(bts *BlogTriggerScheduler) { - posts, err := bts.scan() - if err != nil { - slog.Error("failed to execute scanning new blog pages cron job", slog.String("error", err.Error())) - return - } - if err = onTrigger(posts); err != nil { - slog.Error("error happened on callback function after scanning new blog pages", slog.String("error", err.Error())) - return - } - }, bts)) - - if _, err = bts.scan(); err != nil { - return nil, fmt.Errorf("failed to scan existing blog pages in b2: %w", err) - } - - return bts, nil -} - -func (bts *BlogTriggerScheduler) Close() error { - return bts.s.Shutdown() -} - -func (bts *BlogTriggerScheduler) scan() (newPages []*b2.BlogPage, err error) { - newPages = make([]*b2.BlogPage, 0) - for lang := range bts.knownBlogPages { - posts, err := bts.b2Client.Scan(lang + "/") - if err != nil { - return nil, fmt.Errorf("failed to scan blog pages in b2 on '%s': %w", lang, err) - } - for _, post := range posts { - if _, ok := bts.knownBlogPages[lang][post.FileName]; !ok { - post.Lang = lang - newPages = append(newPages, post) - bts.knownBlogPages[lang][post.FileName] = post - } - } - } - return newPages, nil -} diff --git a/internal/glightbox/html_renderer.go b/internal/glightbox/html_renderer.go index 7022793..deafb4d 100644 --- a/internal/glightbox/html_renderer.go +++ b/internal/glightbox/html_renderer.go @@ -133,7 +133,7 @@ func (r *GLightboxHTMLRenderer) renderGLightbox(w util.BufWriter, source []byte, } w.WriteString(fmt.Sprintf(` -<div class="justify-content-center display-block"> +<div class="justify-content-center display-block m-1"> <hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%%] ml-auto mr-auto"> <div class="grid masonry-grid-%s mx-auto"> <div class="grid-sizer grid-sizer-%s"></div> @@ -156,7 +156,7 @@ func (r *GLightboxHTMLRenderer) renderGLightbox(w util.BufWriter, source []byte, function initMasonryLayout_%s(tm) { clearTimeout(imgLoad_%s_timer); - imgLoad_%s_timer = setTimeout(() => msnry_%s.layout(), 100); + imgLoad_%s_timer = setTimeout(() => msnry_%s.layout(), 300); } imgLoad_%s.on('progress', initMasonryLayout_%s); diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go deleted file mode 100644 index d6dbd17..0000000 --- a/internal/mailer/mailer.go +++ /dev/null @@ -1,544 +0,0 @@ -package mailer - -import ( - "crypto/rand" - "database/sql" - "encoding/base64" - "encoding/binary" - "fmt" - "html/template" - "log/slog" - "slices" - "strconv" - "strings" - "sync" - "time" - - "github.com/SayaAndy/saya-today-web/internal/b2" - "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/wneessen/go-mail" - "golang.org/x/crypto/argon2" -) - -type Mailer struct { - verificationCodes *ristretto.Cache[uint64, string] - unsubscribeCodes *ristretto.Cache[uint64, []byte] - db *sql.DB - tm *templatemanager.TemplateManager - mailClient *mail.Client - clientHost string - mailAddress string - publicName string - salt []byte - - lostMailMap map[string]struct { - Dur time.Duration - End time.Time - CodeExpiry time.Time - } - lostMailMapMutex sync.RWMutex - - hashMap map[string][]byte - hashMapMutex sync.RWMutex - - l map[string]*locale.LocaleConfig -} - -type SubscriptionType int - -const ( - All SubscriptionType = iota - None - Specific -) - -func NewMailer(db *sql.DB, clientHost string, mailHost string, publicName string, mailAddress string, username string, password string, salt []byte, localization map[string]*locale.LocaleConfig) (*Mailer, error) { - verificationCodes, err := ristretto.NewCache(&ristretto.Config[uint64, string]{ - NumCounters: 10000, - MaxCost: 1 << 20, // 1 MB - BufferItems: 64, - TtlTickerDurationInSec: 3600, // 1 hour - }) - if err != nil { - return nil, fmt.Errorf("fail to initialize cache for verification codes: %w", err) - } - - unsubscribeCodes, err := ristretto.NewCache(&ristretto.Config[uint64, []byte]{ - NumCounters: 10000, - MaxCost: 1 << 20, // 1 MB - BufferItems: 64, - TtlTickerDurationInSec: 86400, // 1 day - }) - if err != nil { - return nil, fmt.Errorf("fail to initialize cache for verification codes: %w", err) - } - - tm, err := templatemanager.NewTemplateManager(templatemanager.TemplateManagerTemplates{ - Name: "new-post", - Files: []string{"views/layouts/general-mail.html", "views/messages/new-post.html"}, - }, templatemanager.TemplateManagerTemplates{ - Name: "verify-email", - Files: []string{"views/layouts/general-mail.html", "views/messages/verify-email.html"}, - }) - if err != nil { - return nil, fmt.Errorf("fail to initialize template manager for message templating: %w", err) - } - - mailClient, err := mail.NewClient(mailHost, - mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), mail.WithTLSPortPolicy(mail.TLSMandatory), - mail.WithUsername(username), mail.WithPassword(password), - ) - if err != nil { - return nil, fmt.Errorf("fail to initialize mail client: %w", err) - } - - return &Mailer{ - verificationCodes: verificationCodes, - unsubscribeCodes: unsubscribeCodes, - db: db, - clientHost: clientHost, - tm: tm, - mailClient: mailClient, - mailAddress: mailAddress, - publicName: publicName, - salt: salt, - hashMap: make(map[string][]byte), - lostMailMap: make(map[string]struct { - Dur time.Duration - End time.Time - CodeExpiry time.Time - }, 0), - l: localization}, nil -} - -func (m *Mailer) GetHash(id string) []byte { - m.hashMapMutex.RLock() - if val, ok := m.hashMap[id]; ok { - m.hashMapMutex.RUnlock() - slog.Debug("gave an old hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(val))) - return val - } - m.hashMapMutex.RUnlock() - - m.hashMapMutex.Lock() - defer m.hashMapMutex.Unlock() - - if val, ok := m.hashMap[id]; ok { - slog.Debug("gave a newly generated hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(val))) - return val - } - - m.hashMap[id] = argon2.IDKey([]byte(id), m.salt, 1, 64*1024, 4, 32) - slog.Debug("generated hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(m.hashMap[id]))) - return m.hashMap[id] -} - -func (m *Mailer) IsAllowedToRetryVerification(userId string) (retryAllowed bool, whenAllowed time.Time, codeExpiry time.Time) { - m.lostMailMapMutex.RLock() - defer m.lostMailMapMutex.RUnlock() - previous, ok := m.lostMailMap[userId] - if ok && previous.End.After(time.Now()) { - return false, previous.End, previous.CodeExpiry - } - return true, time.Time{}, previous.CodeExpiry -} - -func (m *Mailer) MailIsTaken(email string) (bool, error) { - tx, err := m.db.Begin() - if err != nil { - return false, fmt.Errorf("failed to initialize transaction with db: %s", err) - } - - slog.Debug("began db transaction", slog.String("method", "MailIsTaken")) - defer func(tx *sql.Tx) { - if err = tx.Commit(); err != nil { - tx.Rollback() - } - slog.Debug("ended db transaction", slog.String("method", "MailIsTaken")) - }(tx) - - var rows *sql.Rows - if rows, err = tx.Query(`SELECT email FROM user_email_table WHERE email=? LIMIT 1;`, email); err != nil { - tx.Rollback() - - return false, fmt.Errorf("failed to query user-email settings in db: %s", err) - } - defer rows.Close() - - isTaken := rows.Next() - return isTaken, nil -} - -func (m *Mailer) GetInfo(userIdHash []byte) (email string, lang string, err error) { - tx, err := m.db.Begin() - if err != nil { - return "", "", fmt.Errorf("failed to initialize transaction with db: %s", err) - } - - slog.Debug("began db transaction", slog.String("method", "GetInfo")) - defer func(tx *sql.Tx) { - if err = tx.Commit(); err != nil { - tx.Rollback() - } - slog.Debug("ended db transaction", slog.String("method", "GetInfo")) - }(tx) - - var rows *sql.Rows - if rows, err = tx.Query(`SELECT email, lang FROM user_email_table WHERE user_id=? LIMIT 1;`, userIdHash); err != nil { - tx.Rollback() - return "", "", fmt.Errorf("failed to query user-email settings in db: %s", err) - } - defer rows.Close() - - if !rows.Next() { - return "", "", nil - } - - if err = rows.Scan(&email, &lang); err != nil { - return "", "", fmt.Errorf("failed to scan the result from user-email settings query: %s", err) - } - return -} - -func (m *Mailer) Unsubscribe(unsubscribeCodeString string) (clientError error, serverError error) { - unsubscribeCode, err := strconv.ParseUint(unsubscribeCodeString, 16, 64) - if err != nil { - return fmt.Errorf("invalid unsubscribe code: %s", err), nil - } - - userId, _ := m.unsubscribeCodes.Get(unsubscribeCode) - if len(userId) == 0 { - return fmt.Errorf("invalid unsubscribe code: have no information about it"), nil - } - - if err = m.Subscribe(userId, None); err != nil { - return nil, fmt.Errorf("failed to unsubscribe: %s", err) - } - - return nil, nil -} - -func (m *Mailer) SendVerificationCode(userId string, address string, lang string) error { - message := mail.NewMsg() - - if err := message.EnvelopeFrom(m.mailAddress); err != nil { - return fmt.Errorf("failed to set ENVELOPE FROM address: %w", err) - } - if err := message.FromFormat(m.publicName, m.mailAddress); err != nil { - return fmt.Errorf("failed to set formatted FROM address: %w", err) - } - if err := message.To(address); err != nil { - return fmt.Errorf("failed to set TO address: %w", err) - } - - message.SetMessageID() - message.SetDate() - message.SetBulk() - - dur := 1 * time.Minute - m.lostMailMapMutex.Lock() - if previous, ok := m.lostMailMap[userId]; ok { - if time.Now().Before(previous.End) { - m.lostMailMapMutex.Unlock() - return fmt.Errorf("user is not allowed to send another verification code until %s", previous.End) - } - dur = 2 * m.lostMailMap[userId].Dur - } - m.lostMailMap[userId] = struct { - Dur time.Duration - End time.Time - CodeExpiry time.Time - }{Dur: dur, End: time.Now().Add(dur), CodeExpiry: time.Now().Add(time.Hour)} - m.lostMailMapMutex.Unlock() - - verificationCodeBytes := make([]byte, 8) - rand.Read(verificationCodeBytes) - verificationCode := binary.LittleEndian.Uint64(verificationCodeBytes) - - verificationInfo := fmt.Sprintf("%s.%s", base64.RawStdEncoding.EncodeToString([]byte(userId)), base64.RawStdEncoding.EncodeToString([]byte(address))) - m.verificationCodes.Set(verificationCode, verificationInfo, int64(len(verificationInfo)+8)) - - message.Subject(m.l[lang].Mail.VerifyEmail.Subject) - - msg, err := m.tm.Render("verify-email", fiber.Map{ - "L": m.l[lang], - "Lang": lang, - "VerificationCode": fmt.Sprintf("%X", verificationCode), - "ClientHost": m.clientHost, - }) - if err != nil { - return fmt.Errorf("failed to render message body: %w", err) - } - - message.SetBodyString(mail.TypeTextHTML, string(msg)) - if err := m.mailClient.DialAndSend(message); err != nil { - return fmt.Errorf("failed to send verification code message: %w", err) - } - slog.Debug("verification code message successfully delivered", slog.String("address", address), slog.String("user_id", userId)) - return nil -} - -func (m *Mailer) Verify(verificationCodeEncoded string, lang string) error { - verificationCode, err := strconv.ParseUint(verificationCodeEncoded, 16, 64) - if err != nil { - return fmt.Errorf("failed to decode verification code from 8-byte hex: %w", err) - } - - verificationInfo, _ := m.verificationCodes.Get(verificationCode) - if verificationInfo == "" { - return fmt.Errorf("failed to get verification info by its code (might be absent, might be empty)") - } - - verificationSegments := strings.Split(verificationInfo, ".") - if len(verificationSegments) != 2 { - m.verificationCodes.Del(verificationCode) - return fmt.Errorf("invalid format of verification info: expected %d segments, got %d", 2, len(verificationSegments)) - } - - userId, err := base64.RawStdEncoding.DecodeString(verificationSegments[0]) - if err != nil { - m.verificationCodes.Del(verificationCode) - delete(m.lostMailMap, verificationSegments[0]) - return fmt.Errorf("could not decode user id: %s", err) - } - - address, err := base64.RawStdEncoding.DecodeString(verificationSegments[1]) - if err != nil { - m.verificationCodes.Del(verificationCode) - delete(m.lostMailMap, verificationSegments[0]) - return fmt.Errorf("could not decode address: %s", err) - } - - tx, err := m.db.Begin() - if err != nil { - return fmt.Errorf("failed to initialize transaction with db: %s", err) - } - - slog.Debug("began db transaction", slog.String("method", "Verify")) - if _, err = tx.Exec(`INSERT INTO user_email_table(user_id, email, lang) VALUES(?, ?, ?) - ON CONFLICT(user_id) DO UPDATE SET - email=excluded.email, - lang=excluded.lang;`, m.GetHash(string(userId)), address, lang); err != nil { - tx.Rollback() - slog.Debug("ended db transaction", slog.String("method", "Verify")) - return fmt.Errorf("failed to configure user-email settings in db: %s", err) - } - - if err = tx.Commit(); err != nil { - tx.Rollback() - slog.Debug("ended db transaction", slog.String("method", "Verify")) - return fmt.Errorf("failed to commit transaction to db: %s", err) - } - slog.Debug("ended db transaction", slog.String("method", "Verify")) - - m.verificationCodes.Del(verificationCode) - delete(m.lostMailMap, verificationSegments[0]) - return nil -} - -func (m *Mailer) GetSubscriptions(userId string) (subscriptionType SubscriptionType, tags []string, err error) { - tx, err := m.db.Begin() - if err != nil { - return None, nil, fmt.Errorf("failed to initialize transaction with db: %s", err) - } - - slog.Debug("began db transaction", slog.String("method", "GetSubscriptions")) - defer func(tx *sql.Tx) { - if err = tx.Commit(); err != nil { - tx.Rollback() - } - slog.Debug("ended db transaction", slog.String("method", "GetSubscriptions")) - }(tx) - - hash := m.GetHash(userId) - - var rows *sql.Rows - if rows, err = tx.Query(`SELECT tags FROM subscription_user_to_tags_table WHERE user_id=? LIMIT 1;`, hash); err != nil { - tx.Rollback() - return None, nil, fmt.Errorf("failed to query user-to-tags table in db for the user: %s", err) - } - defer rows.Close() - - if !rows.Next() { - return None, nil, nil - } - - tagsString := "" - if err = rows.Scan(&tagsString); err != nil { - return None, nil, fmt.Errorf("failed to scan the result from user-to-tags query: %s", err) - } - - switch tagsString { - case "": - return None, nil, nil - case "_all": - return All, nil, nil - default: - return Specific, strings.Split(tagsString, ","), nil - } -} - -func (m *Mailer) Subscribe(userIdHash []byte, subscriptionType SubscriptionType, tags ...string) error { - tx, err := m.db.Begin() - if err != nil { - return fmt.Errorf("failed to initialize transaction with db: %s", err) - } - - slog.Debug("began db transaction", slog.String("method", "Subscribe")) - - slices.Sort(tags) - tagsOutput := "" - switch subscriptionType { - case All: - tagsOutput = "_all" - case None: - tagsOutput = "" - case Specific: - tagsOutput = strings.Join(tags, ",") - } - - if _, err = tx.Exec(`INSERT INTO subscription_user_to_tags_table(user_id, tags) VALUES(?, ?) - ON CONFLICT(user_id) DO UPDATE SET - tags=excluded.tags;`, userIdHash, tagsOutput); err != nil { - tx.Rollback() - slog.Debug("ended db transaction", slog.String("method", "Subscribe")) - return fmt.Errorf("failed to configure user-to-tags table in db for the user: %s", err) - } - - if err = tx.Commit(); err != nil { - tx.Rollback() - slog.Debug("ended db transaction", slog.String("method", "Subscribe")) - return fmt.Errorf("failed to commit transaction to db: %s", err) - } - slog.Debug("ended db transaction", slog.String("method", "Subscribe")) - - return nil -} - -func (m *Mailer) NewPost(post *b2.BlogPage) error { - tx, err := m.db.Begin() - if err != nil { - return fmt.Errorf("failed to initialize transaction with db: %s", err) - } - - slog.Debug("began db transaction", slog.String("method", "NewPost")) - var rows *sql.Rows - if rows, err = tx.Query(`SELECT user_id, tags FROM subscription_user_to_tags_table;`); err != nil { - tx.Rollback() - slog.Debug("ended db transaction", slog.String("method", "NewPost")) - return fmt.Errorf("failed to query user-to-tags table in db: %s", err) - } - - var userId []byte - usersToSend := make([]struct { - userId []byte - email string - }, 0) - var tagsString string - i := -1 - -rowLoop: - for rows.Next() { - i++ - if err = rows.Scan(&userId, &tagsString); err != nil { - slog.Warn("failed to scan a row in user-to-tags table", slog.String("error", err.Error()), slog.Int("index", i), slog.String("user_id", base64.RawStdEncoding.EncodeToString(userId))) - continue - } - - if tagsString == "" { - continue - } - - if tagsString == "_all" { - usersToSend = append(usersToSend, struct { - userId []byte - email string - }{userId, ""}) - continue - } - - pageTags := strings.Split(tagsString, ",") - for _, tag := range pageTags { - if _, found := slices.BinarySearch(post.Metadata.Tags, tag); found { - usersToSend = append(usersToSend, struct { - userId []byte - email string - }{userId, ""}) - continue rowLoop - } - } - } - - tx.Commit() - rows.Close() - slog.Debug("ended db transaction", slog.String("method", "NewPost")) - - for i := range usersToSend { - email, lang, err := m.GetInfo(usersToSend[i].userId) - if err != nil { - slog.Warn("failed to get info about the user", slog.String("error", err.Error()), slog.Int("index", i), slog.String("user_id", base64.RawStdEncoding.EncodeToString(userId))) - continue - } - - if lang != post.Lang { - continue - } - - usersToSend[i].email = email - } - - messages := make([]*mail.Msg, 0, len(usersToSend)) - for _, user := range usersToSend { - if user.email == "" { - continue - } - unsubscribeCodeBytes := make([]byte, 8) - rand.Read(unsubscribeCodeBytes) - unsubscribeCode := binary.LittleEndian.Uint64(unsubscribeCodeBytes) - - unsubscribeFooter := strings.Replace(m.l[post.Lang].Mail.UnsubscribeFooter, "{}", fmt.Sprintf(`<a style="color: #273de1 !important;" href="https://%s/%s/user/unsubscribe?code=%X">`, m.clientHost, post.Lang, unsubscribeCode), 1) - unsubscribeFooter = strings.Replace(unsubscribeFooter, "{/}", "</a>", 1) - - msgBody, err := m.tm.Render("new-post", fiber.Map{ - "L": m.l[post.Lang], - "Lang": post.Lang, - "Post": post, - "ClientHost": m.clientHost, - "UnsubscribeFooter": template.HTML(unsubscribeFooter), - }) - - if err != nil { - return fmt.Errorf("failed to render message body: %w", err) - } - - message := mail.NewMsg() - - if err := message.EnvelopeFrom(m.mailAddress); err != nil { - return fmt.Errorf("failed to set ENVELOPE FROM address: %w", err) - } - if err := message.FromFormat(m.publicName, m.mailAddress); err != nil { - return fmt.Errorf("failed to set formatted FROM address: %w", err) - } - if err := message.To(user.email); err != nil { - return fmt.Errorf("failed to set TO address: %w", err) - } - - message.SetMessageID() - message.SetDate() - message.SetBulk() - message.Subject(m.l[post.Lang].Mail.NewPost.Subject) - message.SetBodyString(mail.TypeTextHTML, string(msgBody)) - - m.unsubscribeCodes.Set(unsubscribeCode, user.userId, 40) - - messages = append(messages, message) - } - - if err := m.mailClient.DialAndSend(messages...); err != nil { - return fmt.Errorf("failed to send new post notifications: %w", err) - } - return nil -} diff --git a/internal/router/api-v1-blog-search.go b/internal/router/api-v1-blog-search.go new file mode 100644 index 0000000..ab5425c --- /dev/null +++ b/internal/router/api-v1-blog-search.go @@ -0,0 +1,128 @@ +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() { + 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-general-page-body.go b/internal/router/api-v1-general-page-body.go new file mode 100644 index 0000000..fe21957 --- /dev/null +++ b/internal/router/api-v1-general-page-body.go @@ -0,0 +1,176 @@ +package router + +import ( + "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/factgiver" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" + "github.com/yuin/goldmark" +) + +var FactGiver *factgiver.FactGiver + +func init() { + tm.Add("general-page-body", "views/partials/general-page-body.html") +} + +func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []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() + + cacheKey := fmt.Sprintf("body.%s", path) + if val, ok := PCache.Get(cacheKey); val != nil && ok { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Type("html").Send(val) + } + + 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" { + querySort := c.Query("sort") + if querySort == "" { + querySort = "publicationDateDesc" + } + + encodedQuery := c.Request().URI().QueryString() + re, err := regexp.Compile(`tags\[\]=([\w]+)`) + if err != nil { + slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error())) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering") + } + decodedQuery, _ := url.QueryUnescape(string(encodedQuery)) + matches := re.FindAllStringSubmatch(decodedQuery, -1) + + queryTags := make([]string, 0, len(matches)) + for _, match := range matches { + queryTags = append(queryTags, string(match[1])) + } + + pages, err := b2Client.Scan(lang + "/") + if err != nil { + slog.Warn("failed to scan pages via b2", slog.String("error", err.Error())) + return c.Status(fiber.ErrInternalServerError.Code).SendString(fmt.Sprintf("failed to scan pages via b2: %s", slog.String("error", err.Error()))) + } + + tagsMap := make(map[string]int) + for _, page := range pages { + for _, tag := range page.Metadata.Tags { + tagsMap[tag]++ + } + } + slog.Debug("enlist pages for catalogue", slog.Int("tag_count", len(tagsMap)), slog.Int("page_count", len(pages)), slog.String("path", c.Path())) + + type Tag struct { + Name string `json:"Name" yaml:"name"` + Count int `json:"Count" yaml:"count"` + } + + tagsArray := make([]Tag, 0, len(tagsMap)) + for tag, count := range tagsMap { + tagsArray = append(tagsArray, Tag{tag, count}) + } + slices.SortFunc(tagsArray, func(a Tag, b Tag) int { + return strings.Compare(a.Name, b.Name) + }) + + values["Tags"] = tagsArray + values["QuerySort"] = querySort + values["QueryTags"] = strings.Join(queryTags, ",") + values["Title"] = l[lang].BlogSearch.Header + + additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html") + } else if len(pathParts) == 3 && pathParts[1] == "blog" { + metadata, parsedMarkdown, err := readBlogPost(md, b2Client, lang+"/"+pathParts[2]) + if err != nil { + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("failed to find '%s' post", pathParts[2])) + } + + geolocationParts := strings.Split(metadata.Geolocation, " ") + var x, y, areaError string + if len(geolocationParts) >= 2 { + x = geolocationParts[0] + y = geolocationParts[1] + } + if len(geolocationParts) >= 3 { + areaError = geolocationParts[2] + } + + values["MapLocationX"] = x + values["MapLocationY"] = y + values["MapLocationAreaMeters"] = areaError + values["Title"] = metadata.Title + values["ParsedMarkdown"] = template.HTML(parsedMarkdown) + values["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00") + values["ActionDate"] = metadata.ActionDate + + additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html") + + go CCache.View(c.IP(), pathParts[2]) + } else if len(pathParts) == 1 { + values["Title"] = l[lang].HomePage.Header + values["FilledHeartCount"] = uint(40) + values["OutlineHeartCount"] = uint(40) + values["GifName"] = fmt.Sprintf("otter-%d.gif", rand.Int()%3+1) + values["FunFacts"] = FactGiver.Give(lang) + additionalTemplates = append(additionalTemplates, "views/pages/home-page.html") + } 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) + } +} diff --git a/internal/router/api-v1-general-page-bottom-embeds.go b/internal/router/api-v1-general-page-bottom-embeds.go new file mode 100644 index 0000000..0a2030f --- /dev/null +++ b/internal/router/api-v1-general-page-bottom-embeds.go @@ -0,0 +1,84 @@ +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() { + 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) == 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 new file mode 100644 index 0000000..2030b3b --- /dev/null +++ b/internal/router/api-v1-general-page-footer.go @@ -0,0 +1,84 @@ +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() { + 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) == 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 new file mode 100644 index 0000000..a2c832b --- /dev/null +++ b/internal/router/api-v1-general-page-header.go @@ -0,0 +1,95 @@ +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() { + 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) == 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 new file mode 100644 index 0000000..544e3a4 --- /dev/null +++ b/internal/router/api-v1-general-page-top-embeds.go @@ -0,0 +1,84 @@ +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() { + 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) == 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 new file mode 100644 index 0000000..a77fc5e --- /dev/null +++ b/internal/router/api-v1-general-page.go @@ -0,0 +1,57 @@ +package router + +import ( + "log/slog" + "strings" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + tm.Add("general-page", "views/layouts/general-page.html") +} + +func Api_V1_GeneralPage(l map[string]*locale.LocaleConfig, langs []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: + cacheKey := "general-page." + lang + if val, ok := PCache.Get(cacheKey); val != nil && ok { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Type("html").Send(val) + } + + content, err := tm.Render("general-page", fiber.Map{ + "L": l[lang], + "Lang": lang, + "QueryString": string(c.Request().URI().QueryString()), + }) + if err != nil { + slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error())) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div") + } + + go PCache.Set(cacheKey, content, int64(len(content))) + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Type("html").Send(content) + } +} diff --git a/internal/router/api-v1-like.go b/internal/router/api-v1-like.go new file mode 100644 index 0000000..327dafa --- /dev/null +++ b/internal/router/api-v1-like.go @@ -0,0 +1,122 @@ +package router + +import ( + "fmt" + "log/slog" + "net/url" + "strconv" + "strings" + + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + tm.Add("blog-page-like-button", "views/partials/blog-page-like-button.html") +} + +func Api_V1_Like_Put(l map[string]*locale.LocaleConfig, b2 *b2.B2Client) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + + referer := c.Get("Referer", "") + if referer == "" { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty") + } + urlStruct, err := url.ParseRequestURI(referer) + if err != nil { + return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error())) + } + + path := urlStruct.EscapedPath() + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) != 3 { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/blog/{page}'") + } + + lang, page := pathParts[0], pathParts[2] + + pageLink := lang + "/" + page + ".md" + if pages, _ := b2.Scan(pageLink); len(pages) == 0 { + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server did not find '%s' article", pageLink)) + } + + newLikeStatus, err := strconv.ParseBool(c.FormValue("like", "true")) + if err != nil { + return c.Status(fiber.ErrBadRequest.Code).SendString("invalid 'like' value") + } + + ip := c.IP() + if newLikeStatus { + CCache.LikeOn(ip, page) + } else { + CCache.LikeOff(ip, page) + } + + slog.Debug("someone pressed the like button!", slog.String("ip", ip), slog.String("page", page), slog.String("new_like_status", fmt.Sprint(newLikeStatus))) + if c.Get("HX-Request", "false") == "true" { + content, err := tm.Render("blog-page-like-button", fiber.Map{ + "L": l[lang], + "Liked": newLikeStatus, + "LikedCount": CCache.GetLikeCount(page), + }) + if err != nil { + slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error())) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div") + } + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Send(content) + } + + return c.Status(fiber.StatusOK).SendString(fmt.Sprint(newLikeStatus)) + } +} + +func Api_V1_Like_Get(l map[string]*locale.LocaleConfig, b2 *b2.B2Client) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + + referer := c.Get("Referer", "") + if referer == "" { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty") + } + urlStruct, err := url.ParseRequestURI(referer) + if err != nil { + return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error())) + } + + path := urlStruct.EscapedPath() + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) != 3 { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/blog/{page}'") + } + + lang, page := pathParts[0], pathParts[2] + + pageLink := lang + "/" + page + ".md" + if pages, _ := b2.Scan(pageLink); len(pages) == 0 { + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server did not find '%s' article", pageLink)) + } + + ip := c.IP() + likeStatus := CCache.GetLikeStatus(ip, page) + + slog.Debug("someone requested the like status!", slog.String("ip", ip), slog.String("page", page), slog.Bool("like_status", likeStatus)) + if c.Get("HX-Request", "false") == "true" { + content, err := tm.Render("blog-page-like-button", fiber.Map{ + "L": l[lang], + "Liked": likeStatus, + "LikedCount": CCache.GetLikeCount(page), + }) + if err != nil { + slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error())) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div") + } + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Send(content) + } + + return c.Status(fiber.StatusOK).SendString(fmt.Sprint(likeStatus)) + } +} diff --git a/internal/router/api-v1-tz.go b/internal/router/api-v1-tz.go new file mode 100644 index 0000000..4e4e462 --- /dev/null +++ b/internal/router/api-v1-tz.go @@ -0,0 +1,37 @@ +package router + +import ( + "log/slog" + "time" + + "github.com/gofiber/fiber/v2" +) + +func Api_V1_TZ() func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + timestampString := c.Query("timestamp") + tz := c.Query("tz") + + loc, err := time.LoadLocation(tz) + if err != nil { + loc = time.UTC + slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz)) + } + + var format string + var timestamp time.Time + for _, format = range []string{"2006-01-02 15:04:05 -07:00", time.RFC3339} { + if timestamp, err = time.Parse(format, timestampString); err == nil { + break + } + } + + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + + if err != nil { + return c.Status(fiber.StatusBadRequest).SendString("invalid timestamp format") + } + + return c.Status(fiber.StatusOK).SendString(timestamp.In(loc).Format(format)) + } +} diff --git a/internal/router/basic-handler.go b/internal/router/basic-handler.go deleted file mode 100644 index a859d10..0000000 --- a/internal/router/basic-handler.go +++ /dev/null @@ -1,62 +0,0 @@ -package router - -import ( - "time" - - "github.com/gofiber/fiber/v2" -) - -type BasicHandler struct{} - -var _ Route = &BasicHandler{} - -func (r *BasicHandler) Filter() (method string, path string) { - panic("handler did not implement Filter method") -} - -func (r *BasicHandler) IsTemplated() bool { - return false -} - -func (r *BasicHandler) ToCache() CacheSetting { - return Disabled -} - -func (r *BasicHandler) CacheDuration() time.Duration { - return 5 * time.Minute -} - -func (r *BasicHandler) ToValidateLang() LangSetting { - return NotRequired -} - -func (r *BasicHandler) TemplatesToInject() []string { - return []string{} -} - -func (r *BasicHandler) 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 7c28b9e..d607cdd 100644 --- a/internal/router/client-cache.go +++ b/internal/router/client-cache.go @@ -24,17 +24,17 @@ type ClientCache struct { db *sql.DB } +var CCache *ClientCache + func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) { tx, err := db.Begin() if err != nil { return nil, fmt.Errorf("fail to init transaction with db to fill cache: %w", err) } - slog.Debug("began db transaction", slog.String("method", "NewClientCache")) rows, err := tx.Query("select * from blog_likes;") if err != nil { tx.Rollback() - slog.Debug("ended db transaction", slog.String("method", "NewClientCache")) return nil, fmt.Errorf("fail to query db for blog_likes to fill cache: %w", err) } @@ -62,7 +62,6 @@ func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) { rows, err = tx.Query("select * from blog_views;") if err != nil { tx.Rollback() - slog.Debug("ended db transaction", slog.String("method", "NewClientCache")) return nil, fmt.Errorf("fail to query db for blog_views to fill cache: %w", err) } @@ -82,11 +81,8 @@ func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) { } if err = tx.Commit(); err != nil { - tx.Rollback() - slog.Debug("ended db transaction", slog.String("method", "NewClientCache")) return nil, fmt.Errorf("fail to commit transaction in db: %w", err) } - slog.Debug("ended db transaction", slog.String("method", "NewClientCache")) return &ClientCache{ hashMap: make(map[string]string), @@ -103,28 +99,18 @@ func (c *ClientCache) Close() error { if err != nil { return fmt.Errorf("fail to init transaction with db to dump cache: %w", err) } - slog.Debug("began db transaction in ClientCache.Close") if err = batchSave(tx, "blog_likes", c.likePageMap); err != nil { tx.Rollback() - slog.Debug("ended db transaction in ClientCache.Close") return fmt.Errorf("fail to save blog_likes: %s", err) } if err = batchSave(tx, "blog_views", c.viewPageMap); err != nil { tx.Rollback() - slog.Debug("ended db transaction in ClientCache.Close") return fmt.Errorf("fail to save blog_views: %s", err) } - if err = tx.Commit(); err != nil { - tx.Rollback() - slog.Debug("ended db transaction in ClientCache.Close") - return fmt.Errorf("fail to commit all the changes related to cache: %s", err) - } - - slog.Debug("ended db transaction in ClientCache.Close") - return nil + return tx.Commit() } func (c *ClientCache) GetHash(id string) string { diff --git a/internal/router/handlers/api-v1-blog-search.go b/internal/router/handlers/api-v1-blog-search.go deleted file mode 100644 index a0316e2..0000000 --- a/internal/router/handlers/api-v1-blog-search.go +++ /dev/null @@ -1,126 +0,0 @@ -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 deleted file mode 100644 index 4f05f48..0000000 --- a/internal/router/handlers/api-v1-email-is-in-verification.go +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 20c5fb6..0000000 --- a/internal/router/handlers/api-v1-email-send-verification-code.go +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index c9ec969..0000000 --- a/internal/router/handlers/api-v1-email-verify.go +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index e9c176e..0000000 --- a/internal/router/handlers/api-v1-like-get.go +++ /dev/null @@ -1,63 +0,0 @@ -package handlers - -import ( - "fmt" - "log/slog" - - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/gofiber/fiber/v2" -) - -type GetLikeHandler struct { - router.BasicHandler -} - -func init() { - router.Routes = append(router.Routes, &GetLikeHandler{}) -} - -func (r *GetLikeHandler) Filter() (method string, path string) { - return "GET", "/api/v1/like" -} - -func (r *GetLikeHandler) IsTemplated() bool { - return false -} - -func (r *GetLikeHandler) TemplatesToInject() []string { - return []string{"views/partials/blog-page-like-button.html"} -} - -func (r *GetLikeHandler) ToCache() router.CacheSetting { - return router.Disabled -} - -func (r *GetLikeHandler) ToValidateLang() router.LangSetting { - return router.InReferer -} - -func (r *GetLikeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - path, pathParts, _, err := router.GetPathFromReferer(c) - if err != nil { - return fiber.StatusBadRequest, fmt.Errorf("error getting path from referer: %w", err) - } - - if len(pathParts) != 3 && pathParts[1] != "blog" { - return fiber.StatusBadRequest, fmt.Errorf("invalid path format: expected '/:lang/blog/:page', got '%s'", path) - } - - page := pathParts[2] - pageLink := lang + "/" + page + ".md" - if pages, _ := supplements.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 deleted file mode 100644 index ea4ef4b..0000000 --- a/internal/router/handlers/api-v1-like-put.go +++ /dev/null @@ -1,74 +0,0 @@ -package handlers - -import ( - "fmt" - "log/slog" - "strconv" - - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/gofiber/fiber/v2" -) - -type PutLikeHandler struct { - router.BasicHandler -} - -func init() { - router.Routes = append(router.Routes, &PutLikeHandler{}) -} - -func (r *PutLikeHandler) Filter() (method string, path string) { - return "PUT", "/api/v1/like" -} - -func (r *PutLikeHandler) IsTemplated() bool { - return false -} - -func (r *PutLikeHandler) TemplatesToInject() []string { - return []string{"views/partials/blog-page-like-button.html"} -} - -func (r *PutLikeHandler) ToCache() router.CacheSetting { - return router.Disabled -} - -func (r *PutLikeHandler) ToValidateLang() router.LangSetting { - return router.InReferer -} - -func (r *PutLikeHandler) 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 deleted file mode 100644 index 144abe5..0000000 --- a/internal/router/handlers/api-v1-subs-put.go +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index 8b08113..0000000 --- a/internal/router/handlers/api-v1-tz-get.go +++ /dev/null @@ -1,65 +0,0 @@ -package handlers - -import ( - "fmt" - "log/slog" - "time" - - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/gofiber/fiber/v2" -) - -type GetTimezoneHandler struct { - router.BasicHandler -} - -func init() { - router.Routes = append(router.Routes, &GetTimezoneHandler{}) -} - -func (r *GetTimezoneHandler) Filter() (method string, path string) { - return "GET", "/api/v1/tz" -} - -func (r *GetTimezoneHandler) IsTemplated() bool { - return false -} - -func (r *GetTimezoneHandler) TemplatesToInject() []string { - return []string{} -} - -func (r *GetTimezoneHandler) ToCache() router.CacheSetting { - return router.Disabled -} - -func (r *GetTimezoneHandler) ToValidateLang() router.LangSetting { - return router.NotRequired -} - -func (r *GetTimezoneHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - timestampString := c.Query("timestamp") - tz := c.Query("tz") - - loc, err := time.LoadLocation(tz) - if err != nil { - loc = time.UTC - slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz)) - } - - var format string - var timestamp time.Time - for _, format = range []string{"2006-01-02 15:04:05 -07:00", time.RFC3339} { - if timestamp, err = time.Parse(format, timestampString); err == nil { - break - } - } - - if err != nil { - return fiber.StatusBadRequest, fmt.Errorf("invalid timestamp format for '%s'", timestampString) - } - - templateMap["Output"] = []byte(timestamp.In(loc).Format("2006-01-02 15:04:05 -07:00")) - - return fiber.StatusOK, nil -} diff --git a/internal/router/handlers/lang-blog-title.go b/internal/router/handlers/lang-blog-title.go deleted file mode 100644 index f1be2cd..0000000 --- a/internal/router/handlers/lang-blog-title.go +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index e4c20c6..0000000 --- a/internal/router/handlers/lang-blog.go +++ /dev/null @@ -1,112 +0,0 @@ -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 deleted file mode 100644 index dbf1437..0000000 --- a/internal/router/handlers/lang-map.go +++ /dev/null @@ -1,95 +0,0 @@ -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 deleted file mode 100644 index 4fbd244..0000000 --- a/internal/router/handlers/lang-user-unsubscribe.go +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index db566fe..0000000 --- a/internal/router/handlers/lang-user.go +++ /dev/null @@ -1,78 +0,0 @@ -package handlers - -import ( - "fmt" - "log/slog" - - "github.com/SayaAndy/saya-today-web/internal/mailer" - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/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 deleted file mode 100644 index fc4deb6..0000000 --- a/internal/router/handlers/lang.go +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index c8c4cd4..0000000 --- a/internal/router/handlers/root.go +++ /dev/null @@ -1,47 +0,0 @@ -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-blog-title.go b/internal/router/lang-blog-title.go new file mode 100644 index 0000000..6e80671 --- /dev/null +++ b/internal/router/lang-blog-title.go @@ -0,0 +1,86 @@ +package router + +import ( + "bytes" + "fmt" + "html/template" + "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/internal/frontmatter" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" + "github.com/yuin/goldmark" +) + +func init() { + tm.Add("blog-page", "views/layouts/general-page.html", "views/pages/blog-page.html") +} + +func Lang_Blog_Title(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client, md goldmark.Markdown) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + ip := c.IP() + slog.Debug("client entering blog page", slog.String("ip", ip), slog.String("page", c.Path())) + + lang := c.Params("lang") + 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: + metadata, parsedMarkdown, err := readBlogPost(md, b2Client, lang+"/"+c.Params("title")) + if err != nil { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("failed to find '%s' post", c.Params("title"))) + } + + geolocationParts := strings.Split(metadata.Geolocation, " ") + var x, y, areaError string + if len(geolocationParts) >= 2 { + x = geolocationParts[0] + y = geolocationParts[1] + } + if len(geolocationParts) >= 3 { + areaError = geolocationParts[2] + } + + content, err := tm.Render("blog-page", fiber.Map{ + "Title": metadata.Title, + "PublishedDate": metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00"), + "PublishedYear": strconv.Itoa(metadata.PublishedTime.Year()), + "ParsedMarkdown": template.HTML(parsedMarkdown), + "MapLocationX": x, + "MapLocationY": y, + "MapLocationAreaMeters": areaError, + "Lang": lang, + "QueryString": string(c.Request().URI().QueryString()), + }) + if err != nil { + slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/blog/"+c.Params("title")), slog.String("error", err.Error())) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") + } + + return c.Type("html").Send(content) + } +} + +func readBlogPost(md goldmark.Markdown, b2Client *b2.B2Client, sourceName string) (metadata *frontmatter.Metadata, html string, err error) { + metadata, markdown, err := b2Client.ReadFrontmatter(sourceName + ".md") + if err != nil { + return nil, "", fmt.Errorf("failed to read a frontmatter file: %w", err) + } + + var buf bytes.Buffer + if err := md.Convert(markdown, &buf); err != nil { + return nil, "", fmt.Errorf("convert source context from md to html: %w", err) + } + + return metadata, buf.String(), nil +} diff --git a/internal/router/lang-blog.go b/internal/router/lang-blog.go new file mode 100644 index 0000000..960ac3d --- /dev/null +++ b/internal/router/lang-blog.go @@ -0,0 +1,98 @@ +package router + +import ( + "fmt" + "log/slog" + "net/url" + "regexp" + "slices" + "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() { + tm.Add("blog-catalogue", "views/layouts/general-page.html", "views/pages/blog-catalogue.html") +} + +func Lang_Blog(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: + querySort := c.Query("sort") + if querySort == "" { + querySort = "publicationDateDesc" + } + + encodedQuery := c.Request().URI().QueryString() + re, err := regexp.Compile(`tags\[\]=([\w]+)`) + if err != nil { + slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error())) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering") + } + decodedQuery, _ := url.QueryUnescape(string(encodedQuery)) + matches := re.FindAllStringSubmatch(decodedQuery, -1) + + queryTags := make([]string, 0, len(matches)) + for _, match := range matches { + queryTags = append(queryTags, string(match[1])) + } + + pages, err := b2Client.Scan(lang + "/") + status := fiber.StatusOK + if err != nil { + status = fiber.StatusPartialContent + pages = []*b2.BlogPage{} + } + + tagsMap := make(map[string]int) + for _, page := range pages { + for _, tag := range page.Metadata.Tags { + tagsMap[tag]++ + } + } + slog.Debug("enlist pages for catalogue", slog.Int("tag_count", len(tagsMap)), slog.Int("page_count", len(pages)), slog.String("path", c.Path())) + + type Tag struct { + Name string `json:"Name" yaml:"name"` + Count int `json:"Count" yaml:"count"` + } + + tagsArray := make([]Tag, 0, len(tagsMap)) + for tag, count := range tagsMap { + tagsArray = append(tagsArray, Tag{tag, count}) + } + slices.SortFunc(tagsArray, func(a Tag, b Tag) int { + return strings.Compare(a.Name, b.Name) + }) + + content, err := tm.Render("blog-catalogue", fiber.Map{ + "QuerySort": querySort, + "QueryTags": strings.Join(queryTags, ","), + "QueryString": string(c.Request().URI().QueryString()), + "Tags": tagsArray, + "Lang": lang, + "L": l[lang], + "PublishedYear": "2025", + "Title": l[lang].BlogSearch.Header, + }) + if err != nil { + slog.Warn("failed to generate page", slog.String("path", c.Path()), slog.String("error", err.Error())) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") + } + + return c.Type("html").Status(status).Send(content) + } +} diff --git a/internal/router/lang-map.go b/internal/router/lang-map.go new file mode 100644 index 0000000..a744f02 --- /dev/null +++ b/internal/router/lang-map.go @@ -0,0 +1,88 @@ +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() { + 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/page-cache.go b/internal/router/page-cache.go new file mode 100644 index 0000000..01e4792 --- /dev/null +++ b/internal/router/page-cache.go @@ -0,0 +1,5 @@ +package router + +import "github.com/dgraph-io/ristretto/v2" + +var PCache *ristretto.Cache[string, []byte] diff --git a/internal/router/path-matcher.go b/internal/router/path-matcher.go deleted file mode 100644 index c8aef08..0000000 --- a/internal/router/path-matcher.go +++ /dev/null @@ -1,48 +0,0 @@ -package router - -import ( - "github.com/gofiber/fiber/v2" - "github.com/valyala/fasthttp" -) - -type PathMatcher struct { - app *fiber.App -} - -func NewPathMatcher() *PathMatcher { - app := fiber.New(fiber.Config{ - DisableStartupMessage: true, - }) - return &PathMatcher{app: app} -} - -func (pm *PathMatcher) AddRoute(method, pattern string) { - pm.app.Add(method, pattern, func(c *fiber.Ctx) error { - c.Locals("pattern", pattern) - return nil - }) -} - -func (pm *PathMatcher) MatchPath(method, path string) (pattern string, params map[string]string, matched bool) { - fctx := &fasthttp.RequestCtx{} - fctx.Request.Header.SetMethod(method) - fctx.Request.SetRequestURI(path) - - ctx := pm.app.AcquireCtx(fctx) - defer pm.app.ReleaseCtx(ctx) - - pm.app.Handler()(fctx) - - if ctx.Route() == nil { - return "", nil, false - } - - pattern = ctx.Locals("pattern").(string) - - params = make(map[string]string) - for _, paramName := range ctx.Route().Params { - params[paramName] = ctx.Params(paramName) - } - - return pattern, params, true -} diff --git a/internal/router/root.go b/internal/router/root.go new file mode 100644 index 0000000..f4a0692 --- /dev/null +++ b/internal/router/root.go @@ -0,0 +1,27 @@ +package router + +import ( + "log/slog" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/gofiber/fiber/v2" +) + +func init() { + tm.Add("index", "views/index.html") +} + +func Root(localeCfg []config.AvailableLanguageConfig) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + content, err := tm.Render("index", fiber.Map{ + "AvailableLanguages": localeCfg, + }) + if err != nil { + slog.Warn("failed to generate page", slog.String("page", "/"), slog.String("error", err.Error())) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") + } + + return c.Type("html").Send(content) + } +} diff --git a/internal/router/router.go b/internal/router/router.go deleted file mode 100644 index ef3f063..0000000 --- a/internal/router/router.go +++ /dev/null @@ -1,559 +0,0 @@ -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 == fiber.StatusNoContent && len(content) != 0 { - statusCode = fiber.StatusOK - } - - if statusCode >= 200 && statusCode < 300 && route.ToCache() != Disabled { - go r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration()) - } - - c.Set(fiber.HeaderContentType, 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 new file mode 100644 index 0000000..e8095ee --- /dev/null +++ b/internal/router/tm.go @@ -0,0 +1,18 @@ +package router + +import ( + "log/slog" + "os" + + "github.com/SayaAndy/saya-today-web/internal/templatemanager" +) + +var tm = assert(templatemanager.NewTemplateManager()) + +func assert[T any](t T, err error) T { + if err != nil { + slog.Error("fail to initialize template manager", slog.String("error", err.Error())) + os.Exit(1) + } + return t +} diff --git a/internal/templatemanager/templatemanager.go b/internal/templatemanager/templatemanager.go index 75309e4..eebc297 100644 --- a/internal/templatemanager/templatemanager.go +++ b/internal/templatemanager/templatemanager.go @@ -31,7 +31,6 @@ var templateFuncMap = template.FuncMap{ } return items }, - "replace": strings.ReplaceAll, } func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager, error) { @@ -39,18 +38,10 @@ 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, @@ -65,16 +56,12 @@ 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 not found", name) + return nil, fmt.Errorf("template %s is 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() @@ -85,27 +72,20 @@ 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, mainTmpl, data) + err = tempTmpl.ExecuteTemplate(&buf, tmpl.Main, data) return buf.Bytes(), err } func (tm *TemplateManager) Add(name string, files ...string) error { - tmpl := template.New(name).Funcs(templateFuncMap) - if len(files) == 0 { - tm.templates[name] = templateManagerRender{ - Main: "", - Tmpl: tmpl, - } - return nil + return fmt.Errorf("you can't add template without any files") } + 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) @@ -115,6 +95,5 @@ func (tm *TemplateManager) Add(name string, files ...string) error { Main: filepath.Base(files[0]), Tmpl: tmpl, } - return nil } diff --git a/locale/localization.en.yaml b/locale/localization.en.yaml index c7f2df8..e93ab00 100644 --- a/locale/localization.en.yaml +++ b/locale/localization.en.yaml @@ -22,53 +22,6 @@ HomePage: Hymn2: "We've been expecting you!" Hymn3: 'You bring such joy in Sayasite!' Hymn4: 'No matter where you roam, know our love is true!~' -UnsubscribePage: - Header: 'Cancelling the Subscription' - UnsetCode: 'Your cancel code is not set. You just having a walk around the site?' - InvalidCode: "Your cancel code could not be verified. Maybe, the code expired after a day. Or maybe, you've already unsubscribed." - OnServerError: "We couldn't unsubscribe you, the problem is on our side! We will solve this problem as soon as possible." - Success: 'Cancelled your subscription successfully! We do hope we will engage you again someday.' Metadata: Published: 'Published' Action: 'Took place on' -Mail: - UnsubscribeFooter: 'If this letter got you in a bad mood, you can unsubscribe from my blog by {}this link{/}.' - VerifyEmail: - Subject: 'Verification Link for SAYA.TODAY is waiting for you!' - Welcome: 'Greetings!' - Intro: 'There are 2 ways for us to validate your profile:' - GotoLink: 'Go by this link:' - InputCode: "Or input this code on the personal page:" - IfRandom: 'If you are unsure what this letter this, you can ignore it, and also, if you are interested, you may visit my site :)' - NewPost: - Subject: 'A new post arrived at SAYA.TODAY!' - Intro: 'A new post came!' - CapturedOn: 'Captured on' -UserProfile: - Header: 'Personal Settings' - EmailHeader: 'E-Mail' - VerificationCodeHeader: 'Verification Code' - TypeEmail: 'Please type your email' - SendCodeButton: 'Send Code' - VerifyButton: 'Verify' - VerificationCodeSent: 'Verification code sent! Please check your e-mail. Your next verification attempt will be possible on {}.' - DelayTilVerification: 'You have already requested the verification before! Please wait until {} for a new verification request.' - FailedEmailRender: 'Failed to render the email status message, please ask administrator for a more detailed cause.' - VerificationCodeSendingError: 'Failed to send a verification code, please ask administrator for a more detailed cause.' - EmailAlreadyValidated: 'You already have this E-Mail validated.' - EmailTaken: 'This E-Mail is already taken.' - EmailEmpty: 'E-Mail is unset or empty.' - VerificationSuccess: 'Success! You are now able to subscribe with your new e-mail.' - VerificationFailed: 'Verification code is invalid, please ask administrator for a more detailed cause.' - VerificationEmpty: 'Verification code is unset or empty.' - SubscriptionHeader: 'Subscribe to...' - TagsNone: 'None' - TagsAll: 'All' - TagsSpecific: 'Specific' - TagDoesNotExist: 'The tag "{}" does not exist on the site.' - TagAlreadyAdded: 'The tag "{}" is already in your subscribed list.' - SaveButton: 'Save' - SubscribeInvalidType: 'Invalid subscription type.' - FailedToSubscribe: 'Failed to subscribe by new settings, please ask administrator for a more detailed cause.' - SubscribedSuccessfully: 'Successfully subscribed you by new settings!' - RefreshPage: 'Please refresh the profile page on the device, where you requested the code.' diff --git a/locale/localization.go b/locale/localization.go index c6467c4..7dbb554 100644 --- a/locale/localization.go +++ b/locale/localization.go @@ -8,14 +8,11 @@ import ( ) type LocaleConfig struct { - TagsLabel string `yaml:"TagsLabel" json:"TagsLabel"` - BlogSearch BlogSearchConfig `yaml:"BlogSearch" json:"BlogSearch"` - GlobalMap GlobalMapConfig `yaml:"GlobalMap" json:"GlobalMap"` - HomePage HomePageConfig `yaml:"HomePage" json:"HomePage"` - UnsubscribePage UnsubscribePageConfig `yaml:"UnsubscribePage" json:"UnsubscribePage"` - Metadata MetadataConfig `yaml:"Metadata" json:"Metadata"` - Mail MailConfig `yaml:"Mail" json:"Mail"` - UserProfile UserProfileConfig `yaml:"UserProfile" json:"UserProfile"` + TagsLabel string `yaml:"TagsLabel" json:"TagsLabel"` + BlogSearch BlogSearchConfig `yaml:"BlogSearch" json:"BlogSearch"` + GlobalMap GlobalMapConfig `yaml:"GlobalMap" json:"GlobalMap"` + HomePage HomePageConfig `yaml:"HomePage" json:"HomePage"` + Metadata MetadataConfig `yaml:"Metadata" json:"Metadata"` } type BlogSearchConfig struct { @@ -47,70 +44,11 @@ type HomePageConfig struct { Hymn4 string `yaml:"Hymn4" json:"Hymn4"` } -type UnsubscribePageConfig struct { - Header string `yaml:"Header" json:"Header"` - UnsetCode string `yaml:"UnsetCode" json:"UnsetCode"` - InvalidCode string `yaml:"InvalidCode" json:"InvalidCode"` - OnServerError string `yaml:"OnServerError" json:"OnServerError"` - Success string `yaml:"Success" json:"Success"` -} - type MetadataConfig struct { Action string `yaml:"Action" json:"Action"` Published string `yaml:"Published" json:"Published"` } -type MailConfig struct { - UnsubscribeFooter string `yaml:"UnsubscribeFooter" json:"UnsubscribeFooter"` - VerifyEmail VerifyEmailConfig `yaml:"VerifyEmail" json:"VerifyEmail"` - NewPost NewPostConfig `yaml:"NewPost" json:"NewPost"` -} - -type VerifyEmailConfig struct { - Subject string `yaml:"Subject" json:"Subject"` - Welcome string `yaml:"Welcome" json:"Welcome"` - Intro string `yaml:"Intro" json:"Intro"` - GotoLink string `yaml:"GotoLink" json:"GotoLink"` - InputCode string `yaml:"InputCode" json:"InputCode"` - IfRandom string `yaml:"IfRandom" json:"IfRandom"` -} - -type NewPostConfig struct { - Subject string `yaml:"Subject" json:"Subject"` - CapturedOn string `yaml:"CapturedOn" json:"CapturedOn"` - Intro string `yaml:"Intro" json:"Intro"` -} - -type UserProfileConfig struct { - Header string `yaml:"Header" json:"Header"` - EmailHeader string `yaml:"EmailHeader" json:"EmailHeader"` - VerificationCodeHeader string `yaml:"VerificationCodeHeader" json:"VerificationCodeHeader"` - TypeEmail string `yaml:"TypeEmail" json:"TypeEmail"` - SendCodeButton string `yaml:"SendCodeButton" json:"SendCodeButton"` - VerifyButton string `yaml:"VerifyButton" json:"VerifyButton"` - VerificationCodeSent string `yaml:"VerificationCodeSent" json:"VerificationCodeSent"` - DelayTilVerification string `yaml:"DelayTilVerification" json:"DelayTilVerification"` - FailedEmailRender string `yaml:"FailedEmailRender" json:"FailedEmailRender"` - VerificationCodeSendingError string `yaml:"VerificationCodeSendingError" json:"VerificationCodeSendingError"` - EmailAlreadyValidated string `yaml:"EmailAlreadyValidated" json:"EmailAlreadyValidated"` - EmailTaken string `yaml:"EmailTaken" json:"EmailTaken"` - EmailEmpty string `yaml:"EmailEmpty" json:"EmailEmpty"` - VerificationSuccess string `yaml:"VerificationSuccess" json:"VerificationSuccess"` - VerificationFailed string `yaml:"VerificationFailed" json:"VerificationFailed"` - VerificationEmpty string `yaml:"VerificationEmpty" json:"VerificationEmpty"` - SubscriptionHeader string `yaml:"SubscriptionHeader" json:"SubscriptionHeader"` - TagsNone string `yaml:"TagsNone" json:"TagsNone"` - TagsAll string `yaml:"TagsAll" json:"TagsAll"` - TagsSpecific string `yaml:"TagsSpecific" json:"TagsSpecific"` - TagDoesNotExist string `yaml:"TagDoesNotExist" json:"TagDoesNotExist"` - TagAlreadyAdded string `yaml:"TagAlreadyAdded" json:"TagAlreadyAdded"` - SaveButton string `yaml:"SaveButton" json:"SaveButton"` - SubscribeInvalidType string `yaml:"SubscribeInvalidType" json:"SubscribeInvalidType"` - FailedToSubscribe string `yaml:"FailedToSubscribe" json:"FailedToSubscribe"` - SubscribedSuccessfully string `yaml:"SubscribedSuccessfully" json:"SubscribedSuccessfully"` - RefreshPage string `yaml:"RefreshPage" json:"RefreshPage"` -} - func LoadConfig(path string, config *LocaleConfig) error { fileBytes, err := os.ReadFile(path) if err != nil { diff --git a/locale/localization.ru.yaml b/locale/localization.ru.yaml index 5df82da..a1a9a6c 100644 --- a/locale/localization.ru.yaml +++ b/locale/localization.ru.yaml @@ -22,53 +22,6 @@ HomePage: Hymn2: 'Мы Вас столько ждали!' Hymn3: 'Столько радости от Вас на Саясайте!' Hymn4: 'Где бы Вы не были, знайте, -- наша любовь крепка!' -UnsubscribePage: - Header: 'Отписка' - UnsetCode: 'Код для отписки не проставлен. Вы случайно не заблудились?' - InvalidCode: 'Код для отписки не подтверждён. Может, истекло время в сутки для кода. А может, вы уже успели отписаться.' - OnServerError: 'Мы не смогли вас отписать, проблема на нашей стороне! В скором времени займёмся проблемой.' - Success: 'Вы отписались от рассылки! Надеюсь, мы ещё сможем вас заинтересовать.' Metadata: Published: 'Опубликовано' Action: 'Время действия' -Mail: - UnsubscribeFooter: 'Если данное письмо пришло вам случайно, либо вы хотите отписаться, можете перейти по {}этой ссылке{/}.' - VerifyEmail: - Subject: 'Ссылка для верификации для подтверждения вашей почты SAYA.TODAY' - Welcome: 'Приветствую!' - Intro: 'Есть 2 способа, чтобы подтвердить свою электронную почту:' - GotoLink: 'Перейти по этой ссылке:' - InputCode: 'Либо ввести этот код на странице подписок:' - IfRandom: 'Если ты не понимаешь, что это за спам, можешь проигнорировать это письмо, а ещё, если интересно, можешь посетить мой сайт :)' - NewPost: - Subject: 'Новый пост на SAYA.TODAY!' - Intro: 'Вышел новый пост!' - CapturedOn: 'Снималось' -UserProfile: - Header: 'Личные настройки' - EmailHeader: 'E-Mail' - VerificationCodeHeader: 'Код верификации' - TypeEmail: 'Пожалуйста, введите свою почту' - SendCodeButton: 'Отправить код' - VerifyButton: 'Верифицировать' - VerificationCodeSent: 'Выслали вам код верификации! Проверьте свою почту. Следующая попытка верификации будет возможна {}.' - DelayTilVerification: 'Вы уже запрашивали недавно верификацию! Дождитесь {} для новой попытки.' - FailedEmailRender: 'Неудачная попытка создать электронное письмо, более подробно спрашивайте у администратора.' - VerificationCodeSendingError: 'Неудачная попытка прислать код для верификации, более подробно спрашивайте у администратора.' - EmailAlreadyValidated: 'Эту почту вы уже подтверждали.' - EmailTaken: 'Эта почта уже занята.' - EmailEmpty: 'Почта не введена.' - VerificationSuccess: 'Поздравляем! Теперь по этой почте можно подписаться на блог.' - VerificationFailed: 'Код для верификации невалиден, при вопросах, пожалуйста, обращайтесь к администратору.' - VerificationEmpty: 'Код для верификации не введён.' - SubscriptionHeader: 'Подписаться на...' - TagsNone: 'Ничего' - TagsAll: 'Всё' - TagsSpecific: 'Определённое' - TagDoesNotExist: 'Тэга "{}" нет на сайте.' - TagAlreadyAdded: 'Тэг "{}" уже у вас добавлен.' - SaveButton: 'Сохранить' - SubscribeInvalidType: 'Неизвестный тип подписки.' - FailedToSubscribe: 'Неудачная попытка записать новую подписку, более подробно спрашивайте у администратора.' - SubscribedSuccessfully: 'Вы успешно подписались по новым настройкам!' - RefreshPage: 'Пожалуйста, обновите страницу с профилем на устройстве, откуда запрашивали код.' @@ -1,6 +1,8 @@ package main import ( + "database/sql" + "errors" "flag" "log/slog" "os" @@ -8,15 +10,50 @@ import ( "syscall" "github.com/SayaAndy/saya-today-web/config" + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/internal/factgiver" + "github.com/SayaAndy/saya-today-web/internal/glightbox" "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 ( - configPath = flag.String("c", "config.yaml", "Path to the configuration file (in YAML format)") + 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) ) func main() { @@ -33,39 +70,119 @@ func main() { slog.SetLogLoggerLevel(cfg.LogLevel) slog.Info("starting sayana-web server...") - app, err := router.NewRouter(cfg) + db, err := sql.Open(cfg.Auth.Db.Type, cfg.Auth.Db.Cfg.DSN) if err != nil { - slog.Error("fail to initialize router app", slog.String("error", err.Error())) + slog.Error("fail to initialize db", slog.String("error", err.Error())) os.Exit(1) } - if err = app.InitRoutes(); err != nil { - slog.Error("fail to initialize routes inside router app", slog.String("error", err.Error())) + 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) } - routerShutdown := make(chan struct{}, 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())) + 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/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.Static("/", "./static") go func() { if err := app.Listen(":3000"); err != nil { slog.Error("error while running fiber server", slog.String("error", err.Error())) - if nestedErr := app.Close(); nestedErr != nil { - slog.Error("error while shutting down fiber server", slog.String("error", err.Error())) - } - <-routerShutdown + panic(err) } }() sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - 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...") + <-sigChan + slog.Info("gracefully shutting down...") + 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())) } + router.PCache.Close() + db.Close() } diff --git a/migrations/2_create_subscription_tables.down.sql b/migrations/2_create_subscription_tables.down.sql deleted file mode 100644 index d9a369e..0000000 --- a/migrations/2_create_subscription_tables.down.sql +++ /dev/null @@ -1,4 +0,0 @@ -DROP INDEX IF EXISTS user_email_table_email_uindex; -DROP TABLE IF EXISTS user_email_table; -DROP TABLE IF EXISTS subscription_user_to_tags_table; -DROP TABLE IF EXISTS subscription_tag_to_users_table; diff --git a/migrations/2_create_subscription_tables.up.sql b/migrations/2_create_subscription_tables.up.sql deleted file mode 100644 index e6e905a..0000000 --- a/migrations/2_create_subscription_tables.up.sql +++ /dev/null @@ -1,18 +0,0 @@ -CREATE TABLE IF NOT EXISTS user_email_table ( - user_id VARCHAR(32) NOT NULL PRIMARY KEY, - email VARCHAR(64) NOT NULL, - lang VARCHAR(2) NOT NULL -) WITHOUT ROWID; - -CREATE UNIQUE INDEX user_email_table_email_uindex -ON user_email_table(email); - -CREATE TABLE IF NOT EXISTS subscription_user_to_tags_table ( - user_id VARCHAR(32) NOT NULL PRIMARY KEY, - tags TEXT NOT NULL -) WITHOUT ROWID; - -CREATE TABLE IF NOT EXISTS subscription_tag_to_users_table ( - tag VARCHAR(32) NOT NULL PRIMARY KEY, - user_ids TEXT NOT NULL -) WITHOUT ROWID; diff --git a/static/input.css b/static/input.css index 739caa3..e153b41 100644 --- a/static/input.css +++ b/static/input.css @@ -9,7 +9,6 @@ html { font-size: 12px; - --crossed-out: url("data:image/svg+xml,<svg id='patternId' width='100%' height='100%' xmlns='http://www.w3.org/2000/svg'><defs><pattern id='a' patternUnits='userSpaceOnUse' width='30' height='30' patternTransform='scale(1) rotate(65)'><rect x='0' y='0' width='100%' height='100%' fill='%232b2b3100' /><path d='M30 15L22.5 30 15 15 22.5 0zm-15 0L7.5 30 0 15 7.5 0z' stroke-width='0.6' stroke='%23ffffffff' fill='none' /></pattern></defs><rect width='800%' height='800%' transform='translate(0,0)' fill='url(%23a)' /></svg>"); --noise-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAMAAAAp4XiDAAAAUVBMVEWFhYWDg4N3d3dtbW17e3t1dXWBgYGHh4d5eXlzc3OLi4ubm5uVlZWPj4+NjY19fX2JiYl/f39ra2uRkZGZmZlpaWmXl5dvb29xcXGTk5NnZ2c8TV1mAAAAG3RSTlNAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEAvEOwtAAAFVklEQVR4XpWWB67c2BUFb3g557T/hRo9/WUMZHlgr4Bg8Z4qQgQJlHI4A8SzFVrapvmTF9O7dmYRFZ60YiBhJRCgh1FYhiLAmdvX0CzTOpNE77ME0Zty/nWWzchDtiqrmQDeuv3powQ5ta2eN0FY0InkqDD73lT9c9lEzwUNqgFHs9VQce3TVClFCQrSTfOiYkVJQBmpbq2L6iZavPnAPcoU0dSw0SUTqz/GtrGuXfbyyBniKykOWQWGqwwMA7QiYAxi+IlPdqo+hYHnUt5ZPfnsHJyNiDtnpJyayNBkF6cWoYGAMY92U2hXHF/C1M8uP/ZtYdiuj26UdAdQQSXQErwSOMzt/XWRWAz5GuSBIkwG1H3FabJ2OsUOUhGC6tK4EMtJO0ttC6IBD3kM0ve0tJwMdSfjZo+EEISaeTr9P3wYrGjXqyC1krcKdhMpxEnt5JetoulscpyzhXN5FRpuPHvbeQaKxFAEB6EN+cYN6xD7RYGpXpNndMmZgM5Dcs3YSNFDHUo2LGfZuukSWyUYirJAdYbF3MfqEKmjM+I2EfhA94iG3L7uKrR+GdWD73ydlIB+6hgref1QTlmgmbM3/LeX5GI1Ux1RWpgxpLuZ2+I+IjzZ8wqE4nilvQdkUdfhzI5QDWy+kw5Wgg2pGpeEVeCCA7b85BO3F9DzxB3cdqvBzWcmzbyMiqhzuYqtHRVG2y4x+KOlnyqla8AoWWpuBoYRxzXrfKuILl6SfiWCbjxoZJUaCBj1CjH7GIaDbc9kqBY3W/Rgjda1iqQcOJu2WW+76pZC9QG7M00dffe9hNnseupFL53r8F7YHSwJWUKP2q+k7RdsxyOB11n0xtOvnW4irMMFNV4H0uqwS5ExsmP9AxbDTc9JwgneAT5vTiUSm1E7BSflSt3bfa1tv8Di3R8n3Af7MNWzs49hmauE2wP+ttrq+AsWpFG2awvsuOqbipWHgtuvuaAE+A1Z/7gC9hesnr+7wqCwG8c5yAg3AL1fm8T9AZtp/bbJGwl1pNrE7RuOX7PeMRUERVaPpEs+yqeoSmuOlokqw49pgomjLeh7icHNlG19yjs6XXOMedYm5xH2YxpV2tc0Ro2jJfxC50ApuxGob7lMsxfTbeUv07TyYxpeLucEH1gNd4IKH2LAg5TdVhlCafZvpskfncCfx8pOhJzd76bJWeYFnFciwcYfubRc12Ip/ppIhA1/mSZ/RxjFDrJC5xifFjJpY2Xl5zXdguFqYyTR1zSp1Y9p+tktDYYSNflcxI0iyO4TPBdlRcpeqjK/piF5bklq77VSEaA+z8qmJTFzIWiitbnzR794USKBUaT0NTEsVjZqLaFVqJoPN9ODG70IPbfBHKK+/q/AWR0tJzYHRULOa4MP+W/HfGadZUbfw177G7j/OGbIs8TahLyynl4X4RinF793Oz+BU0saXtUHrVBFT/DnA3ctNPoGbs4hRIjTok8i+algT1lTHi4SxFvONKNrgQFAq2/gFnWMXgwffgYMJpiKYkmW3tTg3ZQ9Jq+f8XN+A5eeUKHWvJWJ2sgJ1Sop+wwhqFVijqWaJhwtD8MNlSBeWNNWTa5Z5kPZw5+LbVT99wqTdx29lMUH4OIG/D86ruKEauBjvH5xy6um/Sfj7ei6UUVk4AIl3MyD4MSSTOFgSwsH/QJWaQ5as7ZcmgBZkzjjU1UrQ74ci1gWBCSGHtuV1H2mhSnO3Wp/3fEV5a+4wz//6qy8JxjZsmxxy5+4w9CDNJY09T072iKG0EnOS0arEYgXqYnXcYHwjTtUNAcMelOd4xpkoqiTYICWFq0JSiPfPDQdnt+4/wuqcXY47QILbgAAAABJRU5ErkJggg==); --split-left-background: unset; --split-left-repeat: no-repeat; @@ -146,10 +145,10 @@ html { --leaflet-text-color: var(--main-hard-color); --leaflet-link-color: var(--main-medium-color); --leaflet-link-hover-color: var(--main-soft-color); - --interlocked-hexagons-background: url("data:image/svg+xml,<svg width='160' height='160' xmlns='http://www.w3.org/2000/svg'><defs><pattern id='a' patternUnits='userSpaceOnUse' width='40' height='80' patternTransform='scale(2) rotate(0)'><rect x='0' y='0' width='100%' height='100%' fill='%23e3e6ffff'/><path d='M-10 7.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%238c4351ff' fill='none'/><path d='M-10 27.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23385f0dff' fill='none'/><path d='M-10 47.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23006c86ff' fill='none'/><path d='M-10 67.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%235a3e8eff' fill='none'/></pattern></defs><rect width='800%' height='800%' transform='translate(0,0)' fill='url(%23a)'/></svg>"); + --interlocked-hexagons-background: url("data:image/svg+xml,<svg id='patternId' width='100%' height='100%' xmlns='http://www.w3.org/2000/svg'><defs><pattern id='a' patternUnits='userSpaceOnUse' width='40' height='80' patternTransform='scale(2) rotate(0)'><rect x='0' y='0' width='100%' height='100%' fill='%23e3e6ffff'/><path d='M-10 7.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%238c4351ff' fill='none'/><path d='M-10 27.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23385f0dff' fill='none'/><path d='M-10 47.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23006c86ff' fill='none'/><path d='M-10 67.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%235a3e8eff' fill='none'/></pattern></defs><rect width='800%' height='800%' transform='translate(0,0)' fill='url(%23a)'/></svg>"); --interlocked-hexagons-background-repeat: repeat; --interlocked-hexagons-background-size: auto; - --squares-and-triangles-background: url("data:image/svg+xml,<svg width='160' height='160' xmlns='http://www.w3.org/2000/svg'><defs><pattern id='a' patternUnits='userSpaceOnUse' width='40' height='80' patternTransform='scale(2) rotate(0)'><rect x='0' y='0' width='100%' height='100%' fill='%23e3e6ffff'/><path d='M-10 7.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%238c4351ff' fill='none'/><path d='M-10 27.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23385f0dff' fill='none'/><path d='M-10 47.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23006c86ff' fill='none'/><path d='M-10 67.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%235a3e8eff' fill='none'/></pattern></defs><rect width='800%' height='800%' transform='translate(0,0)' fill='url(%23a)'/></svg>"); + --squares-and-triangles-background: url("data:image/svg+xml,<svg id='patternId' width='100%' height='100%' xmlns='http://www.w3.org/2000/svg'><defs><pattern id='a' patternUnits='userSpaceOnUse' width='40' height='80' patternTransform='scale(2) rotate(0)'><rect x='0' y='0' width='100%' height='100%' fill='%23e3e6ffff'/><path d='M-10 7.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%238c4351ff' fill='none'/><path d='M-10 27.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23385f0dff' fill='none'/><path d='M-10 47.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23006c86ff' fill='none'/><path d='M-10 67.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%235a3e8eff' fill='none'/></pattern></defs><rect width='800%' height='800%' transform='translate(0,0)' fill='url(%23a)'/></svg>"); --squares-and-triangles-background-repeat: repeat; --squares-and-triangles-background-width-coef: 5; --squares-and-triangles-background-height-coef: 5; @@ -180,27 +179,6 @@ a:hover, .linklike:hover { text-decoration: underline; } -.crossable.disabled::before, -.crossable.htmx-request::before { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: var(--main-hard-color); - background-size: 0.5rem 0.5rem; - mask-image: var(--crossed-out); - opacity: 1; - transition: opacity 0.3s; - z-index: 25; -} - -form.crossable.disabled input, -form.crossable.htmx-request input { - filter: grayscale(1); -} - .bg-interlocked-hexagons { background-image: var(--interlocked-hexagons-background); background-repeat: var(--interlocked-hexagons-background-repeat); @@ -467,18 +445,12 @@ form.crossable.htmx-request input { border-color: transparent transparent var(--background-dark-color) transparent; } -.grid-item { +.grid-item, .grid-sizer { position: relative; display: inline-block; width: 10rem; } -.grid-sizer { - position: relative; - display: inline-block; - width: 10.1rem; -} - .grid-item-index { visibility: visible; opacity: 1; @@ -503,42 +475,30 @@ form.crossable.htmx-request input { } @container main (width >= 40rem) { - .grid-item { + .grid-item, .grid-sizer { width: 12rem; } - .grid-sizer { - width: 12.1rem; - } - .grid-item-2x { width: 24rem; } } @container main (width >= 76rem) { - .grid-item { + .grid-item, .grid-sizer { width: 18rem; } - .grid-sizer { - width: 18.1rem; - } - .grid-item-2x { width: 36rem; } } @container main (width >= 128rem) { - .grid-item { + .grid-item, .grid-sizer { width: 24rem; } - .grid-sizer { - width: 24.1rem; - } - .grid-item-2x { width: 48rem; } diff --git a/views/layouts/general-mail.html b/views/layouts/general-mail.html deleted file mode 100644 index 8f42c7a..0000000 --- a/views/layouts/general-mail.html +++ /dev/null @@ -1,105 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> - <meta charset="UTF-8"> - <head> - <style> -@font-face { - font-family: 'Philosopher'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(https://fonts.gstatic.com/s/philosopher/v21/vEFV2_5QCwIS4_Dhez5jcWBuT0020Nqf.woff2) format('woff2'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -@font-face { - font-family: 'Philosopher'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(https://fonts.gstatic.com/s/philosopher/v21/vEFV2_5QCwIS4_Dhez5jcWBqT0020NqfZ7c.woff2) format('woff2'); - unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; -} - -html { - font-family: 'Philosopher', sans-serif; - --noise-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAMAAAAp4XiDAAAAUVBMVEWFhYWDg4N3d3dtbW17e3t1dXWBgYGHh4d5eXlzc3OLi4ubm5uVlZWPj4+NjY19fX2JiYl/f39ra2uRkZGZmZlpaWmXl5dvb29xcXGTk5NnZ2c8TV1mAAAAG3RSTlNAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEAvEOwtAAAFVklEQVR4XpWWB67c2BUFb3g557T/hRo9/WUMZHlgr4Bg8Z4qQgQJlHI4A8SzFVrapvmTF9O7dmYRFZ60YiBhJRCgh1FYhiLAmdvX0CzTOpNE77ME0Zty/nWWzchDtiqrmQDeuv3powQ5ta2eN0FY0InkqDD73lT9c9lEzwUNqgFHs9VQce3TVClFCQrSTfOiYkVJQBmpbq2L6iZavPnAPcoU0dSw0SUTqz/GtrGuXfbyyBniKykOWQWGqwwMA7QiYAxi+IlPdqo+hYHnUt5ZPfnsHJyNiDtnpJyayNBkF6cWoYGAMY92U2hXHF/C1M8uP/ZtYdiuj26UdAdQQSXQErwSOMzt/XWRWAz5GuSBIkwG1H3FabJ2OsUOUhGC6tK4EMtJO0ttC6IBD3kM0ve0tJwMdSfjZo+EEISaeTr9P3wYrGjXqyC1krcKdhMpxEnt5JetoulscpyzhXN5FRpuPHvbeQaKxFAEB6EN+cYN6xD7RYGpXpNndMmZgM5Dcs3YSNFDHUo2LGfZuukSWyUYirJAdYbF3MfqEKmjM+I2EfhA94iG3L7uKrR+GdWD73ydlIB+6hgref1QTlmgmbM3/LeX5GI1Ux1RWpgxpLuZ2+I+IjzZ8wqE4nilvQdkUdfhzI5QDWy+kw5Wgg2pGpeEVeCCA7b85BO3F9DzxB3cdqvBzWcmzbyMiqhzuYqtHRVG2y4x+KOlnyqla8AoWWpuBoYRxzXrfKuILl6SfiWCbjxoZJUaCBj1CjH7GIaDbc9kqBY3W/Rgjda1iqQcOJu2WW+76pZC9QG7M00dffe9hNnseupFL53r8F7YHSwJWUKP2q+k7RdsxyOB11n0xtOvnW4irMMFNV4H0uqwS5ExsmP9AxbDTc9JwgneAT5vTiUSm1E7BSflSt3bfa1tv8Di3R8n3Af7MNWzs49hmauE2wP+ttrq+AsWpFG2awvsuOqbipWHgtuvuaAE+A1Z/7gC9hesnr+7wqCwG8c5yAg3AL1fm8T9AZtp/bbJGwl1pNrE7RuOX7PeMRUERVaPpEs+yqeoSmuOlokqw49pgomjLeh7icHNlG19yjs6XXOMedYm5xH2YxpV2tc0Ro2jJfxC50ApuxGob7lMsxfTbeUv07TyYxpeLucEH1gNd4IKH2LAg5TdVhlCafZvpskfncCfx8pOhJzd76bJWeYFnFciwcYfubRc12Ip/ppIhA1/mSZ/RxjFDrJC5xifFjJpY2Xl5zXdguFqYyTR1zSp1Y9p+tktDYYSNflcxI0iyO4TPBdlRcpeqjK/piF5bklq77VSEaA+z8qmJTFzIWiitbnzR794USKBUaT0NTEsVjZqLaFVqJoPN9ODG70IPbfBHKK+/q/AWR0tJzYHRULOa4MP+W/HfGadZUbfw177G7j/OGbIs8TahLyynl4X4RinF793Oz+BU0saXtUHrVBFT/DnA3ctNPoGbs4hRIjTok8i+algT1lTHi4SxFvONKNrgQFAq2/gFnWMXgwffgYMJpiKYkmW3tTg3ZQ9Jq+f8XN+A5eeUKHWvJWJ2sgJ1Sop+wwhqFVijqWaJhwtD8MNlSBeWNNWTa5Z5kPZw5+LbVT99wqTdx29lMUH4OIG/D86ruKEauBjvH5xy6um/Sfj7ei6UUVk4AIl3MyD4MSSTOFgSwsH/QJWaQ5as7ZcmgBZkzjjU1UrQ74ci1gWBCSGHtuV1H2mhSnO3Wp/3fEV5a+4wz//6qy8JxjZsmxxy5+4w9CDNJY09T072iKG0EnOS0arEYgXqYnXcYHwjTtUNAcMelOd4xpkoqiTYICWFq0JSiPfPDQdnt+4/wuqcXY47QILbgAAAABJRU5ErkJggg==); -} - -body { - background-color: #e3e6ff; -} - -a { - text-decoration: underline; - text-shadow: none !important; -} - -.header { - width: 100%; - padding: 8px 8px 8px 32px; - font-size: 48px; - font-family: 'Philosopher'; - color: white; - text-shadow: -2px 0 #343b58, 0 2px #343b58, 2px 0 #343b58, 0 -2px #343b58; - background: url("data:image/svg+xml,<svg width='160' height='160' xmlns='http://www.w3.org/2000/svg'><defs><pattern id='a' patternUnits='userSpaceOnUse' width='40' height='80' patternTransform='scale(2) rotate(0)'><rect x='0' y='0' width='100%' height='100%' fill='%23e3e6ffff'/><path d='M-10 7.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%238c4351ff' fill='none'/><path d='M-10 27.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23385f0dff' fill='none'/><path d='M-10 47.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%23006c86ff' fill='none'/><path d='M-10 67.5l20 5 20-5 20 5' stroke-linejoin='round' stroke-linecap='round' stroke-width='4' stroke='%235a3e8eff' fill='none'/></pattern></defs><rect width='800%' height='800%' transform='translate(0,0)' fill='url(%23a)'/></svg>"); - background-color: #e3e6ff; - background-size: contain; - background-repeat: repeat; -} - -.content { - margin: 0 32px 0 32px; - padding: 16px 16px 32px 16px; - font-size: 24px; - text-shadow: -1px 0 #343b58, 0 1px #343b58, 1px 0 #343b58, 0 -1px #343b58; - color: white; - background-color: #c3caff; - background-image: var(--noise-image); -} - -.footer { - margin: 0; - padding: 1px 8px 1px 8px; - font-size: 16px; - color: white; -} - -.darkened { - background-color: #9aa5f4; - background-image: var(--noise-image); -} - -.outlier { - padding-inline: 4px; - background-color: #6c6e75; - background-image: var(--noise-image); -} - -table, th, td { - border: 1px solid black; -} - -td { - padding: 5px; -} - -img { - width: 200px; -} - </style> - </head> - - <body> - <div id="header" class="header"> - saya.today - </div> - - <div id="content" class="content" style="background-color: #e3e6ff !important;"> - {{ block "body" . }}{{ end }} - </div> - - <div id="footer" class="footer darkened"> - {{ block "footer" . }}{{ end }} - </div> - </body> -</html> diff --git a/views/layouts/general-page.html b/views/layouts/general-page.html index 62360ad..920640e 100644 --- a/views/layouts/general-page.html +++ b/views/layouts/general-page.html @@ -9,7 +9,7 @@ <link href="/output.css" rel="stylesheet"> </head> -<body data-theme="ram" class="font-andika text-main-hard overflow-hidden bg-interlocked-hexagons h-dvh flex flex-row"> +<body data-theme="ram" class="font-andika text-main-hard overflow-hidden bg-interlocked-hexagons h-[100dvh] flex flex-row"> <script> let map = null; @@ -156,7 +156,7 @@ switchThemeDecor(theme); } - const savedTheme = localStorage.getItem('theme') || 'lilac'; + const savedTheme = localStorage.getItem('theme') || 'ram'; switchTheme(savedTheme); </script> @@ -172,23 +172,20 @@ <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-20 h-20 [@media(max-height:32rem)]:h-dvh shadow-[0_0_1rem_black]"> + <div id="sidebar" class="bg-sidebar flex sticky flex-col z-20 w-[100dvw] 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-[100dvh] 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-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-20 h-20 hidden"> + <a href="/{{ .Lang }}" id="sidebar-home-button" class="w-[5rem] h-[5rem]"> <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-20 h-20 hidden"> + <a href="/{{ .Lang }}/blog" id="sidebar-blog-button" class="w-[5rem] h-[5rem]"> <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-20 h-20 hidden"> + <a href="/{{ .Lang }}/map" id="sidebar-map-button" class="w-[5rem] h-[5rem]"> <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-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> + <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="./" id="sidebar-back-button" class="w-[5rem] h-[5rem]"> + <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> </div> <div class="theme-wheel" id="theme-wheel"> @@ -204,7 +201,6 @@ sidebarMapButton = document.getElementById("sidebar-map-button"); sidebarBlogButton = document.getElementById("sidebar-blog-button"); sidebarBackButton = document.getElementById("sidebar-back-button"); - sidebarUserButton = document.getElementById("sidebar-user-button"); function updateSidebar() { if (location.pathname == "" || location.pathname == "/") { @@ -212,21 +208,17 @@ sidebarMapButton.classList.add('hidden'); sidebarBlogButton.classList.add('hidden'); sidebarBackButton.classList.add('hidden'); - sidebarUserButton.classList.add('hidden'); } else { sidebarHomeButton.classList.remove('hidden'); sidebarMapButton.classList.remove('hidden'); sidebarBlogButton.classList.remove('hidden'); sidebarBackButton.classList.remove('hidden'); - sidebarUserButton.classList.remove('hidden'); lang = location.pathname.split("/")[1]; sidebarHomeButton.setAttribute('href', `/${lang}`); sidebarHomeButton.firstElementChild.setAttribute('onclick', `return changeUrl('/${lang}', false);`); sidebarMapButton.setAttribute('href', `/${lang}/map`); sidebarBlogButton.setAttribute('href', `/${lang}/blog`); sidebarBlogButton.firstElementChild.setAttribute('onclick', `return changeUrl('/${lang}/blog', false);`); - sidebarUserButton.setAttribute('href', `/${lang}/user`); - sidebarUserButton.firstElementChild.setAttribute('onclick', `return changeUrl('/${lang}/user', false);`); } } window.addEventListener("popstate", updateSidebar); @@ -234,7 +226,7 @@ </script> </div> - <div class="bg-background-dark @container/main flex z-10 w-dvw xs:w-[90dvw] sm:w-[80dvw] md:w-[75dvw] lg:w-[70dvw] xl:w-[65dvw] 2xl:w-[60dvw] grow-0 h-[calc(100dvh-5rem)] [@media(max-height:32rem)]:h-dvh shadow-[0_0_1rem_black]"> + <div class="bg-background-dark @container/main flex z-10 w-[100dvw] xs:w-[90dvw] sm:w-[80dvw] md:w-[75dvw] lg:w-[70dvw] xl:w-[65dvw] 2xl:w-[60dvw] grow-0 h-[calc(100dvh-5rem)] [@media(max-height:32rem)]:h-[100dvh] shadow-[0_0_1rem_black]"> <div class="bg-background-dark border-r-2 border-dashed border-main-hard"></div> <div class="max-h-100% flex flex-col grow bg-background-dark relative pt-4 ml-8 pr-4"> diff --git a/views/messages/new-post.html b/views/messages/new-post.html deleted file mode 100644 index f1338f7..0000000 --- a/views/messages/new-post.html +++ /dev/null @@ -1,19 +0,0 @@ -{{ define "body" }} -<p>{{ .L.Mail.NewPost.Intro }}</p> -<table> - <tr> - <td rowspan="3"><a href="https://{{ .ClientHost }}/{{ .Lang }}/blog/{{ .Post.FileName }}"><img src="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/{{ .Post.Metadata.Thumbnail }}.webp"></a></td> - <td class="darkened" style="font-size: 24px"><a style="color: #273de1 !important;" href="https://{{ .ClientHost }}/{{ .Lang }}/blog/{{ .Post.FileName }}">{{ .Post.Metadata.Title }}</a></td> - </tr> - <tr> - <td class="darkened" style="font-size: 16px">{{ .Post.Metadata.ShortDescription }}</td> - </tr> - <tr> - <td class="darkened" style="font-size: 16px">{{ .L.Mail.NewPost.CapturedOn }} {{ .Post.Metadata.ActionDate }}</td> - </tr> -</table> -{{ end }} - -{{ define "footer" }} -<p>{{ .UnsubscribeFooter }}</p> -{{ end }} diff --git a/views/messages/verify-email.html b/views/messages/verify-email.html deleted file mode 100644 index dd6ef33..0000000 --- a/views/messages/verify-email.html +++ /dev/null @@ -1,10 +0,0 @@ -{{ define "body" }} -<p>{{ .L.Mail.VerifyEmail.Welcome }}</p> -<p>{{ .L.Mail.VerifyEmail.Intro }}</p> -<ol> - <li>{{ .L.Mail.VerifyEmail.GotoLink }} <a href="https://{{ .ClientHost }}/{{ .Lang }}/user?email_code={{ .VerificationCode }}" class="outlier">>>></a></li> - <li>{{ .L.Mail.VerifyEmail.InputCode }} <span class="outlier">{{ .VerificationCode }}</span><span></li> -</ol> -<p>{{ .L.Mail.VerifyEmail.IfRandom }}</p> -<a href="https://{{ .ClientHost }}" class="outlier">{{ .ClientHost }}</a> -{{ end }}
\ No newline at end of file diff --git a/views/pages/blog-catalogue.html b/views/pages/blog-catalogue.html index d209205..8e91798 100644 --- a/views/pages/blog-catalogue.html +++ b/views/pages/blog-catalogue.html @@ -1,10 +1,10 @@ {{ define "body" }} -<div class="flex flex-row ar-lt-0.8:flex-col text-base h-full"> +<div class="flex flex-row ar-lt-0.8:flex-col text-base h-[100%]"> <form hx-get="/api/v1/blog-search" hx-vals='{"lang": "{{ .Lang }}"}' hx-target=".blog-cards" hx-swap="innerHTML" - class="tags-list ar-gt-0.8:min-w-[10dvh] ar-gt-0.8:max-w-[20dvh] ar-gt-0.8:w-fit ar-lt-0.8:w-full ar-lt-0.8:max-h-[30%] flex shrink-0 flex-col ar-gt-0.8:mr-6 bg-background-light inset-shadow-[0_0_0.4rem_black]"> + class="tags-list ar-gt-0.8:min-w-[10dvh] ar-gt-0.8:max-w-[20dvh] ar-gt-0.8:w-fit ar-lt-0.8:w-[100%] ar-lt-0.8:max-h-[30%] flex shrink-0 flex-col ar-gt-0.8:mr-6 bg-background-light inset-shadow-[0_0_0.4rem_black]"> <div class="flex flex-col overflow-y-auto"> <fieldset> - <legend class="font-bold w-full text-center">{{ .L.BlogSearch.TagsHeader }}</legend> + <legend class="font-bold w-[100%] text-center">{{ .L.BlogSearch.TagsHeader }}</legend> <div class="flex flex-col ar-lt-0.8:grid grid-cols-3 xs:grid-cols-4 sm:grid-cols-5 grid-flow-row-dense"> <div class="m-1"> <label class="font-bold"><input type="checkbox" id="tagsAllCheckbox" onclick="selectAll();"> {{ .L.BlogSearch.ChooseAllTags }}</label> @@ -17,7 +17,7 @@ </div> </fieldset> <fieldset class="mt-1"> - <legend class="font-bold w-full text-center">{{ .L.BlogSearch.OrderByHeader }}</legend> + <legend class="font-bold w-[100%] text-center">{{ .L.BlogSearch.OrderByHeader }}</legend> <div class="flex flex-col ar-lt-0.8:grid grid-cols-3 grid-rows-2 grid-flow-col"> <div class="m-1"> <label><input type="radio" id="sortTitleAsc" name="sort" value="titleAsc" {{ if eq .QuerySort "titleAsc" }}checked{{ end }}> {{ .L.BlogSearch.TitleOrdered }} <i class="fas fa-arrow-down-a-z"></i></label> diff --git a/views/pages/global-map.html b/views/pages/global-map.html index 306f442..b9d2472 100644 --- a/views/pages/global-map.html +++ b/views/pages/global-map.html @@ -12,7 +12,7 @@ <link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet.markercluster/1.4.1/MarkerCluster.Default.css"> </head> -<body data-theme="ram" class="font-andika text-main-hard overflow-hidden bg-interlocked-hexagons h-dvh"> +<body data-theme="ram" class="font-andika text-main-hard overflow-hidden bg-interlocked-hexagons h-[100dvh]"> <script> function switchTheme(theme) { @@ -30,7 +30,7 @@ document.body.setAttribute('data-theme', theme); } - const savedTheme = localStorage.getItem('theme') || 'lilac'; + const savedTheme = localStorage.getItem('theme') || 'ram'; switchTheme(savedTheme); </script> @@ -44,9 +44,6 @@ <div id="sidebar" class="bg-sidebar block fixed shadow-[0_0_0.4rem_black] z-1001 w-[5rem] h-auto"> <div class="text-[3rem] text-stroke-(--sidebar-stroke-color) text-stroke-[0.1dvw] flex flex-col gap-0 relative z-1001"> - <a href="./" class="w-[5rem] h-[5rem]"> - <i 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 }}" class="w-[5rem] h-[5rem]"> <i 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> @@ -57,8 +54,8 @@ <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="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]"> - <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 href="./" class="w-[5rem] h-[5rem]"> + <i 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> </div> <div class="theme-wheel" id="theme-wheel"> diff --git a/views/pages/unsubscribe-page.html b/views/pages/unsubscribe-page.html deleted file mode 100644 index 3876818..0000000 --- a/views/pages/unsubscribe-page.html +++ /dev/null @@ -1,30 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{{ .L.UnsubscribePage.Header }} // SAYA TODAY</title> -<style> - html { - --noise-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAMAAAAp4XiDAAAAUVBMVEWFhYWDg4N3d3dtbW17e3t1dXWBgYGHh4d5eXlzc3OLi4ubm5uVlZWPj4+NjY19fX2JiYl/f39ra2uRkZGZmZlpaWmXl5dvb29xcXGTk5NnZ2c8TV1mAAAAG3RSTlNAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEAvEOwtAAAFVklEQVR4XpWWB67c2BUFb3g557T/hRo9/WUMZHlgr4Bg8Z4qQgQJlHI4A8SzFVrapvmTF9O7dmYRFZ60YiBhJRCgh1FYhiLAmdvX0CzTOpNE77ME0Zty/nWWzchDtiqrmQDeuv3powQ5ta2eN0FY0InkqDD73lT9c9lEzwUNqgFHs9VQce3TVClFCQrSTfOiYkVJQBmpbq2L6iZavPnAPcoU0dSw0SUTqz/GtrGuXfbyyBniKykOWQWGqwwMA7QiYAxi+IlPdqo+hYHnUt5ZPfnsHJyNiDtnpJyayNBkF6cWoYGAMY92U2hXHF/C1M8uP/ZtYdiuj26UdAdQQSXQErwSOMzt/XWRWAz5GuSBIkwG1H3FabJ2OsUOUhGC6tK4EMtJO0ttC6IBD3kM0ve0tJwMdSfjZo+EEISaeTr9P3wYrGjXqyC1krcKdhMpxEnt5JetoulscpyzhXN5FRpuPHvbeQaKxFAEB6EN+cYN6xD7RYGpXpNndMmZgM5Dcs3YSNFDHUo2LGfZuukSWyUYirJAdYbF3MfqEKmjM+I2EfhA94iG3L7uKrR+GdWD73ydlIB+6hgref1QTlmgmbM3/LeX5GI1Ux1RWpgxpLuZ2+I+IjzZ8wqE4nilvQdkUdfhzI5QDWy+kw5Wgg2pGpeEVeCCA7b85BO3F9DzxB3cdqvBzWcmzbyMiqhzuYqtHRVG2y4x+KOlnyqla8AoWWpuBoYRxzXrfKuILl6SfiWCbjxoZJUaCBj1CjH7GIaDbc9kqBY3W/Rgjda1iqQcOJu2WW+76pZC9QG7M00dffe9hNnseupFL53r8F7YHSwJWUKP2q+k7RdsxyOB11n0xtOvnW4irMMFNV4H0uqwS5ExsmP9AxbDTc9JwgneAT5vTiUSm1E7BSflSt3bfa1tv8Di3R8n3Af7MNWzs49hmauE2wP+ttrq+AsWpFG2awvsuOqbipWHgtuvuaAE+A1Z/7gC9hesnr+7wqCwG8c5yAg3AL1fm8T9AZtp/bbJGwl1pNrE7RuOX7PeMRUERVaPpEs+yqeoSmuOlokqw49pgomjLeh7icHNlG19yjs6XXOMedYm5xH2YxpV2tc0Ro2jJfxC50ApuxGob7lMsxfTbeUv07TyYxpeLucEH1gNd4IKH2LAg5TdVhlCafZvpskfncCfx8pOhJzd76bJWeYFnFciwcYfubRc12Ip/ppIhA1/mSZ/RxjFDrJC5xifFjJpY2Xl5zXdguFqYyTR1zSp1Y9p+tktDYYSNflcxI0iyO4TPBdlRcpeqjK/piF5bklq77VSEaA+z8qmJTFzIWiitbnzR794USKBUaT0NTEsVjZqLaFVqJoPN9ODG70IPbfBHKK+/q/AWR0tJzYHRULOa4MP+W/HfGadZUbfw177G7j/OGbIs8TahLyynl4X4RinF793Oz+BU0saXtUHrVBFT/DnA3ctNPoGbs4hRIjTok8i+algT1lTHi4SxFvONKNrgQFAq2/gFnWMXgwffgYMJpiKYkmW3tTg3ZQ9Jq+f8XN+A5eeUKHWvJWJ2sgJ1Sop+wwhqFVijqWaJhwtD8MNlSBeWNNWTa5Z5kPZw5+LbVT99wqTdx29lMUH4OIG/D86ruKEauBjvH5xy6um/Sfj7ei6UUVk4AIl3MyD4MSSTOFgSwsH/QJWaQ5as7ZcmgBZkzjjU1UrQ74ci1gWBCSGHtuV1H2mhSnO3Wp/3fEV5a+4wz//6qy8JxjZsmxxy5+4w9CDNJY09T072iKG0EnOS0arEYgXqYnXcYHwjTtUNAcMelOd4xpkoqiTYICWFq0JSiPfPDQdnt+4/wuqcXY47QILbgAAAABJRU5ErkJggg==); - } -</style> -</head> - -<body style="width: 100dvw; height: 100dvh; display: flex; flex-direction: column; position: fixed"> - - <div style='flex: 3 0 0; background-image: var(--noise-image); background-color: rgba({{ .StatusColor }}, 0.1)'></div> - - <div style="flex: 1 0 0; background-image: var(--noise-image); align-content: center;"> - <p style="position: relative; width: 100%; margin-inline: auto; text-align: center; font-size: 2rem;">{{ .StatusEmoji }}</p> - </div> - - <div style="flex: 1 0 0; background-image: var(--noise-image); align-content: center;"> - <p style="position: relative; width: 100%; margin-inline: auto; text-align: center; font-size: 1.5rem;">{{ .StatusText }}</p> - </div> - - <div style='flex: 3 0 0; background-image: var(--noise-image); background-color: rgba({{ .StatusColor }}, 0.2)'></div> - -</body> - -</html> diff --git a/views/pages/user-page.html b/views/pages/user-page.html deleted file mode 100644 index 7d39825..0000000 --- a/views/pages/user-page.html +++ /dev/null @@ -1,205 +0,0 @@ -{{ define "body" }} -<div class="bg-background-light flex flex-col inset-shadow-[0_0_0.4rem_black] px-8 py-4"> - <form id="email-form" class="w-full mb-2 flex flex-col relative crossable" - hx-get="/api/v1/email/is-in-verification" hx-target="#email-message" hx-swap="outerHTML" hx-trigger="load" hx-indicator="this"> - <div class="flex flex-row w-full mb-4 z-21"> - <div class="flex flex-col w-full sm:flex-3/5 grow-0"> - <label class="block text-main-medium font-bold mb-1 z-22"> - {{ .L.UserProfile.EmailHeader }} - </label> - <input class="bg-(--background-medium-color) appearance-none border-(--background-medium-color) rounded ml-6 mr-4 py-2 px-4 text-main-medium leading-tight inset-shadow-[0_0_0.4rem_black] focus:outline-none focus:bg-(--background-light-color) focus:text-main-hard z-22" - id="email-input" name="email" type="text" value="{{ .Email }}" placeholder="{{ .L.UserProfile.TypeEmail }}" maxlength="64" - data-validated-email="{{ .Email }}" onchange="checkIfValidated(this);"> - </div> - </div> - <button class="w-max mx-auto bg-(--main-hard-color) hover:bg-(--main-medium-color) active:bg-(--main-soft-color) active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-(--background-light-color) font-bold py-2 px-4 rounded z-22" - hx-post="/api/v1/email/send-verification-code" hx-target="#email-message" hx-swap="outerHTML" hx-indicator="#email-form"> - {{ .L.UserProfile.SendCodeButton }} - </button> - </form> - <div id="email-message" class="bg-background-dark text-main-soft z-26 w-full inset-shadow-[0_0_0.4rem_black] py-2 px-4 text-center"></div> - - <hr class="my-2 border-t-4 border-dotted border-main-hard"> - - <form id="email-verification-form" class="w-full mb-2 flex flex-col relative hidden crossable" - {{ if .EmailCode }}hx-post="/api/v1/email/verify" hx-target="#verification-message" hx-swap="outerHTML" hx-trigger="load" hx-indicator="this"{{ end }}> - <div class="flex flex-row w-full mb-4 z-21"> - <div class="flex flex-col w-full sm:flex-2/5 grow-0"> - <label class="block text-main-medium font-bold ml-1 z-22"> - {{ .L.UserProfile.VerificationCodeHeader }} - </label> - <input class="bg-(--background-medium-color) appearance-none border-(--background-medium-color) rounded ml-6 mr-4 py-2 px-4 text-main-medium leading-tight inset-shadow-[0_0_0.4rem_black] focus:outline-none focus:bg-(--background-light-color) focus:text-main-hard z-22" - name="email_code" type="text" value="{{ .EmailCode }}" placeholder="0123456789ABCDEF" maxlength="16" autocapitalize="characters"> - </div> - </div> - <button class="w-max mx-auto bg-(--main-hard-color) hover:bg-(--main-medium-color) active:bg-(--main-soft-color) active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-(--background-light-color) font-bold py-2 px-4 rounded z-22" - hx-post="/api/v1/email/verify" hx-target="#verification-message" hx-swap="outerHTML" hx-indicator="#email-verification-form"> - {{ .L.UserProfile.VerifyButton }} - </button> - </form> - <div id="verification-message" class="bg-background-dark text-main-soft z-26 w-full inset-shadow-[0_0_0.4rem_black] py-2 px-4 text-center"></div> -</div> - -<hr class="my-4 border-t-4 border-dotted border-main-hard"> - -<div class="bg-background-light flex flex-col inset-shadow-[0_0_0.4rem_black] px-8 py-4"> - <form id="subs-form" class="w-full mb-2 flex flex-col relative crossable"> - <div class="flex flex-col w-full mb-4 z-21"> - <label class="block text-main-medium font-bold mb-1 z-22"> - {{ .L.UserProfile.SubscriptionHeader }} - </label> - <div class="flex flex-col sm:flex-row w-full"> - <div class="m-1 w-1/3"> - <label><input class="mr-1" type="radio" id="tagsNone" name="tags" value="none" {{ if eq .TagsPicked "none" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ .L.UserProfile.TagsNone }}</label> - </div> - <div class="m-1 w-1/3"> - <label><input class="mr-1" type="radio" id="tagsAll" name="tags" value="all" {{ if eq .TagsPicked "all" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ .L.UserProfile.TagsAll }}</label> - </div> - <div class="m-1 w-1/3"> - <label><input class="mr-1" type="radio" id="tagsSpecific" name="tags" value="specific" {{ if eq .TagsPicked "specific" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ .L.UserProfile.TagsSpecific }}</label> - </div> - </div> - <div id="pick-tags-form" class="flex flex-col w-full relative crossable"> - <input type="text" class="bg-(--background-medium-color) appearance-none border-(--background-medium-color) rounded ml-6 mr-4 py-2 px-4 text-main-medium leading-tight inset-shadow-[0_0_0.4rem_black] focus:outline-none focus:bg-(--background-light-color) focus:text-main-hard z-22" - list="existing-tags" onchange="addTag(this.value);" onkeydown="if (event.keyCode === 13) {addTag(this.value); this.value=''; return false;}"> - <datalist id="existing-tags"> - {{- range .ExistingTags }} - <option value="{{ .Name }}"> - {{- end }} - </datalist> - <div id="tags-picked-list" class="bg-background-dark flex flex-wrap inset-shadow-[0_0_0.4rem_black] p-2 m-4 w-full"> - {{- range .TagsPickedList }} - <div id="tagPicked{{ . }}" class="bg-main-hard text-background-light m-2 flex flex-row"> - <p class="py-1 px-2">{{ . }}</p> - <p class="py-1 px-2 bg-main-soft cursor-pointer" onclick="htmx.remove(htmx.find('#tagPicked{{ . }}'));">✘</p> - <input type="hidden" name="tags_picked" value="{{ . }}"> - </div> - {{- end }} - </div> - </div> - <button class="w-max mx-auto bg-(--main-hard-color) hover:bg-(--main-medium-color) active:bg-(--main-soft-color) active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-(--background-light-color) font-bold py-2 px-4 rounded z-22" - hx-put="/api/v1/subs" hx-target="#subs-message" hx-swap="outerHTML" hx-indicator="#subs-form"> - {{ .L.UserProfile.SaveButton }} - </button> - </div> - </form> - <div id="subs-message" class="bg-background-dark text-main-soft z-26 w-full inset-shadow-[0_0_0.4rem_black] py-2 px-4 text-center"></div> -</div> - -<script> -htmx.on("htmx:beforeSwap", (e) => { - if (e.detail.xhr.status === 422) { - e.detail.shouldSwap = true; - e.detail.isError = false; - } -}); - -var formAllowTimeout; -var verificationHideTimeout; - -var url = new URL(window.location.href); -url.searchParams.delete('email_code'); -window.history.pushState({}, '', url.toString()); - -htmx.on("htmx:afterSwap", (e) => { - emailMessage = htmx.find("#email-message"); - verificationMessage = htmx.find("#verification-message"); - emailForm = htmx.find("#email-form"); - emailInput = htmx.find("#email-input"); - emailVerificationForm = htmx.find("#email-verification-form"); - subsForm = htmx.find("#subs-form"); - tryAgainTimeDiff = parseInt(emailMessage.dataset.strikedEndTime || "0") - new Date().getTime(); - verificationWaitingTimeDiff = parseInt(emailMessage.dataset.codeExpiryTime || "0") - new Date().getTime(); - - if (tryAgainTimeDiff > 0) { - emailForm.classList.add("disabled"); - formAllowTimeout = setTimeout(() => { - emailForm.classList.remove("disabled"); - emailMessage.classList.add("hidden"); - }, tryAgainTimeDiff); - } else { - emailForm.classList.remove("disabled"); - } - - if (verificationWaitingTimeDiff > 0) { - verificationHideTimeout = setTimeout(() => emailVerificationForm.classList.add("hidden"), verificationWaitingTimeDiff); - emailVerificationForm.classList.remove("hidden"); - } else { - emailVerificationForm.classList.add("hidden"); - } - - if ((verificationMessage.dataset.hideVerificationPanel || "false") == "true") { - emailVerificationForm.classList.add("hidden"); - } - - if (emailInput.dataset.validatedEmail != "") { - subsForm.classList.remove("disabled"); - } else { - subsForm.classList.add("disabled"); - } -}); - -function checkIfValidated(elem) { - if ((elem.dataset.validatedEmail != "") && (elem.value == elem.dataset.validatedEmail)) { - elem.classList.add("!bg-green-300/20"); - } else { - elem.classList.remove("!bg-green-300/20"); - } -} - -checkIfValidated(htmx.find("#email-input")); - -function toggleTagsPick(elem) { - const pickTagsForm = htmx.find("#pick-tags-form"); - if (elem.value === "specific" && elem.checked) { - pickTagsForm.classList.remove("disabled"); - } else { - pickTagsForm.classList.add("disabled"); - } -} - -toggleTagsPick(htmx.find("#tagsSpecific")); - -function addTag(name) { - name = name.trim(); - const subsMessage = htmx.find("#subs-message"); - const existingTags = htmx.findAll(htmx.find("#existing-tags"), "option"); - found = [...existingTags].find(opt => opt.value===name); - if (!found) { - subsMessage.classList.remove("hidden"); - subsMessage.innerHTML = "{{ .L.UserProfile.TagDoesNotExist }}".replace("{}", name); - return; - } - - const tagsPickedList = htmx.find("#tags-picked-list"); - const subscribedTags = htmx.findAll(tagsPickedList, "div input"); - found = [...subscribedTags].find(opt => opt.value===name); - if (found) { - subsMessage.classList.remove("hidden"); - subsMessage.innerHTML = "{{ .L.UserProfile.TagAlreadyAdded }}".replace("{}", name); - return; - } - - tagsPickedList.innerHTML += ` - <div id="tagPicked${name}" class="bg-main-hard text-background-light m-2 flex flex-row"> - <p class="py-1 px-2">${name}</p> - <p class="py-1 px-2 bg-main-soft cursor-pointer" onclick="htmx.remove(htmx.find('#tagPicked${name}'));">✘</p> - <input type="hidden" name="tags_picked" value="${name}"> - </div> - `; -} - -htmx.on("htmx:configRequest", (e) => { - const params = e.detail.parameters; - - if (Array.isArray(params.tags_picked)) { - tags_join = params.tags_picked[0] || ""; - for (i = 1; i < params.tags_picked.length; i++) { - tags_join += ","; - tags_join += params.tags_picked[i]; - } - params.tags_picked = tags_join; - } -}) -</script> -{{ end }}
\ No newline at end of file diff --git a/views/partials/catalogue-blog-cards.html b/views/partials/catalogue-blog-cards.html index b50f419..9bc5a30 100644 --- a/views/partials/catalogue-blog-cards.html +++ b/views/partials/catalogue-blog-cards.html @@ -5,7 +5,7 @@ <img onclick="return changeUrl('{{ .ArticleLink }}');" class="w-full h-full aspect-square cursor-pointer object-cover rounded-lg select-none" src="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/{{ .Thumbnail }}.webp"> </a> <div class="grow my-0.5 mx-1 flex flex-col justify-center"> - <div class="flex flex-row gap-2 max-w-full"> + <div class="flex flex-row gap-2 max-w-[100%]"> <a href="{{ .ArticleLink }}" onclick="return changeUrl('{{ .ArticleLink }}');" class="grow text-base cursor-pointer"> <span class="font-extrabold">{{ .Title }}</span> <span class="font-extrabold select-none text-secondary">//</span> diff --git a/views/partials/general-page-body.html b/views/partials/general-page-body.html index d697c14..c3aa3eb 100644 --- a/views/partials/general-page-body.html +++ b/views/partials/general-page-body.html @@ -1 +1,2 @@ +<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 90180de..a29d40b 100644 --- a/views/partials/general-page-header.html +++ b/views/partials/general-page-header.html @@ -1,4 +1,3 @@ -<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> diff --git a/views/partials/personal-page-status.html b/views/partials/personal-page-status.html deleted file mode 100644 index a4e47c2..0000000 --- a/views/partials/personal-page-status.html +++ /dev/null @@ -1,12 +0,0 @@ -<div id="{{ .StatusId }}" class='z-26 w-full inset-shadow-[0_0_0.4rem_black] - {{if eq .Status "OK"}}bg-green-700 text-green-100 - {{else if eq .Status "Failed"}}bg-red-700 text-red-100 - {{else}}bg-background-dark text-main-soft{{end}} - {{if eq .Message ""}}hidden{{end}} - py-2 px-4 text-center' - {{- range $_, $val := .DataAttributes }} - {{ $val }} - {{- end }} - > - {{ .Message }} -</div>
\ No newline at end of file |