From ad6528e1ba16ac9857dff879276834b1b5161382 Mon Sep 17 00:00:00 2001 From: SayaAndy Date: Sun, 5 Apr 2026 04:42:31 +0700 Subject: feat: redesign catalog sidebar feat: add indicators on catalog if user liked or viewed the page feat: add accent colors for each theme --- internal/router/handlers/api-v1-blog-search.go | 2 ++ 1 file changed, 2 insertions(+) (limited to 'internal/router') diff --git a/internal/router/handlers/api-v1-blog-search.go b/internal/router/handlers/api-v1-blog-search.go index a0316e2..1e8a480 100644 --- a/internal/router/handlers/api-v1-blog-search.go +++ b/internal/router/handlers/api-v1-blog-search.go @@ -91,7 +91,9 @@ func (r *BlogSearchHandler) Render(c *fiber.Ctx, supplements *router.Supplements "Thumbnail": page.Metadata.Thumbnail, "Tags": page.Metadata.Tags, "LikeCount": supplements.ClientCache.GetLikeCount(page.FileName), + "Liked": supplements.ClientCache.GetLikeStatus(c.IP(), page.FileName), "ViewCount": supplements.ClientCache.GetViewCount(page.FileName), + "Viewed": supplements.ClientCache.GetViewStatus(c.IP(), page.FileName), }) break } -- cgit v1.3.1+13 From 66b2a96fcca113105043be3cb9c49f8ee7fe240c Mon Sep 17 00:00:00 2001 From: SayaAndy Date: Wed, 15 Apr 2026 16:11:51 +0700 Subject: feat: support s3 for blog pages refactor: make blog client an interface feat: claude.md --- CLAUDE.md | 62 +++++++++++ config/config.go | 79 +++++++++++++- go.mod | 12 +++ go.sum | 24 +++++ internal/b2/client.go | 136 ------------------------ internal/blog/b2.go | 133 +++++++++++++++++++++++ internal/blog/client.go | 27 +++++ internal/blog/s3.go | 140 +++++++++++++++++++++++++ internal/blogtrigger/blogtrigger.go | 22 ++-- internal/factgiver/factgiver.go | 10 +- internal/mailer/mailer.go | 4 +- internal/router/handlers/api-v1-blog-search.go | 6 +- internal/router/handlers/api-v1-like-get.go | 2 +- internal/router/handlers/api-v1-like-put.go | 2 +- internal/router/handlers/lang-blog-title.go | 16 +-- internal/router/handlers/lang-blog.go | 8 +- internal/router/handlers/lang-map.go | 8 +- internal/router/handlers/lang-user.go | 2 +- internal/router/router.go | 10 +- 19 files changed, 520 insertions(+), 183 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 internal/b2/client.go create mode 100644 internal/blog/b2.go create mode 100644 internal/blog/client.go create mode 100644 internal/blog/s3.go (limited to 'internal/router') diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..34a12ce --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,62 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Run + +```bash +# Build (CGO required for SQLite) +CGO_ENABLED=1 go build -o sayana-web . + +# Run (select config via ENVIRONMENT) +ENVIRONMENT=local ./sayana-web + +# Generate Tailwind CSS +npx @tailwindcss/cli -i ./static/input.css -o ./static/output.css + +# Docker build +docker build -t sayana-web . +``` + +Server listens on `:3000`. No Makefile. No test suite currently. + +## Environment + +Config selected by `ENVIRONMENT` var (`local`/`stage`/`prod`) → `config/config.{env}.yaml`. + +Key env vars: `B2_KEY_ID`, `B2_APPLICATION_KEY`, `MAIL_HOST`, `MAIL_ADDRESS`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_SALT`. See `.envrc` for local setup. + +## Architecture + +**Go + Fiber v2** web app serving a multi-language blog/travel site with SSR. + +### Request Flow +1. Fiber router with middleware (CORS, compression, ETags, trailing slash) +2. Routes auto-register via `init()` in `internal/router/handlers/` — each handler file adds to `router.Routes` global slice +3. Handlers implement `Route` interface: `Filter()`, `IsTemplated()`, `Render()`, `RenderBody()` +4. Templated routes render via `TemplateManager` (Go `html/template`) with layout composition +5. Pages cached in Ristretto (512MB in-memory) with TTL; segments loadable via `/api/v1/general-page/:part` + +### Storage +- **Blog content:** Markdown files with YAML frontmatter on B2 or S3 (configurable via `blog.Client` interface) +- **Database:** SQLite3 via `database/sql` (no ORM). Migrations in `migrations/` via golang-migrate +- **Tables:** `blog_likes`, `blog_views`, `user_email_table`, subscription tables +- **Session cache:** SQLite-backed `ClientCache` for client IDs/preferences + +### Key Subsystems +- **Markdown rendering:** Goldmark with custom extensions — `TailwindExtension` (applies Tailwind classes) and `GLightboxExtension` (image galleries) +- **Mailer:** SMTP email with verification codes, subscription management +- **BlogTrigger:** gocron scheduler that detects new posts and sends notification emails +- **i18n:** Language in URL path (`/en/`, `/ru/`), locale YAML files in `locale/` + +### Handler Pattern +Handlers in `internal/router/handlers/` follow naming convention matching routes: +- `lang-blog.go` → `/:lang/blog/` pages +- `api-v1-like-put.go` → `/api/v1/like` PUT endpoint +- Each handler self-registers in `init()`, no central route table to maintain + +## Deployment + +CI/CD via GitHub Actions → Docker image to ghcr.io → Ansible playbook (`deploy/`). +- `stage` branch → stage environment +- Version tags → prod environment diff --git a/config/config.go b/config/config.go index eb4a91d..e48a28b 100644 --- a/config/config.go +++ b/config/config.go @@ -1,6 +1,8 @@ package config import ( + "encoding/json" + "fmt" "log/slog" "os" @@ -25,8 +27,72 @@ type BlogPagesConfig struct { } type StorageConfig struct { - Type string `json:"type" yaml:"type"` - Config B2Config `json:"Config" yaml:"config" validate:"required"` + Type string `json:"type" yaml:"type,oneof=b2 s3"` + Config any `json:"Config" yaml:"config" validate:"required"` +} + +func (sc *StorageConfig) UnmarshalJSON(data []byte) error { + var tmp struct { + Type string `json:"Type"` + Config json.RawMessage `json:"Config"` + } + + if err := json.Unmarshal(data, &tmp); err != nil { + return err + } + + sc.Type = tmp.Type + + switch tmp.Type { + case "b2": + var b2Config B2Config + if err := json.Unmarshal(tmp.Config, &b2Config); err != nil { + return fmt.Errorf("unmarshal B2Config: %w", err) + } + sc.Config = &b2Config + case "s3": + var s3Config S3Config + if err := json.Unmarshal(tmp.Config, &s3Config); err != nil { + return fmt.Errorf("unmarshal S3Config: %w", err) + } + sc.Config = &s3Config + default: + return fmt.Errorf("unsupported storage type: %s", tmp.Type) + } + + return nil +} + +func (sc *StorageConfig) UnmarshalYAML(value *yaml.Node) error { + var tmp struct { + Type string `yaml:"type"` + Config yaml.Node `yaml:"config"` + } + + if err := value.Decode(&tmp); err != nil { + return err + } + + sc.Type = tmp.Type + + switch tmp.Type { + case "b2": + var b2Config B2Config + if err := tmp.Config.Decode(&b2Config); err != nil { + return fmt.Errorf("unmarshal B2Config: %w", err) + } + sc.Config = &b2Config + case "s3": + var s3Config S3Config + if err := tmp.Config.Decode(&s3Config); err != nil { + return fmt.Errorf("unmarshal S3Config: %w", err) + } + sc.Config = &s3Config + default: + return fmt.Errorf("unsupported storage type: %s", tmp.Type) + } + + return nil } type B2Config struct { @@ -37,6 +103,15 @@ type B2Config struct { ApplicationKey string `json:"ApplicationKey" yaml:"applicationKey"` } +type S3Config struct { + BucketName string `json:"BucketName" yaml:"bucketName" validate:"required,min=1"` + Region string `json:"Region" yaml:"region" validate:"required,min=1"` + Prefix string `json:"Prefix" yaml:"prefix"` + Endpoint string `json:"Endpoint" yaml:"endpoint" validate:"required,url"` + AccessKeyID string `json:"AccessKeyID" yaml:"accessKeyID"` + SecretAccessKey string `json:"SecretAccessKey" yaml:"secretAccessKey"` +} + type FactGiverConfig struct { Storage StorageConfig `json:"Storage" yaml:"storage" validate:"required"` FactsFileName string `json:"FactsFileName" yaml:"factsFileName" validate:"required"` diff --git a/go.mod b/go.mod index 5f95df5..228e579 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,18 @@ require ( require ( github.com/andybalholm/brotli v1.2.1 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 // indirect + github.com/aws/smithy-go v1.24.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect diff --git a/go.sum b/go.sum index 7b25a49..50f54cb 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,30 @@ github.com/Backblaze/blazer v0.7.2 h1:UWNHMLB+Nf+UmbO2qkVvgriODLEMz4kIyr2Hm+DVXQ github.com/Backblaze/blazer v0.7.2/go.mod h1:T4y3EYa9IQ5J0PKc/C/J8/CEnSd3qa/lgNw938wZg10= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= +github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= 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/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= diff --git a/internal/b2/client.go b/internal/b2/client.go deleted file mode 100644 index fda79e0..0000000 --- a/internal/b2/client.go +++ /dev/null @@ -1,136 +0,0 @@ -package b2 - -import ( - "context" - "fmt" - "slices" - "strings" - "time" - - "github.com/Backblaze/blazer/b2" - "github.com/SayaAndy/saya-today-web/config" - "github.com/SayaAndy/saya-today-web/internal/frontmatter" -) - -type B2Client struct { - prefix string - bucket *b2.Bucket - b2cl *b2.Client -} - -func NewB2Client(cfg *config.B2Config) (*B2Client, error) { - b2cl, err := b2.NewClient(context.Background(), cfg.KeyID, cfg.ApplicationKey) - if err != nil { - return nil, err - } - - bucket, err := b2cl.Bucket(context.Background(), cfg.BucketName) - if err != nil { - return nil, err - } - - return &B2Client{b2cl: b2cl, bucket: bucket, prefix: cfg.Prefix}, nil -} - -type BlogPage struct { - Link string - FileName string - Lang string - ModifiedTime time.Time - Metadata *frontmatter.Metadata -} - -func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) { - filePaths := []*BlogPage{} - - iter := c.bucket.List(context.Background(), b2.ListPrefix(c.prefix+prefix)) - - for iter.Next() { - obj := iter.Object() - if obj == nil { - return nil, fmt.Errorf("failed to reference object in B2 bucket") - } - - attrs, err := obj.Attrs(context.Background()) - if err != nil { - return nil, fmt.Errorf("get attributes for object: %w", err) - } - - if attrs.Status != b2.Uploaded { - continue - } - - if !strings.Contains(attrs.ContentType, "text/markdown") { - continue - } - - if _, ok := attrs.Info["title"]; !ok { - continue - } - - publishedTime, err := time.Parse(time.RFC3339, attrs.Info["published-time"]) - if err != nil { - return nil, fmt.Errorf("failed to parse published time metadata field: %w", err) - } - - linkParts := strings.Split(obj.Name(), "/") - nameParts := strings.Split(linkParts[len(linkParts)-1], ".") - fileName := strings.Join(nameParts[:len(linkParts)-1], ".") - - tags := strings.Split(attrs.Info["tags"], ",") - slices.Sort(tags) - - lang, _ := strings.CutPrefix(linkParts[0], c.prefix) - - filePaths = append(filePaths, &BlogPage{ - Link: obj.Name(), - FileName: fileName, - Lang: lang, - ModifiedTime: attrs.LastModified, - Metadata: &frontmatter.Metadata{ - Title: attrs.Info["title"], - ShortDescription: attrs.Info["short-description"], - ActionDate: attrs.Info["action-date"], - PublishedTime: publishedTime, - Thumbnail: attrs.Info["thumbnail"], - Tags: tags, - Geolocation: attrs.Info["geolocation"], - }, - }) - } - - if err := iter.Err(); err != nil { - return nil, fmt.Errorf("iterate over B2 objects: %w", err) - } - - return filePaths, nil -} - -func (c *B2Client) ReadAll(path string) ([]byte, error) { - obj := c.bucket.Object(c.prefix + path) - if obj == nil { - return nil, fmt.Errorf("failed to reference object in B2 bucket") - } - attrs, err := obj.Attrs(context.Background()) - if err != nil { - return nil, fmt.Errorf("error getting attributes of an object: %w", err) - } - - content := make([]byte, attrs.Size) - reader := obj.NewReader(context.Background()) - - if _, err = reader.Read(content); err != nil { - return nil, fmt.Errorf("failed to read file content: %w", err) - } - - return content, nil -} - -func (c *B2Client) ReadFrontmatter(path string) (metadata *frontmatter.Metadata, markdown []byte, err error) { - contentBytes, err := c.ReadAll(path) - if err != nil { - return nil, nil, fmt.Errorf("failed to read file for frontmatter parsing: %w", err) - } - - return frontmatter.ParseFrontmatter(contentBytes) -} diff --git a/internal/blog/b2.go b/internal/blog/b2.go new file mode 100644 index 0000000..0caa0ba --- /dev/null +++ b/internal/blog/b2.go @@ -0,0 +1,133 @@ +package blog + +import ( + "context" + "fmt" + "slices" + "strings" + "time" + + "github.com/Backblaze/blazer/b2" + "github.com/SayaAndy/saya-today-web/config" + "github.com/SayaAndy/saya-today-web/internal/frontmatter" +) + +type B2Client struct { + prefix string + bucket *b2.Bucket + b2cl *b2.Client +} + +func NewB2Client(cfg *config.StorageConfig) (Client, error) { + if cfg.Type != "b2" { + return nil, fmt.Errorf("invalid storage type for B2InputClient") + } + b2cfg := cfg.Config.(*config.B2Config) + + b2cl, err := b2.NewClient(context.Background(), b2cfg.KeyID, b2cfg.ApplicationKey) + if err != nil { + return nil, err + } + + bucket, err := b2cl.Bucket(context.Background(), b2cfg.BucketName) + if err != nil { + return nil, err + } + + return &B2Client{b2cl: b2cl, bucket: bucket, prefix: b2cfg.Prefix}, nil +} + +func (c *B2Client) Scan(prefix string) ([]*Page, error) { + filePaths := []*Page{} + + iter := c.bucket.List(context.Background(), b2.ListPrefix(c.prefix+prefix)) + + for iter.Next() { + obj := iter.Object() + if obj == nil { + return nil, fmt.Errorf("failed to reference object in B2 bucket") + } + + attrs, err := obj.Attrs(context.Background()) + if err != nil { + return nil, fmt.Errorf("get attributes for object: %w", err) + } + + if attrs.Status != b2.Uploaded { + continue + } + + if !strings.Contains(attrs.ContentType, "text/markdown") { + continue + } + + if _, ok := attrs.Info["title"]; !ok { + continue + } + + publishedTime, err := time.Parse(time.RFC3339, attrs.Info["published-time"]) + if err != nil { + return nil, fmt.Errorf("failed to parse published time metadata field: %w", err) + } + + linkParts := strings.Split(obj.Name(), "/") + nameParts := strings.Split(linkParts[len(linkParts)-1], ".") + fileName := strings.Join(nameParts[:len(linkParts)-1], ".") + + tags := strings.Split(attrs.Info["tags"], ",") + slices.Sort(tags) + + lang, _ := strings.CutPrefix(linkParts[0], c.prefix) + + filePaths = append(filePaths, &Page{ + Link: obj.Name(), + FileName: fileName, + Lang: lang, + ModifiedTime: attrs.LastModified, + Metadata: &frontmatter.Metadata{ + Title: attrs.Info["title"], + ShortDescription: attrs.Info["short-description"], + ActionDate: attrs.Info["action-date"], + PublishedTime: publishedTime, + Thumbnail: attrs.Info["thumbnail"], + Tags: tags, + Geolocation: attrs.Info["geolocation"], + }, + }) + } + + if err := iter.Err(); err != nil { + return nil, fmt.Errorf("iterate over B2 objects: %w", err) + } + + return filePaths, nil +} + +func (c *B2Client) ReadAll(path string) ([]byte, error) { + obj := c.bucket.Object(c.prefix + path) + if obj == nil { + return nil, fmt.Errorf("failed to reference object in B2 bucket") + } + attrs, err := obj.Attrs(context.Background()) + if err != nil { + return nil, fmt.Errorf("error getting attributes of an object: %w", err) + } + + content := make([]byte, attrs.Size) + reader := obj.NewReader(context.Background()) + + if _, err = reader.Read(content); err != nil { + return nil, fmt.Errorf("failed to read file content: %w", err) + } + + return content, nil +} + +func (c *B2Client) ReadFrontmatter(path string) (metadata *frontmatter.Metadata, markdown []byte, err error) { + contentBytes, err := c.ReadAll(path) + if err != nil { + return nil, nil, fmt.Errorf("failed to read file for frontmatter parsing: %w", err) + } + + return frontmatter.ParseFrontmatter(contentBytes) +} diff --git a/internal/blog/client.go b/internal/blog/client.go new file mode 100644 index 0000000..56bbee4 --- /dev/null +++ b/internal/blog/client.go @@ -0,0 +1,27 @@ +package blog + +import ( + "time" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/SayaAndy/saya-today-web/internal/frontmatter" +) + +type Page struct { + Link string + FileName string + Lang string + ModifiedTime time.Time + Metadata *frontmatter.Metadata +} + +type Client interface { + Scan(prefix string) ([]*Page, error) + ReadAll(path string) ([]byte, error) + ReadFrontmatter(path string) (metadata *frontmatter.Metadata, markdown []byte, err error) +} + +var NewClientMap = map[string]func(*config.StorageConfig) (Client, error){ + "b2": NewB2Client, + "s3": NewS3Client, +} diff --git a/internal/blog/s3.go b/internal/blog/s3.go new file mode 100644 index 0000000..de4eb3a --- /dev/null +++ b/internal/blog/s3.go @@ -0,0 +1,140 @@ +package blog + +import ( + "context" + "fmt" + "io" + "slices" + "strings" + "time" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/SayaAndy/saya-today-web/internal/frontmatter" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +type S3Client struct { + prefix string + bucketName string + s3cl *s3.Client +} + +func NewS3Client(cfg *config.StorageConfig) (Client, error) { + if cfg.Type != "s3" { + return nil, fmt.Errorf("invalid storage type for S3Client") + } + s3cfg := cfg.Config.(*config.S3Config) + + s3cl := s3.New(s3.Options{ + Region: s3cfg.Region, + BaseEndpoint: aws.String(s3cfg.Endpoint), + Credentials: credentials.NewStaticCredentialsProvider(s3cfg.AccessKeyID, s3cfg.SecretAccessKey, ""), + }) + + return &S3Client{s3cfg.Prefix, s3cfg.BucketName, s3cl}, nil +} + +func (c *S3Client) Scan(prefix string) ([]*Page, error) { + pages := []*Page{} + + fullPrefix := c.prefix + prefix + input := &s3.ListObjectsV2Input{ + Bucket: aws.String(c.bucketName), + Prefix: aws.String(fullPrefix), + } + + paginator := s3.NewListObjectsV2Paginator(c.s3cl, input) + + for paginator.HasMorePages() { + output, err := paginator.NextPage(context.Background()) + if err != nil { + return nil, fmt.Errorf("list S3 objects: %w", err) + } + + for _, obj := range output.Contents { + key := aws.ToString(obj.Key) + + if !strings.HasSuffix(key, ".md") { + continue + } + + head, err := c.s3cl.HeadObject(context.Background(), &s3.HeadObjectInput{ + Bucket: aws.String(c.bucketName), + Key: aws.String(key), + }) + if err != nil { + return nil, fmt.Errorf("head S3 object %s: %w", key, err) + } + + if head.ContentType == nil || !strings.Contains(*head.ContentType, "text/markdown") { + continue + } + + meta := head.Metadata + if meta["title"] == "" { + continue + } + + publishedTime, err := time.Parse(time.RFC3339, meta["published-time"]) + if err != nil { + return nil, fmt.Errorf("failed to parse published time metadata field: %w", err) + } + + linkParts := strings.Split(key, "/") + nameParts := strings.Split(linkParts[len(linkParts)-1], ".") + fileName := strings.Join(nameParts[:len(nameParts)-1], ".") + + tags := strings.Split(meta["tags"], ",") + slices.Sort(tags) + + lang, _ := strings.CutPrefix(linkParts[0], c.prefix) + + pages = append(pages, &Page{ + Link: key, + FileName: fileName, + Lang: lang, + ModifiedTime: aws.ToTime(obj.LastModified), + Metadata: &frontmatter.Metadata{ + Title: meta["title"], + ShortDescription: meta["short-description"], + ActionDate: meta["action-date"], + PublishedTime: publishedTime, + Thumbnail: meta["thumbnail"], + Tags: tags, + Geolocation: meta["geolocation"], + }, + }) + } + } + + return pages, nil +} + +func (c *S3Client) ReadAll(path string) ([]byte, error) { + output, err := c.s3cl.GetObject(context.Background(), &s3.GetObjectInput{ + Bucket: aws.String(c.bucketName), + Key: aws.String(c.prefix + path), + }) + if err != nil { + return nil, fmt.Errorf("get S3 object: %w", err) + } + defer output.Body.Close() + + content, err := io.ReadAll(output.Body) + if err != nil { + return nil, fmt.Errorf("read S3 object body: %w", err) + } + + return content, nil +} + +func (c *S3Client) ReadFrontmatter(path string) (metadata *frontmatter.Metadata, markdown []byte, err error) { + contentBytes, err := c.ReadAll(path) + if err != nil { + return nil, nil, fmt.Errorf("failed to read file for frontmatter parsing: %w", err) + } + + return frontmatter.ParseFrontmatter(contentBytes) +} diff --git a/internal/blogtrigger/blogtrigger.go b/internal/blogtrigger/blogtrigger.go index c5b3097..81efafe 100644 --- a/internal/blogtrigger/blogtrigger.go +++ b/internal/blogtrigger/blogtrigger.go @@ -5,29 +5,29 @@ import ( "log/slog" "github.com/SayaAndy/saya-today-web/config" - "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/internal/blog" "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 + knownBlogPages map[string]map[string]*blog.Page + blogClient blog.Client + onTrigger func([]*blog.Page) error } -func NewBlogTriggerScheduler(b2Client *b2.B2Client, availableLanguages []config.AvailableLanguageConfig, cron string, onTrigger func([]*b2.BlogPage) error) (*BlogTriggerScheduler, error) { +func NewBlogTriggerScheduler(blogClient blog.Client, availableLanguages []config.AvailableLanguageConfig, cron string, onTrigger func([]*blog.Page) 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)) + knownBlogPages := make(map[string]map[string]*blog.Page, len(availableLanguages)) for _, lang := range availableLanguages { - knownBlogPages[lang.Name] = make(map[string]*b2.BlogPage) + knownBlogPages[lang.Name] = make(map[string]*blog.Page) } - bts := &BlogTriggerScheduler{s, knownBlogPages, b2Client, onTrigger} + bts := &BlogTriggerScheduler{s, knownBlogPages, blogClient, onTrigger} defer bts.s.Start() bts.s.NewJob(gocron.CronJob(cron, false), gocron.NewTask(func(bts *BlogTriggerScheduler) { @@ -53,10 +53,10 @@ func (bts *BlogTriggerScheduler) Close() error { return bts.s.Shutdown() } -func (bts *BlogTriggerScheduler) scan() (newPages []*b2.BlogPage, err error) { - newPages = make([]*b2.BlogPage, 0) +func (bts *BlogTriggerScheduler) scan() (newPages []*blog.Page, err error) { + newPages = make([]*blog.Page, 0) for lang := range bts.knownBlogPages { - posts, err := bts.b2Client.Scan(lang + "/") + posts, err := bts.blogClient.Scan(lang + "/") if err != nil { return nil, fmt.Errorf("failed to scan blog pages in b2 on '%s': %w", lang, err) } diff --git a/internal/factgiver/factgiver.go b/internal/factgiver/factgiver.go index 5b418ee..c5b8bc4 100644 --- a/internal/factgiver/factgiver.go +++ b/internal/factgiver/factgiver.go @@ -8,11 +8,11 @@ import ( "time" "github.com/SayaAndy/saya-today-web/config" - "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/internal/blog" ) type FactGiver struct { - b2Client *b2.B2Client + blogClient blog.Client cache map[string][]string langs []config.AvailableLanguageConfig factsFileName string @@ -21,13 +21,13 @@ type FactGiver struct { } func NewFactGiver(cfg *config.FactGiverConfig, langs []config.AvailableLanguageConfig) (*FactGiver, error) { - b2Client, err := b2.NewB2Client(&cfg.Storage.Config) + blogClient, err := blog.NewClientMap[cfg.Storage.Type](&cfg.Storage) if err != nil { return nil, fmt.Errorf("fail to init b2 client for a new fact giver: %s", err.Error()) } factGiver := &FactGiver{ - b2Client: b2Client, + blogClient: blogClient, cache: make(map[string][]string, len(langs)), langs: langs, factsFileName: cfg.FactsFileName, @@ -53,7 +53,7 @@ func (g *FactGiver) Give(lang string) [3]string { func (g *FactGiver) initCache() error { for _, lang := range g.langs { localFacts := strings.Replace(g.factsFileName, "*", lang.Name, 1) - factsContentBytes, err := g.b2Client.ReadAll(localFacts) + factsContentBytes, err := g.blogClient.ReadAll(localFacts) if err != nil { return fmt.Errorf("fail to read '%s' facts file: %s", lang.Name, err.Error()) } diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go index d6dbd17..ffa7b30 100644 --- a/internal/mailer/mailer.go +++ b/internal/mailer/mailer.go @@ -14,7 +14,7 @@ import ( "sync" "time" - "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/internal/blog" "github.com/SayaAndy/saya-today-web/internal/templatemanager" "github.com/SayaAndy/saya-today-web/locale" "github.com/dgraph-io/ristretto/v2" @@ -418,7 +418,7 @@ func (m *Mailer) Subscribe(userIdHash []byte, subscriptionType SubscriptionType, return nil } -func (m *Mailer) NewPost(post *b2.BlogPage) error { +func (m *Mailer) NewPost(post *blog.Page) error { tx, err := m.db.Begin() if err != nil { return fmt.Errorf("failed to initialize transaction with db: %s", err) diff --git a/internal/router/handlers/api-v1-blog-search.go b/internal/router/handlers/api-v1-blog-search.go index 1e8a480..c9a91ad 100644 --- a/internal/router/handlers/api-v1-blog-search.go +++ b/internal/router/handlers/api-v1-blog-search.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/internal/blog" "github.com/SayaAndy/saya-today-web/internal/router" "github.com/gofiber/fiber/v2" ) @@ -56,11 +56,11 @@ func (r *BlogSearchHandler) Render(c *fiber.Ctx, supplements *router.Supplements cacheKey := "blog-search." + lang + ".pages-list" - var pages []*b2.BlogPage + var pages []*blog.Page if pagesBytes, ok := supplements.PageCache.Get(cacheKey); pagesBytes != nil || ok { json.Unmarshal(pagesBytes, &pages) } else { - pages, err = supplements.B2Client.Scan(lang + "/") + pages, err = supplements.BlogClient.Scan(lang + "/") if err != nil { return fiber.StatusInternalServerError, fmt.Errorf("failed to scan pages for '%s' lang: %w", lang, err) } diff --git a/internal/router/handlers/api-v1-like-get.go b/internal/router/handlers/api-v1-like-get.go index e9c176e..0eaf5f6 100644 --- a/internal/router/handlers/api-v1-like-get.go +++ b/internal/router/handlers/api-v1-like-get.go @@ -48,7 +48,7 @@ func (r *GetLikeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, l page := pathParts[2] pageLink := lang + "/" + page + ".md" - if pages, _ := supplements.B2Client.Scan(pageLink); len(pages) == 0 { + if pages, _ := supplements.BlogClient.Scan(pageLink); len(pages) == 0 { return fiber.StatusNotFound, fmt.Errorf("server did not find '%s' article", pageLink) } diff --git a/internal/router/handlers/api-v1-like-put.go b/internal/router/handlers/api-v1-like-put.go index ea4ef4b..434f15a 100644 --- a/internal/router/handlers/api-v1-like-put.go +++ b/internal/router/handlers/api-v1-like-put.go @@ -49,7 +49,7 @@ func (r *PutLikeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, l page := pathParts[2] pageLink := lang + "/" + page + ".md" - if pages, _ := supplements.B2Client.Scan(pageLink); len(pages) == 0 { + if pages, _ := supplements.BlogClient.Scan(pageLink); len(pages) == 0 { return fiber.StatusNotFound, fmt.Errorf("server did not find '%s' article", pageLink) } diff --git a/internal/router/handlers/lang-blog-title.go b/internal/router/handlers/lang-blog-title.go index 5bfc6e7..f1be0a5 100644 --- a/internal/router/handlers/lang-blog-title.go +++ b/internal/router/handlers/lang-blog-title.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/internal/blog" "github.com/SayaAndy/saya-today-web/internal/frontmatter" "github.com/SayaAndy/saya-today-web/internal/router" "github.com/gofiber/fiber/v2" @@ -49,7 +49,7 @@ func (r *BlogPageHandler) ToValidateLang() router.LangSetting { func (r *BlogPageHandler) SitemapInfo(supplements *router.Supplements) []router.SitemapInfo { sitemapInfo := []router.SitemapInfo{} - pages, err := supplements.B2Client.Scan("") + pages, err := supplements.BlogClient.Scan("") if err != nil { return sitemapInfo } @@ -66,7 +66,7 @@ func (r *BlogPageHandler) SitemapInfo(supplements *router.Supplements) []router. } func (r *BlogPageHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta []router.MetaField, err error) { - metadata, _, err := supplements.B2Client.ReadFrontmatter(lang + "/" + c.Params("title") + ".md") + metadata, _, err := supplements.BlogClient.ReadFrontmatter(lang + "/" + c.Params("title") + ".md") if err != nil { return nil, fmt.Errorf("failed to read frontmatter of the desired blog post: %w", err) } @@ -82,7 +82,7 @@ func (r *BlogPageHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, } func (r *BlogPageHandler) AddLinkedData(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (ld map[string]any, err error) { - metadata, _, err := supplements.B2Client.ReadFrontmatter(lang + "/" + c.Params("title") + ".md") + metadata, _, err := supplements.BlogClient.ReadFrontmatter(lang + "/" + c.Params("title") + ".md") if err != nil { return nil, fmt.Errorf("failed to read frontmatter of the desired blog post: %w", err) } @@ -103,7 +103,7 @@ func (r *BlogPageHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplemen return fiber.StatusBadRequest, fmt.Errorf("failed to get path from referer: %w", err) } - metadata, parsedMarkdown, err := readBlogPost(supplements.MarkdownRenderer, supplements.B2Client, lang+"/"+pathParts[2]) + metadata, parsedMarkdown, err := readBlogPost(supplements.MarkdownRenderer, supplements.BlogClient, lang+"/"+pathParts[2]) if err != nil { return fiber.StatusNotFound, fmt.Errorf("failed to find '%s' post: %w", pathParts[2], err) } @@ -138,7 +138,7 @@ func (r *BlogPageHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplem return fiber.StatusBadRequest, fmt.Errorf("failed to get path from referer: %w", err) } - metadata, _, err := supplements.B2Client.ReadFrontmatter(lang + "/" + pathParts[2] + ".md") + metadata, _, err := supplements.BlogClient.ReadFrontmatter(lang + "/" + pathParts[2] + ".md") if err != nil { return fiber.StatusNotFound, fmt.Errorf("could not read '%s' for metadata: %w", path, err) } @@ -148,8 +148,8 @@ func (r *BlogPageHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplem 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") +func readBlogPost(md goldmark.Markdown, blogClient blog.Client, sourceName string) (metadata *frontmatter.Metadata, html string, err error) { + metadata, markdown, err := blogClient.ReadFrontmatter(sourceName + ".md") if err != nil { return nil, "", fmt.Errorf("failed to read a frontmatter file: %w", err) } diff --git a/internal/router/handlers/lang-blog.go b/internal/router/handlers/lang-blog.go index 709d3dc..79ec9f3 100644 --- a/internal/router/handlers/lang-blog.go +++ b/internal/router/handlers/lang-blog.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/internal/blog" "github.com/SayaAndy/saya-today-web/internal/router" "github.com/gofiber/fiber/v2" ) @@ -79,7 +79,7 @@ func (r *CatalogueHandler) RenderBody(c *fiber.Ctx, supplements *router.Suppleme queryTags = append(queryTags, string(match[1])) } - tagsArray, err := getTags(supplements.B2Client, lang) + tagsArray, err := getTags(supplements.BlogClient, lang) if err != nil { return fiber.StatusInternalServerError, fmt.Errorf("failed to get the available tags") } @@ -102,8 +102,8 @@ type Tag struct { Count int `json:"Count" yaml:"count"` } -func getTags(b2Client *b2.B2Client, lang string) (tags []Tag, err error) { - pages, err := b2Client.Scan(lang + "/") +func getTags(blogClient blog.Client, lang string) (tags []Tag, err error) { + pages, err := blogClient.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) diff --git a/internal/router/handlers/lang-map.go b/internal/router/handlers/lang-map.go index 551b485..d452bc7 100644 --- a/internal/router/handlers/lang-map.go +++ b/internal/router/handlers/lang-map.go @@ -7,7 +7,7 @@ import ( "strconv" "strings" - "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/internal/blog" "github.com/SayaAndy/saya-today-web/internal/router" "github.com/gofiber/fiber/v2" ) @@ -49,8 +49,8 @@ func (r *MapHandler) SitemapInfo(supplements *router.Supplements) []router.Sitem } func (r *MapHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - pages, err := supplements.B2Client.Scan(lang + "/") - slices.SortFunc(pages, func(a *b2.BlogPage, b *b2.BlogPage) int { + pages, err := supplements.BlogClient.Scan(lang + "/") + slices.SortFunc(pages, func(a *blog.Page, b *blog.Page) int { return a.Metadata.PublishedTime.Compare(b.Metadata.PublishedTime) }) status := fiber.StatusOK @@ -60,7 +60,7 @@ func (r *MapHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang slog.String("lang", lang), ) status = fiber.StatusPartialContent - pages = []*b2.BlogPage{} + pages = []*blog.Page{} } type MapMarker struct { diff --git a/internal/router/handlers/lang-user.go b/internal/router/handlers/lang-user.go index eae1685..0dda529 100644 --- a/internal/router/handlers/lang-user.go +++ b/internal/router/handlers/lang-user.go @@ -55,7 +55,7 @@ func (r *UserHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, slog.Error("get info from mailer about a client", slog.String("error", err.Error())) } - tagsArray, err := getTags(supplements.B2Client, lang) + tagsArray, err := getTags(supplements.BlogClient, lang) if err != nil { return fiber.ErrInternalServerError.Code, fmt.Errorf("failed to get the available tags") } diff --git a/internal/router/router.go b/internal/router/router.go index 3242759..e8fb47a 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -13,7 +13,7 @@ import ( "time" "github.com/SayaAndy/saya-today-web/config" - "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/internal/blog" "github.com/SayaAndy/saya-today-web/internal/blogtrigger" "github.com/SayaAndy/saya-today-web/internal/factgiver" "github.com/SayaAndy/saya-today-web/internal/glightbox" @@ -90,7 +90,7 @@ type Route interface { type Supplements struct { DB *sql.DB - B2Client *b2.B2Client + BlogClient blog.Client Localization map[string]*locale.LocaleConfig AvailableLanguages []config.AvailableLanguageConfig ClientCache *ClientCache @@ -140,7 +140,7 @@ func NewRouter(cfg *config.Config) (*Router, error) { } slog.Debug("successfully applied migrations") - supplements.B2Client, err = b2.NewB2Client(&cfg.BlogPages.Storage.Config) + supplements.BlogClient, err = blog.NewClientMap[cfg.BlogPages.Storage.Type](&cfg.BlogPages.Storage) if err != nil { return nil, fmt.Errorf("fail to initialize b2 client: %w", err) } @@ -198,8 +198,8 @@ func NewRouter(cfg *config.Config) (*Router, error) { 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 { + supplements.BlogTrigger, err = blogtrigger.NewBlogTriggerScheduler(supplements.BlogClient, cfg.AvailableLanguages, cfg.Mail.Trigger.OnNewPost, + func(bp []*blog.Page) error { for _, post := range bp { if err := supplements.Mailer.NewPost(post); err != nil { return err -- cgit v1.3.1+13 From 2d727cc1eeb926f5b5ee578a0ee54269e0390a96 Mon Sep 17 00:00:00 2001 From: SayaAndy Date: Sat, 2 May 2026 12:58:40 +0700 Subject: refactor: parametrize static & photo storage url refactor: parametrize allow origins --- config/config.go | 23 ++++++++++++++++ config/config.local.yaml | 22 +++++++++++++++ config/config.prod.yaml | 22 +++++++++++++++ config/config.stage.yaml | 22 +++++++++++++++ internal/glightbox/extension.go | 11 +++++--- internal/glightbox/html_renderer.go | 42 ++++++++++++++++++++--------- internal/router/handlers/lang-blog-title.go | 2 +- internal/router/handlers/lang.go | 10 +++++-- internal/router/router.go | 42 ++++++++++++++++++----------- views/layouts/general-page.html | 16 +++++------ views/messages/new-post.html | 2 +- views/pages/global-map.html | 14 +++++----- views/pages/home-page.html | 2 +- views/partials/catalogue-blog-cards.html | 2 +- 14 files changed, 178 insertions(+), 54 deletions(-) (limited to 'internal/router') diff --git a/config/config.go b/config/config.go index ebe0367..536a333 100644 --- a/config/config.go +++ b/config/config.go @@ -20,6 +20,9 @@ type Config struct { Mail MailConfig `json:"Mail" yaml:"mail" validate:"required"` CanonicalEndpoint string `json:"CanonicalEndpoint" yaml:"canonicalEndpoint" validate:"required"` Meta MetaConfig `json:"Meta" yaml:"meta"` + PhotoStorage PhotoStorageConfig `json:"PhotoStorage" yaml:"photoStorage"` + StaticStorage PhotoTypeConfig `json:"StaticStorage" yaml:"staticStorage"` + AllowOrigins []string `json:"AllowOrigins" yaml:"allowOrigins"` } type BlogPagesConfig struct { @@ -158,6 +161,26 @@ type MetaConfig struct { GoogleSiteVerification string `json:"GoogleSiteVerification" yaml:"googleSiteVerification"` } +type PhotoStorageConfig struct { + Full PhotoTypeConfig `json:"Full" yaml:"full" validate:"required"` + Webp PhotoTypeConfig `json:"Webp" yaml:"webp"` + Thumbnail1600p PhotoTypeConfig `json:"Thumbnail1600p" yaml:"thumbnail1600p"` + Thumbnail1200p PhotoTypeConfig `json:"Thumbnail1200p" yaml:"thumbnail1200p"` + Thumbnail800p PhotoTypeConfig `json:"Thumbnail800p" yaml:"thumbnail800p"` + Thumbnail560p PhotoTypeConfig `json:"Thumbnail560p" yaml:"thumbnail560p"` + Thumbnail320p PhotoTypeConfig `json:"Thumbnail320p" yaml:"thumbnail320p"` + HomePageGifs HomePageGifsConfig `json:"HomePageGifs" yaml:"homePageGifs"` +} + +type PhotoTypeConfig struct { + BaseUrl string `json:"BaseUrl" yaml:"baseUrl" validate:"url,required"` +} + +type HomePageGifsConfig struct { + BaseUrl string `json:"BaseUrl" yaml:"baseUrl" validate:"url,required"` + Indexes []string `json:"Indexes" yaml:"indexes"` +} + 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 c02dc2d..d312504 100644 --- a/config/config.local.yaml +++ b/config/config.local.yaml @@ -46,3 +46,25 @@ mail: trigger: onNewPost: "* * * * *" canonicalEndpoint: 'http://127.0.0.1:3000' +photoStorage: + full: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/full/%s + webp: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/webp/%s.webp + thumbnail1600p: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/webp-1600p/%s.webp + thumbnail1200p: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/webp-1200p/%s.webp + thumbnail800p: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/webp-800p/%s.webp + thumbnail560p: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/webp-560p/%s.webp + thumbnail320p: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp + homePageGifs: + baseUrl: https://f003.backblazeb2.com/file/sayana-static/home-page-gifs/otter-%s.gif + indexes: ['1', '2', '3'] +staticStorage: + baseUrl: https://f003.backblazeb2.com/file/sayana-static +allowOrigins: + - https://f003.backblazeb2.com diff --git a/config/config.prod.yaml b/config/config.prod.yaml index b40ba57..2c5a159 100644 --- a/config/config.prod.yaml +++ b/config/config.prod.yaml @@ -47,3 +47,25 @@ mail: canonicalEndpoint: 'https://${FQDN}' meta: googleSiteVerification: '${GOOGLE_SITE_VERIFICATION}' +photoStorage: + full: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/full/%s + webp: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/webp/%s.webp + thumbnail1600p: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/webp-1600p/%s.webp + thumbnail1200p: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/webp-1200p/%s.webp + thumbnail800p: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/webp-800p/%s.webp + thumbnail560p: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/webp-560p/%s.webp + thumbnail320p: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp + homePageGifs: + baseUrl: https://f003.backblazeb2.com/file/sayana-static/home-page-gifs/otter-%s.gif + indexes: ['1', '2', '3'] +staticStorage: + baseUrl: https://f003.backblazeb2.com/file/sayana-static +allowOrigins: + - https://f003.backblazeb2.com diff --git a/config/config.stage.yaml b/config/config.stage.yaml index efa94ae..0889c19 100644 --- a/config/config.stage.yaml +++ b/config/config.stage.yaml @@ -45,3 +45,25 @@ mail: trigger: onNewPost: "0/3 * * * *" canonicalEndpoint: 'https://${FQDN}' +photoStorage: + full: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/full/%s + webp: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/webp/%s.webp + thumbnail1600p: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/webp-1600p/%s.webp + thumbnail1200p: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/webp-1200p/%s.webp + thumbnail800p: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/webp-800p/%s.webp + thumbnail560p: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/webp-560p/%s.webp + thumbnail320p: + baseUrl: https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp + homePageGifs: + baseUrl: https://f003.backblazeb2.com/file/sayana-static/home-page-gifs/otter-%s.gif + indexes: ['1', '2', '3'] +staticStorage: + baseUrl: https://f003.backblazeb2.com/file/sayana-static +allowOrigins: + - https://f003.backblazeb2.com diff --git a/internal/glightbox/extension.go b/internal/glightbox/extension.go index 7fd17b1..e9a9f3e 100644 --- a/internal/glightbox/extension.go +++ b/internal/glightbox/extension.go @@ -1,6 +1,7 @@ package glightbox import ( + "github.com/SayaAndy/saya-today-web/config" "github.com/yuin/goldmark" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer" @@ -8,10 +9,12 @@ import ( ) // Extension that combines parser and renderer -type GLightboxExtension struct{} +type GLightboxExtension struct { + photoStorage config.PhotoStorageConfig +} -func NewGLightboxExtension() goldmark.Extender { - return &GLightboxExtension{} +func NewGLightboxExtension(photoStorage config.PhotoStorageConfig) goldmark.Extender { + return &GLightboxExtension{photoStorage} } func (e *GLightboxExtension) Extend(m goldmark.Markdown) { @@ -22,7 +25,7 @@ func (e *GLightboxExtension) Extend(m goldmark.Markdown) { ) m.Renderer().AddOptions( renderer.WithNodeRenderers( - util.Prioritized(NewGLightboxHTMLRenderer(), 500), + util.Prioritized(NewGLightboxHTMLRenderer(e.photoStorage), 500), ), ) } diff --git a/internal/glightbox/html_renderer.go b/internal/glightbox/html_renderer.go index f71a8f2..b2cd855 100644 --- a/internal/glightbox/html_renderer.go +++ b/internal/glightbox/html_renderer.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/SayaAndy/saya-today-web/config" "github.com/SayaAndy/saya-today-web/internal/tailwind" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" @@ -21,9 +22,10 @@ type GLightboxHTMLRenderer struct { html.Config md goldmark.Markdown anchorMatchRe *regexp.Regexp + photoStorage config.PhotoStorageConfig } -func NewGLightboxHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { +func NewGLightboxHTMLRenderer(photoStorage config.PhotoStorageConfig, opts ...html.Option) renderer.NodeRenderer { r := &GLightboxHTMLRenderer{ Config: html.NewConfig(), md: goldmark.New( @@ -44,11 +46,12 @@ func NewGLightboxHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { ), ), ), + anchorMatchRe: regexp.MustCompile(`(?s)<\s*a(\s+[^<]*)(href\s*=\s*["'].*?["'])\s*([^<]*)>(.*?)<\s*\/\s*a\s*>`), + photoStorage: photoStorage, } for _, opt := range opts { opt.SetHTMLOption(&r.Config) } - r.anchorMatchRe = regexp.MustCompile(`(?s)<\s*a(\s+[^<]*)(href\s*=\s*["'].*?["'])\s*([^<]*)>(.*?)<\s*\/\s*a\s*>`) return r } @@ -111,19 +114,32 @@ func (r *GLightboxHTMLRenderer) renderGLightbox(w util.BufWriter, source []byte, anchorlessCaptionHTML := r.anchorMatchRe.ReplaceAll(captionHTML, []byte("$4")) elements = append(elements, fmt.Sprintf(` - + - - - - - - + + + + + + -

%s

- %d -
`, fullImageUrl, strings.Join(tagClassList, " "), galleryID, dayDate.Format("2006-01-02 15:04:05 -07:00"), dataDescriptionAttribute, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, anchorlessCaptionHTML, i+1)) +

%[5]s

+ %[6]d + `, + strings.Join(tagClassList, " "), + galleryID, + dayDate.Format("2006-01-02 15:04:05 -07:00"), + dataDescriptionAttribute, + anchorlessCaptionHTML, + i+1, + fmt.Sprintf(r.photoStorage.Full.BaseUrl, fullImageUrl), + fmt.Sprintf(r.photoStorage.Thumbnail1600p.BaseUrl, imageUrlWithoutExt), + fmt.Sprintf(r.photoStorage.Thumbnail1200p.BaseUrl, imageUrlWithoutExt), + fmt.Sprintf(r.photoStorage.Thumbnail800p.BaseUrl, imageUrlWithoutExt), + fmt.Sprintf(r.photoStorage.Thumbnail560p.BaseUrl, imageUrlWithoutExt), + fmt.Sprintf(r.photoStorage.Thumbnail320p.BaseUrl, imageUrlWithoutExt), + )) if glightboxDescId != "" { elements = append(elements, fmt.Sprintf(` diff --git a/internal/router/handlers/lang-blog-title.go b/internal/router/handlers/lang-blog-title.go index f1be0a5..49744c1 100644 --- a/internal/router/handlers/lang-blog-title.go +++ b/internal/router/handlers/lang-blog-title.go @@ -74,7 +74,7 @@ func (r *BlogPageHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, return []router.MetaField{ {Property: "og:title", Content: metadata.Title}, {Property: "og:description", Content: fmt.Sprintf("%s [%s]", metadata.ShortDescription, metadata.ActionDate)}, - {Property: "og:image", Content: fmt.Sprintf("https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp", metadata.Thumbnail)}, + {Property: "og:image", Content: fmt.Sprintf(supplements.PhotoStorage.Thumbnail320p.BaseUrl, metadata.Thumbnail)}, {Property: "og:url", Content: fmt.Sprintf("%s/%s/blog/%s", templateMap["CanonicalEndpoint"], lang, c.Params("title"))}, {Property: "og:type", Content: "website"}, {Name: "twitter:card", Content: "summary_large_image"}, diff --git a/internal/router/handlers/lang.go b/internal/router/handlers/lang.go index c618353..25b5036 100644 --- a/internal/router/handlers/lang.go +++ b/internal/router/handlers/lang.go @@ -53,7 +53,10 @@ func (r *HomeHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lan return []router.MetaField{ {Property: "og:title", Content: supplements.Localization[lang].HomePage.Header}, {Property: "og:description", Content: supplements.Localization[lang].HomePage.HomePageDescription}, - {Property: "og:image", Content: fmt.Sprintf("https://f003.backblazeb2.com/file/sayana-static/home-page-gifs/otter-%d.gif", rand.Int()%3+1)}, + {Property: "og:image", Content: fmt.Sprintf( + supplements.PhotoStorage.HomePageGifs.BaseUrl, + supplements.PhotoStorage.HomePageGifs.Indexes[rand.Int()%len(supplements.PhotoStorage.HomePageGifs.Indexes)], + )}, {Property: "og:url", Content: fmt.Sprintf("%s/%s", templateMap["CanonicalEndpoint"], lang)}, {Property: "og:type", Content: "website"}, }, nil @@ -63,7 +66,10 @@ func (r *HomeHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, 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["GifUrl"] = fmt.Sprintf( + supplements.PhotoStorage.HomePageGifs.BaseUrl, + supplements.PhotoStorage.HomePageGifs.Indexes[rand.Int()%len(supplements.PhotoStorage.HomePageGifs.Indexes)], + ) templateMap["FunFacts"] = supplements.FactGiver.Give(lang) return fiber.StatusOK, nil } diff --git a/internal/router/router.go b/internal/router/router.go index e8fb47a..926d21a 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -101,6 +101,8 @@ type Supplements struct { TemplateManager *templatemanager.TemplateManager MarkdownRenderer goldmark.Markdown Meta config.MetaConfig + PhotoStorage config.PhotoStorageConfig + StaticStorage config.PhotoTypeConfig } type Router struct { @@ -156,7 +158,7 @@ func NewRouter(cfg *config.Config) (*Router, error) { supplements.MarkdownRenderer = goldmark.New( goldmark.WithExtensions( - glightbox.NewGLightboxExtension(), + glightbox.NewGLightboxExtension(cfg.PhotoStorage), tailwind.NewTailwindExtension(), ), goldmark.WithParserOptions( @@ -217,6 +219,8 @@ func NewRouter(cfg *config.Config) (*Router, error) { } supplements.Meta = cfg.Meta + supplements.PhotoStorage = cfg.PhotoStorage + supplements.StaticStorage = cfg.StaticStorage enablePrintRoutes := false if cfg.LogLevel <= slog.LevelDebug { @@ -229,7 +233,7 @@ func NewRouter(cfg *config.Config) (*Router, error) { }) app.Use(cors.New(cors.Config{ - AllowOrigins: "https://f003.backblazeb2.com", + AllowOrigins: strings.Join(cfg.AllowOrigins, ","), AllowMethods: "GET,POST,OPTIONS", AllowHeaders: "Origin, Content-Type, Accept", })) @@ -299,11 +303,13 @@ func (r *Router) InitRoutes() (err error) { } defaultMap := fiber.Map{ - "L": r.supplements.Localization[lang], - "Lang": lang, - "Path": trimmedPath, - "QueryString": queryString, - "CanonicalEndpoint": r.canonicalEndpoint, + "L": r.supplements.Localization[lang], + "Lang": lang, + "Path": trimmedPath, + "QueryString": queryString, + "CanonicalEndpoint": r.canonicalEndpoint, + "StaticStorageBaseUrl": r.supplements.StaticStorage.BaseUrl, + "ThumbnailBaseUrl": r.supplements.PhotoStorage.Thumbnail320p.BaseUrl, } statusCode, err := currentRoute.Render(c, r.supplements, lang, defaultMap) @@ -423,10 +429,12 @@ func (r *Router) generalPage(c *fiber.Ctx, route Route, lang string) error { } valueMap := fiber.Map{ - "L": r.supplements.Localization[lang], - "Lang": lang, - "QueryString": queryString, - "CanonicalEndpoint": r.canonicalEndpoint, + "L": r.supplements.Localization[lang], + "Lang": lang, + "QueryString": queryString, + "CanonicalEndpoint": r.canonicalEndpoint, + "StaticStorageBaseUrl": r.supplements.StaticStorage.BaseUrl, + "ThumbnailBaseUrl": r.supplements.PhotoStorage.Thumbnail320p.BaseUrl, } var err error @@ -503,11 +511,13 @@ func (r *Router) generalPageSegment(c *fiber.Ctx, part string) error { var statusCode int defaultMap := fiber.Map{ - "L": r.supplements.Localization[lang], - "Lang": lang, - "Path": strings.Trim(path, "/"), - "QueryString": queryString, - "CanonicalEndpoint": r.canonicalEndpoint, + "L": r.supplements.Localization[lang], + "Lang": lang, + "Path": strings.Trim(path, "/"), + "QueryString": queryString, + "CanonicalEndpoint": r.canonicalEndpoint, + "StaticStorageBaseUrl": r.supplements.StaticStorage.BaseUrl, + "ThumbnailBaseUrl": r.supplements.PhotoStorage.Thumbnail320p.BaseUrl, } switch part { diff --git a/views/layouts/general-page.html b/views/layouts/general-page.html index b620566..1265266 100644 --- a/views/layouts/general-page.html +++ b/views/layouts/general-page.html @@ -13,8 +13,8 @@ {{- end }} {{- end }} - - + + {{- with .LinkedData }} - + - - - + + + + - - + + +