diff options
| author | 2026-04-15 16:11:51 +0700 | |
|---|---|---|
| committer | 2026-04-15 16:11:51 +0700 | |
| commit | 66b2a96fcca113105043be3cb9c49f8ee7fe240c (patch) | |
| tree | 04a9d8b816f6507076bf778769f7990bf153fce4 | |
| parent | a3aa1dd07abfbbb27a7dfeeb536a6fcc51709256 (diff) | |
| download | web-66b2a96fcca113105043be3cb9c49f8ee7fe240c.tar.gz web-66b2a96fcca113105043be3cb9c49f8ee7fe240c.zip | |
feat: support s3 for blog pages
refactor: make blog client an interface
feat: claude.md
| -rw-r--r-- | CLAUDE.md | 62 | ||||
| -rw-r--r-- | config/config.go | 79 | ||||
| -rw-r--r-- | go.mod | 12 | ||||
| -rw-r--r-- | go.sum | 24 | ||||
| -rw-r--r-- | internal/blog/b2.go (renamed from internal/b2/client.go) | 29 | ||||
| -rw-r--r-- | internal/blog/client.go | 27 | ||||
| -rw-r--r-- | internal/blog/s3.go | 140 | ||||
| -rw-r--r-- | internal/blogtrigger/blogtrigger.go | 22 | ||||
| -rw-r--r-- | internal/factgiver/factgiver.go | 10 | ||||
| -rw-r--r-- | internal/mailer/mailer.go | 4 | ||||
| -rw-r--r-- | internal/router/handlers/api-v1-blog-search.go | 6 | ||||
| -rw-r--r-- | internal/router/handlers/api-v1-like-get.go | 2 | ||||
| -rw-r--r-- | internal/router/handlers/api-v1-like-put.go | 2 | ||||
| -rw-r--r-- | internal/router/handlers/lang-blog-title.go | 16 | ||||
| -rw-r--r-- | internal/router/handlers/lang-blog.go | 8 | ||||
| -rw-r--r-- | internal/router/handlers/lang-map.go | 8 | ||||
| -rw-r--r-- | internal/router/handlers/lang-user.go | 2 | ||||
| -rw-r--r-- | internal/router/router.go | 10 |
18 files changed, 400 insertions, 63 deletions
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"` @@ -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 @@ -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/blog/b2.go index fda79e0..0caa0ba 100644 --- a/internal/b2/client.go +++ b/internal/blog/b2.go @@ -1,4 +1,4 @@ -package b2 +package blog import ( "context" @@ -18,30 +18,27 @@ type B2Client struct { b2cl *b2.Client } -func NewB2Client(cfg *config.B2Config) (*B2Client, error) { - b2cl, err := b2.NewClient(context.Background(), cfg.KeyID, cfg.ApplicationKey) +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(), cfg.BucketName) + bucket, err := b2cl.Bucket(context.Background(), b2cfg.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 + return &B2Client{b2cl: b2cl, bucket: bucket, prefix: b2cfg.Prefix}, nil } -func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) { - filePaths := []*BlogPage{} +func (c *B2Client) Scan(prefix string) ([]*Page, error) { + filePaths := []*Page{} iter := c.bucket.List(context.Background(), b2.ListPrefix(c.prefix+prefix)) @@ -82,7 +79,7 @@ func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) { lang, _ := strings.CutPrefix(linkParts[0], c.prefix) - filePaths = append(filePaths, &BlogPage{ + filePaths = append(filePaths, &Page{ Link: obj.Name(), FileName: fileName, Lang: lang, 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 |