diff --git a/README.md b/README.md index ec4b028..304208a 100644 --- a/README.md +++ b/README.md @@ -253,9 +253,22 @@ PORT=8080 ENV=development FRONTEND_URL=http://localhost:5173 -# Storage -STORAGE_TYPE=local -UPLOAD_DIR=./uploads +# Storage — pluggable backend (local | s3) +STORAGE_BACKEND=local +STORAGE_LOCAL_PATH=./uploads +STORAGE_URL_PREFIX=/static + +# S3-compatible (AWS S3, Cloudflare R2, MinIO, Backblaze B2, DO Spaces, ...) +# Uncomment and set STORAGE_BACKEND=s3 to use: +# STORAGE_S3_BUCKET=my-bucket +# STORAGE_S3_REGION=us-east-1 # for AWS — Cloudflare R2 wants "auto" +# STORAGE_S3_ACCESS_KEY_ID= +# STORAGE_S3_SECRET_ACCESS_KEY= +# STORAGE_S3_ENDPOINT= # set for R2 (https://.r2.cloudflarestorage.com), MinIO, B2 +# STORAGE_S3_USE_PATH_STYLE=false # true for MinIO + +# Legacy aliases (still honored as fallback): STORAGE_TYPE, UPLOAD_DIR, +# AWS_BUCKET, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY # Payments (optional) STRIPE_KEY= diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 5cdbb15..2e06ab1 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -17,6 +17,7 @@ import ( "github.com/afa/blueprint/backend/internal/handlers" "github.com/afa/blueprint/backend/internal/infrastructure" + "github.com/afa/blueprint/backend/internal/infrastructure/storage" "github.com/afa/blueprint/backend/migrations" "github.com/afa/blueprint/backend/pkg/config" "github.com/afa/blueprint/backend/pkg/database" @@ -113,6 +114,18 @@ func main() { return c.JSON(result) }) + // Storage backend (local or s3) + resolvedBackend := cfg.StorageBackend + if resolvedBackend == "" { + resolvedBackend = "local" + } + storageBackend, err := storage.NewFromConfig(c.Background(), cfg) + if err != nil { + log.Printf("Storage init failed: %v", err) + return + } + log.Printf("Storage backend: %s", resolvedBackend) + // Repositories userRepo := infrastructure.NewUserRepo(pool) flagRepo := infrastructure.NewFeatureFlagRepo(pool) @@ -158,9 +171,9 @@ func main() { adminHandler := handlers.NewAdminHandler(userRepo, bannerRepo, linktreeRepo, brandKitRepo, emailGroupRepo, emailSubRepo, userGroupRepo, cfg) storeHandler := handlers.NewStoreHandler(productRepo, categoryRepo, orderRepo, couponRepo, cfg) couponHandler := handlers.NewCouponHandler(couponRepo) - paymentHandler := handlers.NewPaymentHandler(orderRepo, pixConfigRepo, cfg) + paymentHandler := handlers.NewPaymentHandler(orderRepo, pixConfigRepo, cfg, storageBackend) blogRepo := infrastructure.NewBlogRepo(pool) - blogHandler := handlers.NewBlogHandler(blogRepo, cfg) + blogHandler := handlers.NewBlogHandler(blogRepo, cfg, storageBackend) cronJobRepo := infrastructure.NewCronJobRepo(pool) jobExecRepo := infrastructure.NewJobExecutionRepo(pool) toolRepo := infrastructure.NewAdminToolRepo(pool) @@ -397,8 +410,22 @@ func main() { admin.Get("/config/export", envConfigHandler.ExportEnv) admin.Post("/config/import", envConfigHandler.ImportEnv) - // Static file serving - app.Static("/static", cfg.UploadDir) + // Static file serving — only registered when running on the local + // backend. For S3, file URLs are presigned and served by S3 directly, + // so exposing the local upload dir would only leak local artifacts. + if resolvedBackend == "local" { + staticRoot := cfg.StorageLocalPath + if staticRoot == "" { + staticRoot = cfg.UploadDir + } + staticMount := cfg.StorageURLPrefix + if staticMount == "" { + staticMount = "/static" + } + app.Static(staticMount, staticRoot) + } else { + log.Printf("Static file serving disabled (backend=%s)", resolvedBackend) + } log.Printf("Server starting on :%s", cfg.Port) if err := app.Listen(":" + cfg.Port); err != nil { diff --git a/backend/go.mod b/backend/go.mod index 2349a26..f17e4e0 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,19 +3,39 @@ module github.com/afa/blueprint/backend go 1.25.0 require ( + github.com/aws/aws-sdk-go-v2 v1.41.6 + github.com/aws/aws-sdk-go-v2/config v1.32.16 + github.com/aws/aws-sdk-go-v2/credentials v1.19.15 + github.com/aws/aws-sdk-go-v2/service/s3 v1.100.0 + github.com/aws/smithy-go v1.25.0 github.com/gofiber/fiber/v2 v2.52.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.9.1 + github.com/prometheus/client_golang v1.23.2 github.com/redis/go-redis/v9 v9.18.0 github.com/robfig/cron/v3 v3.0.1 github.com/stripe/stripe-go/v82 v82.5.1 + github.com/valyala/fasthttp v1.51.0 golang.org/x/crypto v0.49.0 ) require ( github.com/andybalholm/brotli v1.0.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -23,18 +43,17 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/kr/text v0.2.0 // indirect github.com/lib/pq v1.10.9 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect diff --git a/backend/go.sum b/backend/go.sum index fc4b7bf..38693a7 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -4,6 +4,42 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= +github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9/go.mod h1:uOYhgfgThm/ZyAuJGNQ5YgNyOlYfqnGpTHXvk3cpykg= +github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM= +github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc= +github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 h1:xnvDEnw+pnj5mctWiYuFbigrEzSm35x7k4KS/ZkCANg= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14/go.mod h1:yS5rNogD8e0Wu9+l3MUwr6eENBzEeGejvINpN5PAYfY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 h1:SE+aQ4DEqG53RRCAIHlCf//B2ycxGH7jFkpnAh/kKPM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22/go.mod h1:ES3ynECd7fYeJIL6+oax+uIEljmfps0S70BaQzbMd/o= +github.com/aws/aws-sdk-go-v2/service/s3 v1.100.0 h1:7G26Sae6PMKn4kMcU5JzNfrm1YrKwyOhowXPYR2WiWY= +github.com/aws/aws-sdk-go-v2/service/s3 v1.100.0/go.mod h1:Fw9aqhJicIVee1VytBBjH+l+5ov6/PhbtIK/u3rt/ls= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo= +github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= +github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -16,6 +52,7 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -45,6 +82,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -55,12 +94,16 @@ github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= -github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -101,6 +144,8 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -128,6 +173,8 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= @@ -145,6 +192,8 @@ golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/backend/internal/domain/storage.go b/backend/internal/domain/storage.go new file mode 100644 index 0000000..3bff5f1 --- /dev/null +++ b/backend/internal/domain/storage.go @@ -0,0 +1,39 @@ +package domain + +import ( + "context" + "errors" + "io" + "time" +) + +var ( + ErrNotFound = errors.New("not found") + ErrInvalidInput = errors.New("invalid input") +) + +// Storage persists and retrieves binary objects (uploaded files, receipts, +// covers, etc.). Production impls: infrastructure/storage/local and +// infrastructure/storage/s3. +// +// URL contract — read carefully before persisting Upload's return value: +// +// - LocalStorage.Upload returns a stable, non-expiring relative path +// (e.g. "/static/covers/xyz.png") served by the app's static handler. +// Safe to persist in the database. +// +// - S3Storage.Upload returns a presigned GET URL valid for 24h. Storing +// it directly will produce broken links after expiry. Callers that +// need durable links should persist the key and call SignedURL on +// read, or proxy reads through the backend. +// +// SignedURL is always a time-limited URL. For LocalStorage it returns the +// same stable path as Upload (TTL is advisory and ignored). For S3Storage +// it returns a fresh presigned URL valid for ttl. +type Storage interface { + Upload(ctx context.Context, key string, r io.Reader, contentType string) (url string, err error) + Download(ctx context.Context, key string) (io.ReadCloser, error) + Exists(ctx context.Context, key string) (bool, error) + SignedURL(ctx context.Context, key string, ttl time.Duration) (string, error) + Delete(ctx context.Context, key string) error +} diff --git a/backend/internal/handlers/blog.go b/backend/internal/handlers/blog.go index ac76f61..ca6a7a3 100644 --- a/backend/internal/handlers/blog.go +++ b/backend/internal/handlers/blog.go @@ -4,7 +4,9 @@ import ( "bytes" "encoding/json" "encoding/xml" + "errors" "io" + "log" "net/http" "regexp" "strings" @@ -19,8 +21,9 @@ var nonAlphanumHyphen = regexp.MustCompile(`[^a-z0-9-]`) var multipleHyphens = regexp.MustCompile(`-+`) type BlogHandler struct { - blog domain.BlogRepository - cfg *config.Config + blog domain.BlogRepository + cfg *config.Config + storage domain.Storage } type blogAIResponse struct { @@ -88,8 +91,8 @@ type atomFeed struct { Entries []atomEntry `xml:"entry"` } -func NewBlogHandler(blog domain.BlogRepository, cfg *config.Config) *BlogHandler { - return &BlogHandler{blog: blog, cfg: cfg} +func NewBlogHandler(blog domain.BlogRepository, cfg *config.Config, storage domain.Storage) *BlogHandler { + return &BlogHandler{blog: blog, cfg: cfg, storage: storage} } func slugify(s string) string { @@ -353,9 +356,13 @@ func (h *BlogHandler) AdminUploadCover(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "cover file is required") } - url, err := UploadFile(file, "covers", h.cfg) + url, err := UploadFormFile(c.Context(), h.storage, file, "covers") if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + if errors.Is(err, domain.ErrInvalidInput) { + return fiber.NewError(fiber.StatusBadRequest, "invalid upload") + } + log.Printf("blog.AdminUploadCover: upload failed (post=%s, file=%s): %v", id, file.Filename, err) + return fiber.NewError(fiber.StatusInternalServerError, "upload failed") } post.CoverImage = &url diff --git a/backend/internal/handlers/blog_test.go b/backend/internal/handlers/blog_test.go index fe837a8..aa00c41 100644 --- a/backend/internal/handlers/blog_test.go +++ b/backend/internal/handlers/blog_test.go @@ -26,7 +26,7 @@ func setupBlogApp() (*fiber.App, *testutil.MockBlogRepo) { FrontendURL: "http://localhost:3000", } - h := handlers.NewBlogHandler(blogRepo, cfg) + h := handlers.NewBlogHandler(blogRepo, cfg, nil) app.Get("/blog", h.ListPublished) app.Get("/blog/rss.xml", h.RSSFeed) diff --git a/backend/internal/handlers/payment.go b/backend/internal/handlers/payment.go index cfae613..5c463fa 100644 --- a/backend/internal/handlers/payment.go +++ b/backend/internal/handlers/payment.go @@ -1,7 +1,9 @@ package handlers import ( + "errors" "fmt" + "log" "github.com/afa/blueprint/backend/internal/domain" "github.com/afa/blueprint/backend/pkg/config" @@ -13,13 +15,14 @@ import ( ) type PaymentHandler struct { - orders domain.OrderRepository - pixCfg domain.PixConfigRepository - cfg *config.Config + orders domain.OrderRepository + pixCfg domain.PixConfigRepository + cfg *config.Config + storage domain.Storage } -func NewPaymentHandler(orders domain.OrderRepository, pixCfg domain.PixConfigRepository, cfg *config.Config) *PaymentHandler { - return &PaymentHandler{orders: orders, pixCfg: pixCfg, cfg: cfg} +func NewPaymentHandler(orders domain.OrderRepository, pixCfg domain.PixConfigRepository, cfg *config.Config, storage domain.Storage) *PaymentHandler { + return &PaymentHandler{orders: orders, pixCfg: pixCfg, cfg: cfg, storage: storage} } type createPaymentRequest struct { @@ -181,8 +184,12 @@ func (h *PaymentHandler) UploadPixReceipt(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "receipt file is required") } - url, err := UploadFile(file, "receipts", h.cfg) + url, err := UploadFormFile(c.Context(), h.storage, file, "receipts") if err != nil { + if errors.Is(err, domain.ErrInvalidInput) { + return fiber.NewError(fiber.StatusBadRequest, "invalid upload") + } + log.Printf("payment.UploadPixReceipt: upload failed (order=%s, file=%s): %v", orderID, file.Filename, err) return fiber.NewError(fiber.StatusInternalServerError, "upload failed") } diff --git a/backend/internal/handlers/upload.go b/backend/internal/handlers/upload.go index 8d4b70c..42550b3 100644 --- a/backend/internal/handlers/upload.go +++ b/backend/internal/handlers/upload.go @@ -1,27 +1,24 @@ package handlers import ( + "context" "fmt" - "io" "mime/multipart" - "os" "path/filepath" - "github.com/afa/blueprint/backend/pkg/config" + "github.com/afa/blueprint/backend/internal/domain" "github.com/google/uuid" ) -func UploadFile(file *multipart.FileHeader, prefix string, cfg *config.Config) (string, error) { - // For now, only local storage - // TODO: Add S3 support when AWS SDK is integrated - - ext := filepath.Ext(file.Filename) - filename := fmt.Sprintf("%s/%s%s", prefix, uuid.NewString(), ext) - destPath := filepath.Join(cfg.UploadDir, filename) - - if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { - return "", err +// UploadFormFile persists a multipart upload via the configured storage +// backend. The returned URL is suitable for direct use in APIs (e.g. +// /static/covers/.png for local, or a presigned GET URL for S3). +func UploadFormFile(ctx context.Context, storage domain.Storage, file *multipart.FileHeader, prefix string) (string, error) { + if storage == nil { + return "", fmt.Errorf("upload: storage is nil") } + ext := filepath.Ext(file.Filename) + key := fmt.Sprintf("%s/%s%s", prefix, uuid.NewString(), ext) src, err := file.Open() if err != nil { @@ -29,15 +26,6 @@ func UploadFile(file *multipart.FileHeader, prefix string, cfg *config.Config) ( } defer func() { _ = src.Close() }() - dst, err := os.Create(destPath) - if err != nil { - return "", err - } - defer func() { _ = dst.Close() }() - - if _, err := io.Copy(dst, src); err != nil { - return "", err - } - - return fmt.Sprintf("/static/%s", filename), nil + contentType := file.Header.Get("Content-Type") + return storage.Upload(ctx, key, src, contentType) } diff --git a/backend/internal/infrastructure/storage/factory.go b/backend/internal/infrastructure/storage/factory.go new file mode 100644 index 0000000..f25761e --- /dev/null +++ b/backend/internal/infrastructure/storage/factory.go @@ -0,0 +1,51 @@ +package storage + +import ( + "context" + "fmt" + "strings" + + "github.com/afa/blueprint/backend/internal/domain" + "github.com/afa/blueprint/backend/pkg/config" +) + +// NewFromConfig constructs a domain.Storage implementation from config. +// Reads cfg.StorageBackend: +// +// - "s3" → S3Storage (AWS, R2, MinIO via aws-sdk-go-v2) +// - "local" (default) → LocalStorage at cfg.StorageLocalPath +// +// When StorageBackend="s3" but StorageS3Bucket is empty, the factory +// returns an error rather than silently falling back — fail-loud is +// preferred to writing files to disk when the operator asked for S3. +func NewFromConfig(ctx context.Context, cfg *config.Config) (domain.Storage, error) { + backend := strings.ToLower(strings.TrimSpace(cfg.StorageBackend)) + if backend == "" { + backend = "local" + } + switch backend { + case "s3": + if cfg.StorageS3Bucket == "" { + return nil, fmt.Errorf("storage: STORAGE_BACKEND=s3 but STORAGE_S3_BUCKET is empty: %w", domain.ErrInvalidInput) + } + return NewS3Storage(ctx, S3Config{ + Bucket: cfg.StorageS3Bucket, + Region: cfg.StorageS3Region, + AccessKeyID: cfg.StorageS3AccessKeyID, + SecretAccessKey: cfg.StorageS3SecretAccessKey, + Endpoint: cfg.StorageS3Endpoint, + UsePathStyle: cfg.StorageS3UsePathStyle, + }) + case "local": + root := cfg.StorageLocalPath + if root == "" { + root = cfg.UploadDir + } + if root == "" { + root = "./uploads" + } + return NewLocalStorage(root, cfg.StorageURLPrefix), nil + default: + return nil, fmt.Errorf("storage: unknown STORAGE_BACKEND %q (use \"local\" or \"s3\")", backend) + } +} diff --git a/backend/internal/infrastructure/storage/factory_test.go b/backend/internal/infrastructure/storage/factory_test.go new file mode 100644 index 0000000..d020477 --- /dev/null +++ b/backend/internal/infrastructure/storage/factory_test.go @@ -0,0 +1,55 @@ +package storage_test + +import ( + "context" + "errors" + "testing" + + "github.com/afa/blueprint/backend/internal/domain" + "github.com/afa/blueprint/backend/internal/infrastructure/storage" + "github.com/afa/blueprint/backend/pkg/config" +) + +func TestFactory_LocalDefault(t *testing.T) { + cfg := &config.Config{StorageBackend: "local", StorageLocalPath: t.TempDir(), StorageURLPrefix: "/static"} + s, err := storage.NewFromConfig(context.Background(), cfg) + if err != nil { + t.Fatal(err) + } + if s == nil { + t.Fatal("nil storage") + } +} + +func TestFactory_S3MissingBucketFails(t *testing.T) { + cfg := &config.Config{StorageBackend: "s3"} + _, err := storage.NewFromConfig(context.Background(), cfg) + if !errors.Is(err, domain.ErrInvalidInput) { + t.Fatalf("expected ErrInvalidInput, got %v", err) + } +} + +func TestFactory_UnknownBackend(t *testing.T) { + cfg := &config.Config{StorageBackend: "floppy"} + _, err := storage.NewFromConfig(context.Background(), cfg) + if err == nil { + t.Fatal("expected error for unknown backend") + } +} + +func TestFactory_NormalizesBackend(t *testing.T) { + for _, bk := range []string{"LOCAL", " Local ", "local"} { + cfg := &config.Config{StorageBackend: bk, UploadDir: t.TempDir()} + if _, err := storage.NewFromConfig(context.Background(), cfg); err != nil { + t.Fatalf("backend=%q: %v", bk, err) + } + } +} + +func TestFactory_EmptyDefaultsToLocal(t *testing.T) { + cfg := &config.Config{StorageBackend: "", UploadDir: t.TempDir()} + s, err := storage.NewFromConfig(context.Background(), cfg) + if err != nil || s == nil { + t.Fatalf("expected local default, got err=%v", err) + } +} diff --git a/backend/internal/infrastructure/storage/local.go b/backend/internal/infrastructure/storage/local.go new file mode 100644 index 0000000..3c92dbb --- /dev/null +++ b/backend/internal/infrastructure/storage/local.go @@ -0,0 +1,246 @@ +// Package storage provides concrete domain.Storage implementations for +// persisting uploaded files. LocalStorage writes to a configurable +// filesystem root; S3Storage writes to an S3-compatible bucket with +// presigned GET URLs. +package storage + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/afa/blueprint/backend/internal/domain" +) + +// LocalStorage is a domain.Storage implementation that persists objects +// under a filesystem root. Writes are atomic (write-to-tmp + rename) +// and directory-traversal attempts are rejected. The URL returned from +// Upload is a relative path "/static/{key}" — the server mounts a static +// file handler at that mount point. +type LocalStorage struct { + root string + urlPrefix string +} + +// NewLocalStorage constructs a LocalStorage rooted at the given path. +// The directory is created (mkdir -p) on first use. urlPrefix is the +// URL path prefix returned by Upload/SignedURL (e.g. "/static"). +func NewLocalStorage(root, urlPrefix string) *LocalStorage { + if urlPrefix == "" { + urlPrefix = "/static" + } + return &LocalStorage{root: root, urlPrefix: strings.TrimRight(urlPrefix, "/")} +} + +// Root returns the filesystem root this LocalStorage writes to. +func (l *LocalStorage) Root() string { return l.root } + +// validateKey rejects empty keys, absolute paths, and traversal attempts. +func validateKey(key string) error { + if key == "" { + return fmt.Errorf("storage: empty key: %w", domain.ErrInvalidInput) + } + cleaned := filepath.ToSlash(filepath.Clean(key)) + if cleaned == ".." || strings.HasPrefix(cleaned, "../") || + strings.Contains(cleaned, "/../") || strings.HasSuffix(cleaned, "/..") { + return fmt.Errorf("storage: traversal key %q: %w", key, domain.ErrInvalidInput) + } + for _, seg := range strings.Split(strings.ReplaceAll(key, "\\", "/"), "/") { + if seg == ".." { + return fmt.Errorf("storage: traversal key %q: %w", key, domain.ErrInvalidInput) + } + } + if filepath.IsAbs(key) { + return fmt.Errorf("storage: absolute key %q: %w", key, domain.ErrInvalidInput) + } + return nil +} + +func (l *LocalStorage) resolve(key string) (string, error) { + if err := validateKey(key); err != nil { + return "", err + } + return filepath.Join(l.root, filepath.FromSlash(key)), nil +} + +// checkContains verifies that dst, after resolving any symlinks on its +// existing components, is still inside l.root (also symlink-resolved). +// dst itself need not exist — the check walks up to the first existing +// ancestor and resolves there. Callers must invoke this AFTER any +// MkdirAll for new uploads, so that the parent directory exists and is +// resolved (otherwise a symlink planted at the parent slips through). +func (l *LocalStorage) checkContains(dst string) error { + realRoot, err := filepath.EvalSymlinks(l.root) + if err != nil { + // Root absent on first write is normal; nothing to escape into. + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("storage: evalsymlinks root: %w", err) + } + if realRoot, err = filepath.Abs(realRoot); err != nil { + return fmt.Errorf("storage: abs root: %w", err) + } + + // Walk dst upward to the first component that exists, then resolve. + target := dst + for { + real, err := filepath.EvalSymlinks(target) + if err == nil { + if real, err = filepath.Abs(real); err != nil { + return fmt.Errorf("storage: abs dst: %w", err) + } + rel, err := filepath.Rel(realRoot, real) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("storage: key escapes root: %w", domain.ErrInvalidInput) + } + return nil + } + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("storage: evalsymlinks: %w", err) + } + parent := filepath.Dir(target) + if parent == target { + // reached filesystem root without finding anything that exists + return nil + } + target = parent + } +} + +func (l *LocalStorage) publicURL(key string) string { + return l.urlPrefix + "/" + strings.TrimPrefix(filepath.ToSlash(key), "/") +} + +// Upload writes the body to {root}/{key} via a write-to-tmp + rename +// atomic swap. Parent directories are created on demand. +func (l *LocalStorage) Upload(ctx context.Context, key string, r io.Reader, _ string) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + dst, err := l.resolve(key) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return "", fmt.Errorf("storage: mkdir: %w", err) + } + // Containment must run AFTER MkdirAll (so the parent dir exists and + // EvalSymlinks resolves it) and BEFORE CreateTemp (so we never write + // to a path that resolves outside root via a planted symlink). + if err := l.checkContains(dst); err != nil { + return "", err + } + tmp, err := os.CreateTemp(filepath.Dir(dst), ".upload-*.tmp") + if err != nil { + return "", fmt.Errorf("storage: create tmp: %w", err) + } + tmpName := tmp.Name() + cleanup := func() { _ = os.Remove(tmpName) } + if _, err := io.Copy(tmp, r); err != nil { + _ = tmp.Close() + cleanup() + return "", fmt.Errorf("storage: copy body: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + cleanup() + return "", fmt.Errorf("storage: fsync: %w", err) + } + // os.CreateTemp creates with mode 0o600 (only owner readable). + // Static file servers running under a different user need 0o644 + // to actually serve the bytes. + if err := tmp.Chmod(0o644); err != nil { + _ = tmp.Close() + cleanup() + return "", fmt.Errorf("storage: chmod: %w", err) + } + if err := tmp.Close(); err != nil { + cleanup() + return "", fmt.Errorf("storage: close tmp: %w", err) + } + if err := os.Rename(tmpName, dst); err != nil { + cleanup() + return "", fmt.Errorf("storage: rename: %w", err) + } + return l.publicURL(key), nil +} + +// Download opens {root}/{key} for reading. Returns domain.ErrNotFound +// if the object is absent. +func (l *LocalStorage) Download(ctx context.Context, key string) (io.ReadCloser, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + src, err := l.resolve(key) + if err != nil { + return nil, err + } + if err := l.checkContains(src); err != nil { + return nil, err + } + f, err := os.Open(src) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, domain.ErrNotFound + } + return nil, fmt.Errorf("storage: open: %w", err) + } + return f, nil +} + +// Exists reports whether {root}/{key} is a regular file. +func (l *LocalStorage) Exists(ctx context.Context, key string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + p, err := l.resolve(key) + if err != nil { + return false, err + } + if err := l.checkContains(p); err != nil { + return false, err + } + st, err := os.Stat(p) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("storage: stat: %w", err) + } + return st.Mode().IsRegular(), nil +} + +// SignedURL returns the same public URL as Upload. Local storage has +// no cryptographic signing — the TTL is advisory and ignored. +func (l *LocalStorage) SignedURL(_ context.Context, key string, _ time.Duration) (string, error) { + if err := validateKey(key); err != nil { + return "", err + } + return l.publicURL(key), nil +} + +// Delete removes {root}/{key}. Missing keys are not an error. +func (l *LocalStorage) Delete(ctx context.Context, key string) error { + if err := ctx.Err(); err != nil { + return err + } + p, err := l.resolve(key) + if err != nil { + return err + } + if err := l.checkContains(p); err != nil { + return err + } + if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("storage: delete: %w", err) + } + return nil +} + +var _ domain.Storage = (*LocalStorage)(nil) diff --git a/backend/internal/infrastructure/storage/local_test.go b/backend/internal/infrastructure/storage/local_test.go new file mode 100644 index 0000000..8bbd133 --- /dev/null +++ b/backend/internal/infrastructure/storage/local_test.go @@ -0,0 +1,173 @@ +package storage_test + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/afa/blueprint/backend/internal/domain" + "github.com/afa/blueprint/backend/internal/infrastructure/storage" +) + +func TestLocalStorage_UploadDownload(t *testing.T) { + dir := t.TempDir() + s := storage.NewLocalStorage(dir, "/static") + ctx := context.Background() + + url, err := s.Upload(ctx, "covers/x.png", bytes.NewReader([]byte("hello")), "image/png") + if err != nil { + t.Fatalf("upload: %v", err) + } + if url != "/static/covers/x.png" { + t.Fatalf("unexpected url: %s", url) + } + + // Persisted on disk + if _, err := os.Stat(filepath.Join(dir, "covers", "x.png")); err != nil { + t.Fatalf("file not persisted: %v", err) + } + + rc, err := s.Download(ctx, "covers/x.png") + if err != nil { + t.Fatalf("download: %v", err) + } + defer func() { _ = rc.Close() }() + b, _ := io.ReadAll(rc) + if string(b) != "hello" { + t.Fatalf("round-trip mismatch: %s", b) + } +} + +func TestLocalStorage_TraversalRejected(t *testing.T) { + dir := t.TempDir() + s := storage.NewLocalStorage(dir, "/static") + ctx := context.Background() + + cases := []string{"../etc/passwd", "a/../../b", "/abs/path", ""} + for _, k := range cases { + if _, err := s.Upload(ctx, k, strings.NewReader("x"), ""); err == nil { + t.Fatalf("expected rejection for key %q", k) + } else if !errors.Is(err, domain.ErrInvalidInput) { + t.Fatalf("expected ErrInvalidInput for %q, got %v", k, err) + } + } +} + +func TestLocalStorage_DownloadMissing(t *testing.T) { + dir := t.TempDir() + s := storage.NewLocalStorage(dir, "/static") + _, err := s.Download(context.Background(), "missing.txt") + if !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestLocalStorage_SurvivesRestart(t *testing.T) { + dir := t.TempDir() + ctx := context.Background() + + s1 := storage.NewLocalStorage(dir, "/static") + if _, err := s1.Upload(ctx, "a/b.txt", strings.NewReader("persist"), ""); err != nil { + t.Fatalf("upload: %v", err) + } + + // Simulate restart: brand-new storage instance, same root + s2 := storage.NewLocalStorage(dir, "/static") + ok, err := s2.Exists(ctx, "a/b.txt") + if err != nil || !ok { + t.Fatalf("persistence check: ok=%v err=%v", ok, err) + } + rc, err := s2.Download(ctx, "a/b.txt") + if err != nil { + t.Fatalf("download after restart: %v", err) + } + defer func() { _ = rc.Close() }() + b, _ := io.ReadAll(rc) + if string(b) != "persist" { + t.Fatalf("content lost across restart: %s", b) + } +} + +func TestLocalStorage_DeleteIdempotent(t *testing.T) { + dir := t.TempDir() + s := storage.NewLocalStorage(dir, "/static") + ctx := context.Background() + if _, err := s.Upload(ctx, "k.txt", strings.NewReader("x"), ""); err != nil { + t.Fatal(err) + } + if err := s.Delete(ctx, "k.txt"); err != nil { + t.Fatalf("delete: %v", err) + } + if err := s.Delete(ctx, "k.txt"); err != nil { + t.Fatalf("second delete should be idempotent: %v", err) + } +} + +// TestLocalStorage_SymlinkEscapeRejected proves the EvalSymlinks-based +// containment check rejects keys whose resolved path lands outside root, +// even when the textual key passes validateKey. We plant a symlink under +// root pointing at /tmp and try to write through it. +func TestLocalStorage_SymlinkEscapeRejected(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks under Windows require elevated privileges") + } + root := t.TempDir() + outside := t.TempDir() // a directory outside root + + // Plant: /escape -> + if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil { + t.Fatalf("symlink: %v", err) + } + + s := storage.NewLocalStorage(root, "/static") + ctx := context.Background() + + // All three operations must reject the escape — Upload requires the + // containment check before CreateTemp, and Download/Exists/Delete + // require it before touching the resolved file. + if _, err := s.Upload(ctx, "escape/foo.txt", strings.NewReader("x"), ""); !errors.Is(err, domain.ErrInvalidInput) { + t.Fatalf("upload escape: expected ErrInvalidInput, got %v", err) + } + if _, err := s.Download(ctx, "escape/foo.txt"); !errors.Is(err, domain.ErrInvalidInput) { + t.Fatalf("download escape: expected ErrInvalidInput, got %v", err) + } + if _, err := s.Exists(ctx, "escape/foo.txt"); !errors.Is(err, domain.ErrInvalidInput) { + t.Fatalf("exists escape: expected ErrInvalidInput, got %v", err) + } + if err := s.Delete(ctx, "escape/foo.txt"); !errors.Is(err, domain.ErrInvalidInput) { + t.Fatalf("delete escape: expected ErrInvalidInput, got %v", err) + } + + // Sanity: nothing was written to the outside dir. + entries, _ := os.ReadDir(outside) + if len(entries) != 0 { + t.Fatalf("outside dir should be empty, got %d entries", len(entries)) + } +} + +// TestLocalStorage_FileMode0644 verifies the post-rename file mode is +// 0o644 — CreateTemp uses 0o600 and the chmod step is what makes uploads +// readable by a separate static-server user. +func TestLocalStorage_FileMode0644(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX file modes do not apply on Windows") + } + dir := t.TempDir() + s := storage.NewLocalStorage(dir, "/static") + if _, err := s.Upload(context.Background(), "f.txt", strings.NewReader("x"), ""); err != nil { + t.Fatalf("upload: %v", err) + } + st, err := os.Stat(filepath.Join(dir, "f.txt")) + if err != nil { + t.Fatalf("stat: %v", err) + } + if got := st.Mode().Perm(); got != 0o644 { + t.Fatalf("file mode = %o, want 0644", got) + } +} diff --git a/backend/internal/infrastructure/storage/s3.go b/backend/internal/infrastructure/storage/s3.go new file mode 100644 index 0000000..de221df --- /dev/null +++ b/backend/internal/infrastructure/storage/s3.go @@ -0,0 +1,256 @@ +package storage + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "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" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/aws/smithy-go" + + "github.com/afa/blueprint/backend/internal/domain" +) + +// defaultPresignTTL is the TTL applied when Upload returns a presigned +// GET URL. +const defaultPresignTTL = 24 * time.Hour + +// S3Client is the minimal subset of the aws-sdk-go-v2 s3.Client surface +// that S3Storage consumes. Narrowed so tests can inject a mock. +type S3Client interface { + PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) + GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) + DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) + HeadObject(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options)) (*s3.HeadObjectOutput, error) +} + +// S3Presigner presigns GET requests. +type S3Presigner interface { + PresignGetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.PresignOptions)) (*PresignedHTTPRequest, error) +} + +// PresignedHTTPRequest mirrors v4.PresignedHTTPRequest's URL-only slice. +type PresignedHTTPRequest struct { + URL string +} + +// S3Storage is a domain.Storage backed by an S3-compatible bucket +// (AWS S3, Cloudflare R2, MinIO). +type S3Storage struct { + bucket string + client S3Client + presigner S3Presigner + ttl time.Duration +} + +// S3Config bundles the fields required to construct an S3Storage. +// Endpoint is optional (fill for R2, MinIO, B2). UsePathStyle is +// required for MinIO; leave false for AWS S3; R2 works with either. +type S3Config struct { + Bucket string + Region string + AccessKeyID string + SecretAccessKey string + Endpoint string + UsePathStyle bool +} + +// NewS3Storage constructs an S3Storage using aws-sdk-go-v2. +func NewS3Storage(ctx context.Context, cfg S3Config) (*S3Storage, error) { + if cfg.Bucket == "" { + return nil, fmt.Errorf("storage: S3 bucket required: %w", domain.ErrInvalidInput) + } + loadOpts := []func(*awsconfig.LoadOptions) error{} + if cfg.Region != "" { + loadOpts = append(loadOpts, awsconfig.WithRegion(cfg.Region)) + } + if cfg.AccessKeyID != "" && cfg.SecretAccessKey != "" { + loadOpts = append(loadOpts, awsconfig.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""), + )) + } + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, loadOpts...) + if err != nil { + return nil, fmt.Errorf("storage: load aws config: %w", err) + } + s3Opts := func(o *s3.Options) { + if cfg.Endpoint != "" { + ep := cfg.Endpoint + o.BaseEndpoint = &ep + } + if cfg.UsePathStyle { + o.UsePathStyle = true + } + } + client := s3.NewFromConfig(awsCfg, s3Opts) + presignClient := s3.NewPresignClient(client, func(po *s3.PresignOptions) { + po.ClientOptions = append(po.ClientOptions, s3Opts) + }) + return &S3Storage{ + bucket: cfg.Bucket, + client: client, + presigner: presignerAdapter{p: presignClient}, + ttl: defaultPresignTTL, + }, nil +} + +// NewS3StorageWithClient is the test-friendly constructor. It panics on +// invalid input so misuse fails at construction rather than later in +// Upload/SignedURL with a less obvious nil-pointer. +func NewS3StorageWithClient(bucket string, client S3Client, presigner S3Presigner, ttl time.Duration) *S3Storage { + if bucket == "" { + panic("storage.NewS3StorageWithClient: bucket is required") + } + if client == nil { + panic("storage.NewS3StorageWithClient: client is required") + } + if presigner == nil { + panic("storage.NewS3StorageWithClient: presigner is required") + } + if ttl <= 0 { + ttl = defaultPresignTTL + } + return &S3Storage{bucket: bucket, client: client, presigner: presigner, ttl: ttl} +} + +func (s *S3Storage) Bucket() string { return s.bucket } +func (s *S3Storage) Client() S3Client { return s.client } + +// Upload PUTs body under key and returns a presigned GET URL valid +// for s.ttl (24h by default). +func (s *S3Storage) Upload(ctx context.Context, key string, r io.Reader, contentType string) (string, error) { + if err := validateKey(key); err != nil { + return "", err + } + body, err := io.ReadAll(r) + if err != nil { + return "", fmt.Errorf("storage: read body: %w", err) + } + input := &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: bytes.NewReader(body), + } + if contentType != "" { + input.ContentType = aws.String(contentType) + } + if _, err := s.client.PutObject(ctx, input); err != nil { + return "", fmt.Errorf("storage: s3 put: %w", err) + } + return s.SignedURL(ctx, key, s.ttl) +} + +// Download returns a ReadCloser streaming the object body. +func (s *S3Storage) Download(ctx context.Context, key string) (io.ReadCloser, error) { + if err := validateKey(key); err != nil { + return nil, err + } + out, err := s.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + if err != nil { + if isS3NotFound(err) { + return nil, domain.ErrNotFound + } + return nil, fmt.Errorf("storage: s3 get: %w", err) + } + return out.Body, nil +} + +// Exists returns true when HEAD succeeds, false on NotFound. +func (s *S3Storage) Exists(ctx context.Context, key string) (bool, error) { + if err := validateKey(key); err != nil { + return false, err + } + _, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + if err != nil { + if isS3NotFound(err) { + return false, nil + } + return false, fmt.Errorf("storage: s3 head: %w", err) + } + return true, nil +} + +// SignedURL returns a presigned GET URL for key valid for ttl. +func (s *S3Storage) SignedURL(ctx context.Context, key string, ttl time.Duration) (string, error) { + if err := validateKey(key); err != nil { + return "", err + } + if ttl <= 0 { + ttl = s.ttl + } + req, err := s.presigner.PresignGetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }, func(o *s3.PresignOptions) { o.Expires = ttl }) + if err != nil { + return "", fmt.Errorf("storage: presign: %w", err) + } + return req.URL, nil +} + +// Delete removes key from the bucket. S3-compatible backends vary in how +// they treat missing keys: AWS S3 returns success, but R2 and MinIO have +// historically returned NoSuchKey. Map those to nil so Delete is +// idempotent across providers. +func (s *S3Storage) Delete(ctx context.Context, key string) error { + if err := validateKey(key); err != nil { + return err + } + _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + if err != nil { + if isS3NotFound(err) { + return nil + } + return fmt.Errorf("storage: s3 delete: %w", err) + } + return nil +} + +func isS3NotFound(err error) bool { + var nsk *s3types.NoSuchKey + if errors.As(err, &nsk) { + return true + } + var nf *s3types.NotFound + if errors.As(err, &nf) { + return true + } + var apiErr smithy.APIError + if errors.As(err, &apiErr) { + switch apiErr.ErrorCode() { + case "NoSuchKey", "NotFound", "404": + return true + } + } + return false +} + +type presignerAdapter struct { + p *s3.PresignClient +} + +func (a presignerAdapter) PresignGetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.PresignOptions)) (*PresignedHTTPRequest, error) { + req, err := a.p.PresignGetObject(ctx, params, optFns...) + if err != nil { + return nil, err + } + return &PresignedHTTPRequest{URL: req.URL}, nil +} + +var _ domain.Storage = (*S3Storage)(nil) diff --git a/backend/internal/infrastructure/storage/s3_endpoint_test.go b/backend/internal/infrastructure/storage/s3_endpoint_test.go new file mode 100644 index 0000000..e858f30 --- /dev/null +++ b/backend/internal/infrastructure/storage/s3_endpoint_test.go @@ -0,0 +1,99 @@ +package storage_test + +import ( + "context" + "testing" + + "github.com/afa/blueprint/backend/internal/infrastructure/storage" +) + +// isolateAWSEnv disables AWS shared config / IMDS lookups so +// LoadDefaultConfig does not hit the network or read host AWS profiles +// when running under CI. +func isolateAWSEnv(t *testing.T) { + t.Helper() + for _, k := range []string{ + "AWS_PROFILE", + "AWS_CONFIG_FILE", + "AWS_SHARED_CREDENTIALS_FILE", + } { + t.Setenv(k, "") + } + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") +} + +// TestNewS3Storage_CustomEndpoint verifies that custom endpoints +// (R2, MinIO, B2, etc.) are accepted by the constructor and do not +// fail boot. Does not validate live connectivity — that requires +// real credentials and a reachable service. +func TestNewS3Storage_CustomEndpoint(t *testing.T) { + isolateAWSEnv(t) + ctx := context.Background() + + // MinIO-style: path-style endpoint local + if _, err := storage.NewS3Storage(ctx, storage.S3Config{ + Bucket: "test-bucket", + Region: "us-east-1", + AccessKeyID: "minio", + SecretAccessKey: "minio123", + Endpoint: "http://localhost:9000", + UsePathStyle: true, + }); err != nil { + t.Fatalf("MinIO config: %v", err) + } + + // Cloudflare R2: virtual-hosted endpoint + if _, err := storage.NewS3Storage(ctx, storage.S3Config{ + Bucket: "test-bucket", + Region: "auto", + AccessKeyID: "r2-key", + SecretAccessKey: "r2-secret", + Endpoint: "https://abc123.r2.cloudflarestorage.com", + }); err != nil { + t.Fatalf("R2 config: %v", err) + } + + // Backblaze B2 + if _, err := storage.NewS3Storage(ctx, storage.S3Config{ + Bucket: "test-bucket", + Region: "us-west-002", + AccessKeyID: "b2-key", + SecretAccessKey: "b2-secret", + Endpoint: "https://s3.us-west-002.backblazeb2.com", + }); err != nil { + t.Fatalf("B2 config: %v", err) + } + + // DigitalOcean Spaces + if _, err := storage.NewS3Storage(ctx, storage.S3Config{ + Bucket: "test-bucket", + Region: "nyc3", + AccessKeyID: "do-key", + SecretAccessKey: "do-secret", + Endpoint: "https://nyc3.digitaloceanspaces.com", + }); err != nil { + t.Fatalf("DO Spaces config: %v", err) + } + + // AWS default: no endpoint + if _, err := storage.NewS3Storage(ctx, storage.S3Config{ + Bucket: "test-bucket", + Region: "us-east-1", + AccessKeyID: "aws-key", + SecretAccessKey: "aws-secret", + }); err != nil { + t.Fatalf("AWS default config: %v", err) + } +} + +// TestNewS3Storage_MissingBucket guarantees fail-fast. +func TestNewS3Storage_MissingBucket(t *testing.T) { + isolateAWSEnv(t) + _, err := storage.NewS3Storage(context.Background(), storage.S3Config{ + Region: "us-east-1", + Endpoint: "http://localhost:9000", + }) + if err == nil { + t.Fatal("expected error when Bucket is empty") + } +} diff --git a/backend/internal/infrastructure/storage/s3_test.go b/backend/internal/infrastructure/storage/s3_test.go new file mode 100644 index 0000000..3ed04c4 --- /dev/null +++ b/backend/internal/infrastructure/storage/s3_test.go @@ -0,0 +1,99 @@ +package storage_test + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/s3" + + "github.com/afa/blueprint/backend/internal/domain" + "github.com/afa/blueprint/backend/internal/infrastructure/storage" +) + +// fakeS3Client is a no-op S3Client used to satisfy NewS3StorageWithClient +// where the client is not exercised. Calling any method panics so misuse +// surfaces as a test failure. +type fakeS3Client struct{} + +func (fakeS3Client) PutObject(context.Context, *s3.PutObjectInput, ...func(*s3.Options)) (*s3.PutObjectOutput, error) { + panic("fakeS3Client.PutObject called") +} +func (fakeS3Client) GetObject(context.Context, *s3.GetObjectInput, ...func(*s3.Options)) (*s3.GetObjectOutput, error) { + panic("fakeS3Client.GetObject called") +} +func (fakeS3Client) DeleteObject(context.Context, *s3.DeleteObjectInput, ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) { + panic("fakeS3Client.DeleteObject called") +} +func (fakeS3Client) HeadObject(context.Context, *s3.HeadObjectInput, ...func(*s3.Options)) (*s3.HeadObjectOutput, error) { + panic("fakeS3Client.HeadObject called") +} + +type fakePresigner struct{} + +func (fakePresigner) PresignGetObject(context.Context, *s3.GetObjectInput, ...func(*s3.PresignOptions)) (*storage.PresignedHTTPRequest, error) { + panic("fakePresigner.PresignGetObject called") +} + +// recordingClient buffers PutObject inputs for assertion. +type recordingClient struct { + fakeS3Client + puts []*s3.PutObjectInput +} + +func (r *recordingClient) PutObject(_ context.Context, in *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) { + r.puts = append(r.puts, in) + return &s3.PutObjectOutput{}, nil +} + +func mustPanic(t *testing.T, want string, fn func()) { + t.Helper() + defer func() { + r := recover() + if r == nil { + t.Fatalf("expected panic containing %q, got none", want) + } + msg, _ := r.(string) + if !strings.Contains(msg, want) { + t.Fatalf("expected panic containing %q, got %v", want, r) + } + }() + fn() +} + +func TestNewS3StorageWithClient_PanicsOnEmptyBucket(t *testing.T) { + mustPanic(t, "bucket is required", func() { + storage.NewS3StorageWithClient("", fakeS3Client{}, fakePresigner{}, 0) + }) +} + +func TestNewS3StorageWithClient_PanicsOnNilClient(t *testing.T) { + mustPanic(t, "client is required", func() { + storage.NewS3StorageWithClient("bucket", nil, fakePresigner{}, 0) + }) +} + +func TestNewS3StorageWithClient_PanicsOnNilPresigner(t *testing.T) { + mustPanic(t, "presigner is required", func() { + storage.NewS3StorageWithClient("bucket", fakeS3Client{}, nil, 0) + }) +} + +// TestS3_Upload_RejectsInvalidKeyBeforeReadingBody guards against a +// regression where a traversal key would still cause io.ReadAll on the +// caller-supplied body. Using a recording client confirms PutObject is +// never invoked when the key is invalid. +func TestS3_Upload_RejectsInvalidKeyBeforeReadingBody(t *testing.T) { + rc := &recordingClient{} + s := storage.NewS3StorageWithClient("bucket", rc, fakePresigner{}, 0) + + _, err := s.Upload(context.Background(), "../escape", bytes.NewReader([]byte("x")), "") + if !errors.Is(err, domain.ErrInvalidInput) { + t.Fatalf("expected ErrInvalidInput, got %v", err) + } + if len(rc.puts) != 0 { + t.Fatalf("PutObject should not have been called for invalid key") + } +} diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index d985148..95cd314 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -22,11 +22,23 @@ type Config struct { StripeKey string StripeWebhookSecret string - StorageType string // local, s3 + StorageType string // local, s3 (legacy — kept for backwards compat) AWSBucket string AWSRegion string UploadDir string + // Pluggable storage backend (preferred over StorageType). + // StorageBackend: "local" | "s3" + StorageBackend string + StorageLocalPath string + StorageURLPrefix string // URL prefix for local-storage public URLs (default "/static") + StorageS3Bucket string + StorageS3Region string + StorageS3AccessKeyID string + StorageS3SecretAccessKey string + StorageS3Endpoint string // optional: R2/MinIO endpoint override + StorageS3UsePathStyle bool // true for MinIO + OpenAIKey string TelegramToken string @@ -76,6 +88,16 @@ func Load() *Config { AWSRegion: getEnv("AWS_REGION", "us-east-1"), UploadDir: getEnv("UPLOAD_DIR", "./uploads"), + StorageBackend: getEnv("STORAGE_BACKEND", getEnv("STORAGE_TYPE", "local")), + StorageLocalPath: getEnv("STORAGE_LOCAL_PATH", ""), + StorageURLPrefix: getEnv("STORAGE_URL_PREFIX", "/static"), + StorageS3Bucket: getEnv("STORAGE_S3_BUCKET", getEnv("AWS_BUCKET", "")), + StorageS3Region: getEnv("STORAGE_S3_REGION", getEnv("AWS_REGION", "us-east-1")), + StorageS3AccessKeyID: getEnv("STORAGE_S3_ACCESS_KEY_ID", getEnv("AWS_ACCESS_KEY_ID", "")), + StorageS3SecretAccessKey: getEnv("STORAGE_S3_SECRET_ACCESS_KEY", getEnv("AWS_SECRET_ACCESS_KEY", "")), + StorageS3Endpoint: getEnv("STORAGE_S3_ENDPOINT", ""), + StorageS3UsePathStyle: getEnvBool("STORAGE_S3_USE_PATH_STYLE", false), + OpenAIKey: getEnv("OPENAI_KEY", ""), TelegramToken: getEnv("TELEGRAM_BOT_TOKEN", ""), @@ -117,6 +139,18 @@ func getEnvInt(key string, defaultValue int) int { return defaultValue } +// getEnvBool parses common truthy/falsy strings (1, t, true, yes, on, +// case-insensitive) via strconv.ParseBool. Falls back to defaultValue on +// missing or unparseable input. +func getEnvBool(key string, defaultValue bool) bool { + if value := os.Getenv(key); value != "" { + if v, err := strconv.ParseBool(value); err == nil { + return v + } + } + return defaultValue +} + func getBcryptCost() int { cost := getEnvInt("BCRYPT_COST", 12) if cost < bcrypt.MinCost || cost > bcrypt.MaxCost {