summaryrefslogtreecommitdiff
diff options
from:
to:
context:
space:
mode:
authorGravatar SayaAndy <saya.andy@posteo.com> 2026-04-15 16:26:00 +0700
committerGravatar SayaAndy <saya.andy@posteo.com> 2026-04-15 16:26:00 +0700
commit129e48683f6fc0615b606a737f2afcd50538bb2e (patch)
tree4c9481f725bef512ca58cb6daaef467f9ca1de2c
parentd3b9bd54cc3e13b5a9d16404717551238eab18c6 (diff)
downloadarticlator-129e48683f6fc0615b606a737f2afcd50538bb2e.tar.gz
articlator-129e48683f6fc0615b606a737f2afcd50538bb2e.zip
feat: s3 storage client
feat: claude.md
-rw-r--r--CLAUDE.md38
-rw-r--r--config/config.go18
-rw-r--r--go.mod28
-rw-r--r--go.sum47
-rw-r--r--internal/storage/s3.go224
-rw-r--r--internal/storage/storage_interface.go1
6 files changed, 352 insertions, 4 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..f96e149
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,38 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+Go service that extracts YAML frontmatter metadata from markdown files stored in cloud object storage (Backblaze B2 or AWS S3), then writes that metadata back as object attributes/metadata. Supports draft/prod workflow where draft files (`.draft.md`) are compared against prod (`.md`) and skipped if unchanged.
+
+## Commands
+
+```bash
+# Build
+go build -o metadata-extractor main.go
+
+# Run (requires B2_KEY_ID and B2_APPLICATION_KEY env vars, see .envrc)
+go run main.go -c config/config.json
+
+# No tests or linter configured yet
+```
+
+## Architecture
+
+Three-layer pipeline: **Config → Storage → Frontmatter parsing**
+
+- `main.go` — Entry point. Loads config, scans bucket, fans out goroutines (semaphore-bounded) to process each file: read → parse frontmatter → write metadata back as object attributes.
+- `config/config.go` — Loads JSON config with `os.ExpandEnv()` for credential injection. Validates via `validator/v10` struct tags. Storage type (`"b2"` or `"s3"`) selects which config struct and client to use.
+- `internal/storage/storage_interface.go` — `StorageClient` interface (`Scan`, `GetReader`, `WriteMetadata`, `CompareDraftAndProd`). Factory map registers all backends.
+- `internal/storage/b2.go` — Backblaze B2 implementation. Uses SHA1 for draft/prod change tracking. Writes metadata to B2 object `Info` map.
+- `internal/storage/s3.go` — AWS S3 implementation (AWS SDK v2). Uses ETag for draft/prod change tracking. Writes metadata as S3 user metadata. Supports custom `Endpoint` + `UsePathStyle` for S3-compatible stores (MinIO, etc).
+- `internal/frontmatter/parser.go` — Extracts content between `---` delimiters, unmarshals YAML into `Metadata` struct.
+
+## Key Conventions
+
+- Metadata keys use kebab-case (e.g., `short-description`, `action-date`) — stored in B2 `Info` map or S3 user metadata
+- Geolocation format: `"{x} {y}"` or `"{x} {y} {areaError}"` — space-separated floats, validated in both storage backends
+- Change tracking: B2 uses SHA1 (`metadata-last-update-sha1`), S3 uses ETag (`metadata-last-update-etag`)
+- Semantic commit messages: `feat:`, `fix:`, `refactor:`
+- Structured logging via `log/slog`
diff --git a/config/config.go b/config/config.go
index b41be13..543413f 100644
--- a/config/config.go
+++ b/config/config.go
@@ -24,7 +24,7 @@ type DraftModeConfig struct {
}
type StorageConfig struct {
- Type string `json:"Type" validate:"required,oneof=b2"`
+ Type string `json:"Type" validate:"required,oneof=b2 s3"`
Config any `json:"Config" validate:"required"`
}
@@ -47,6 +47,12 @@ func (sc *StorageConfig) UnmarshalJSON(data []byte) error {
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)
}
@@ -62,6 +68,16 @@ type B2Config struct {
ApplicationKey string `json:"ApplicationKey"`
}
+type S3Config struct {
+ BucketName string `json:"BucketName" validate:"required,min=1"`
+ Region string `json:"Region" validate:"required,min=1"`
+ Prefix string `json:"Prefix"`
+ Endpoint string `json:"Endpoint"`
+ UsePathStyle bool `json:"UsePathStyle"`
+ AccessKeyID string `json:"AccessKeyID"`
+ SecretAccessKey string `json:"SecretAccessKey"`
+}
+
func LoadConfig(path string, config *Config) error {
fileBytes, err := os.ReadFile(path)
if err != nil {
diff --git a/go.mod b/go.mod
index 1b97d17..40d2417 100644
--- a/go.mod
+++ b/go.mod
@@ -3,15 +3,37 @@ module github.com/SayaAndy/saya-today-article-metadata-add
go 1.24.5
require (
- github.com/Backblaze/blazer v0.7.2 // indirect
+ github.com/Backblaze/blazer v0.7.2
+ github.com/aws/aws-sdk-go-v2 v1.41.5
+ github.com/aws/aws-sdk-go-v2/config v1.32.14
+ github.com/aws/aws-sdk-go-v2/credentials v1.19.14
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0
+ github.com/go-playground/validator/v10 v10.27.0
+ gopkg.in/yaml.v3 v3.0.1
+)
+
+require (
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // 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/ini v1.8.6 // 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/signin v1.0.9 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
+ github.com/aws/smithy-go v1.24.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
- github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 7984445..0036623 100644
--- a/go.sum
+++ b/go.sum
@@ -1,7 +1,49 @@
github.com/Backblaze/blazer v0.7.2 h1:UWNHMLB+Nf+UmbO2qkVvgriODLEMz4kIyr2Hm+DVXQM=
github.com/Backblaze/blazer v0.7.2/go.mod h1:T4y3EYa9IQ5J0PKc/C/J8/CEnSd3qa/lgNw938wZg10=
+github.com/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/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
+github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
+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/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
+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/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
+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/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
+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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
+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=
@@ -10,6 +52,10 @@ github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHO
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
@@ -18,6 +64,7 @@ golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/storage/s3.go b/internal/storage/s3.go
new file mode 100644
index 0000000..5e9bcd2
--- /dev/null
+++ b/internal/storage/s3.go
@@ -0,0 +1,224 @@
+package storage
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/credentials"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+
+ "github.com/SayaAndy/saya-today-article-metadata-add/config"
+ "github.com/SayaAndy/saya-today-article-metadata-add/internal/frontmatter"
+)
+
+var _ StorageClient = &S3StorageClient{}
+
+type S3StorageClient struct {
+ prefix string
+ bucket string
+ client *s3.Client
+ draftModeCfg *config.DraftModeConfig
+}
+
+func NewS3StorageClient(cfg *config.StorageConfig, draftModeCfg *config.DraftModeConfig) (StorageClient, error) {
+ if cfg.Type != "s3" {
+ return nil, fmt.Errorf("invalid storage type for S3StorageClient")
+ }
+ s3cfg := cfg.Config.(*config.S3Config)
+
+ opts := []func(*awsconfig.LoadOptions) error{
+ awsconfig.WithRegion(s3cfg.Region),
+ }
+ if s3cfg.AccessKeyID != "" && s3cfg.SecretAccessKey != "" {
+ opts = append(opts, awsconfig.WithCredentialsProvider(
+ credentials.NewStaticCredentialsProvider(s3cfg.AccessKeyID, s3cfg.SecretAccessKey, ""),
+ ))
+ }
+
+ awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(), opts...)
+ if err != nil {
+ return nil, fmt.Errorf("load AWS config: %w", err)
+ }
+
+ var s3Opts []func(*s3.Options)
+ if s3cfg.Endpoint != "" {
+ s3Opts = append(s3Opts, func(o *s3.Options) {
+ o.BaseEndpoint = aws.String(s3cfg.Endpoint)
+ })
+ }
+ s3Opts = append(s3Opts, func(o *s3.Options) {
+ o.UsePathStyle = s3cfg.UsePathStyle
+ })
+
+ client := s3.NewFromConfig(awsCfg, s3Opts...)
+
+ draftModeCfgCopy := *draftModeCfg
+
+ return &S3StorageClient{
+ client: client,
+ bucket: s3cfg.BucketName,
+ prefix: s3cfg.Prefix,
+ draftModeCfg: &draftModeCfgCopy,
+ }, nil
+}
+
+func (sc *S3StorageClient) Scan() ([]string, error) {
+ var filePaths []string
+
+ paginator := s3.NewListObjectsV2Paginator(sc.client, &s3.ListObjectsV2Input{
+ Bucket: aws.String(sc.bucket),
+ Prefix: aws.String(sc.prefix),
+ })
+
+ for paginator.HasMorePages() {
+ page, err := paginator.NextPage(context.Background())
+ if err != nil {
+ return nil, fmt.Errorf("list S3 objects: %w", err)
+ }
+
+ for _, obj := range page.Contents {
+ name := aws.ToString(obj.Key)
+ if !strings.HasSuffix(name, ".md") {
+ continue
+ }
+
+ if sc.draftModeCfg.Enabled && !strings.HasSuffix(name, sc.draftModeCfg.DraftSuffix) {
+ continue
+ }
+
+ filePaths = append(filePaths, strings.TrimPrefix(name, sc.prefix))
+ }
+ }
+
+ return filePaths, nil
+}
+
+func (sc *S3StorageClient) GetReader(path string) (io.ReadCloser, int64, error) {
+ key := sc.prefix + path
+
+ out, err := sc.client.GetObject(context.Background(), &s3.GetObjectInput{
+ Bucket: aws.String(sc.bucket),
+ Key: aws.String(key),
+ })
+ if err != nil {
+ return nil, 0, fmt.Errorf("get S3 object %q: %w", key, err)
+ }
+
+ return out.Body, aws.ToInt64(out.ContentLength), nil
+}
+
+func (sc *S3StorageClient) WriteMetadata(path string, metadata *frontmatter.Metadata) error {
+ draftKey := sc.prefix + path
+
+ getOut, err := sc.client.GetObject(context.Background(), &s3.GetObjectInput{
+ Bucket: aws.String(sc.bucket),
+ Key: aws.String(draftKey),
+ })
+ if err != nil {
+ return fmt.Errorf("get draft object %q: %w", draftKey, err)
+ }
+ content, err := io.ReadAll(getOut.Body)
+ getOut.Body.Close()
+ if err != nil {
+ return fmt.Errorf("read draft object %q: %w", draftKey, err)
+ }
+
+ headOut, err := sc.client.HeadObject(context.Background(), &s3.HeadObjectInput{
+ Bucket: aws.String(sc.bucket),
+ Key: aws.String(draftKey),
+ })
+ if err != nil {
+ return fmt.Errorf("head draft object %q: %w", draftKey, err)
+ }
+ draftETag := aws.ToString(headOut.ETag)
+
+ geolocationParts := strings.Split(metadata.Geolocation, " ")
+ if (len(geolocationParts) == 1 && geolocationParts[0] != "") || len(geolocationParts) >= 4 {
+ return fmt.Errorf("invalid geolocation format, expecting '{x} {y} [areaError]' or an empty string")
+ }
+ if len(geolocationParts) >= 2 {
+ if _, err := strconv.ParseFloat(geolocationParts[0], 64); err != nil {
+ return fmt.Errorf("invalid geolocation parameter, expected float for X: %w", err)
+ }
+ if _, err := strconv.ParseFloat(geolocationParts[1], 64); err != nil {
+ return fmt.Errorf("invalid geolocation parameter, expected float for Y: %w", err)
+ }
+ }
+ if len(geolocationParts) == 3 {
+ if _, err := strconv.ParseFloat(geolocationParts[2], 64); err != nil {
+ return fmt.Errorf("invalid geolocation parameter, expected float for area error: %w", err)
+ }
+ }
+
+ medley := ""
+ if metadata.Medley != "" {
+ medley = fmt.Sprintf("%s %d", metadata.Medley, metadata.MedleyPart)
+ }
+
+ s3Metadata := map[string]string{
+ "title": metadata.Title,
+ "short-description": metadata.ShortDescription,
+ "action-date": metadata.ActionDate,
+ "published-time": metadata.PublishedTime.Format(time.RFC3339),
+ "thumbnail": metadata.Thumbnail,
+ "tags": strings.Join(metadata.Tags, ","),
+ "geolocation": metadata.Geolocation,
+ "medley": medley,
+ "metadata-last-update-etag": draftETag,
+ }
+
+ targetKey := draftKey
+ if sc.draftModeCfg.Enabled {
+ prodPath := strings.TrimSuffix(path, sc.draftModeCfg.DraftSuffix) + sc.draftModeCfg.ProdSuffix
+ targetKey = sc.prefix + prodPath
+ }
+
+ _, err = sc.client.PutObject(context.Background(), &s3.PutObjectInput{
+ Bucket: aws.String(sc.bucket),
+ Key: aws.String(targetKey),
+ Body: bytes.NewReader(content),
+ ContentType: aws.String("text/markdown; charset=utf-8"),
+ Metadata: s3Metadata,
+ })
+ if err != nil {
+ return fmt.Errorf("put S3 object %q: %w", targetKey, err)
+ }
+
+ return nil
+}
+
+func (sc *S3StorageClient) CompareDraftAndProd(path string) bool {
+ draftKey := sc.prefix + path
+ prodPath := strings.TrimSuffix(path, sc.draftModeCfg.DraftSuffix) + sc.draftModeCfg.ProdSuffix
+ prodKey := sc.prefix + prodPath
+
+ draftHead, err := sc.client.HeadObject(context.Background(), &s3.HeadObjectInput{
+ Bucket: aws.String(sc.bucket),
+ Key: aws.String(draftKey),
+ })
+ if err != nil {
+ return false
+ }
+
+ prodHead, err := sc.client.HeadObject(context.Background(), &s3.HeadObjectInput{
+ Bucket: aws.String(sc.bucket),
+ Key: aws.String(prodKey),
+ })
+ if err != nil {
+ return true
+ }
+
+ lastUpdateETag, ok := prodHead.Metadata["metadata-last-update-etag"]
+ if !ok {
+ return true
+ }
+
+ return aws.ToString(draftHead.ETag) != lastUpdateETag
+}
diff --git a/internal/storage/storage_interface.go b/internal/storage/storage_interface.go
index 02e0790..7134e30 100644
--- a/internal/storage/storage_interface.go
+++ b/internal/storage/storage_interface.go
@@ -16,4 +16,5 @@ type StorageClient interface {
var NewStorageClientMap = map[string]func(*config.StorageConfig, *config.DraftModeConfig) (StorageClient, error){
"b2": NewB2StorageClient,
+ "s3": NewS3StorageClient,
}