axe/internal/storage/s3.go

194 lines
7.6 KiB
Go
Raw Normal View History

2026-10-18 19:07:25 +00:00
// Package storage — объектное хранилище файлов. S3-совместимое:
// локально MinIO в compose, в проде — любой S3 (AWS, R2, Wasabi…).
// Бакет приватный: на чтение отдаются только временные подписанные
// ссылки, поэтому объекты не светятся наружу просто так.
//
// Приложение знает только интерфейс Store — тесты подменяют его
// фейком, а хендлеры не видят SDK.
package storage
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"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"
)
// Store — минимум операций, нужных фотоотчёту.
type Store interface {
// Put — положить объект: данные к этому моменту уже проверены
// хендлером (тип по сигнатуре, размер).
Put(ctx context.Context, key string, data []byte, contentType string) error
// PresignGet — временную ссылку на чтение объекта (срок — ttl).
PresignGet(ctx context.Context, key string, ttl time.Duration) (string, error)
// Delete — убрать объект. Отсутствие объекта не ошибка: S3 на
// повторное удаление отвечает успехом.
Delete(ctx context.Context, key string) error
}
// Config — параметры подключения. Пустой Endpoint означает штатный S3
// (AWS); свой endpoint задают self-hosted хранилища вроде MinIO.
type Config struct {
Endpoint string // например http://localhost:5434; "" — стандартный S3
Region string
Bucket string
AccessKey string
SecretKey string
// UsePathStyle — путь «/бакет/ключ» вместо поддомена «бакет.хост».
// Для локального MinIO обязателен: bucket.localhost:5434 не резолвится.
UsePathStyle bool
}
// ConfigFromEnv — конфиг из окружения (ключи описаны в .env.example).
// Обязательны S3_BUCKET; ключи — когда задан свой endpoint (у штатного
// AWS цепочка берёт их из окружения/ролей сама).
func ConfigFromEnv() (Config, error) {
cfg := Config{
Endpoint: os.Getenv("S3_ENDPOINT"),
Region: os.Getenv("S3_REGION"),
Bucket: os.Getenv("S3_BUCKET"),
AccessKey: os.Getenv("S3_ACCESS_KEY"),
SecretKey: os.Getenv("S3_SECRET_KEY"),
UsePathStyle: os.Getenv("S3_USE_PATH_STYLE") == "true",
}
if cfg.Bucket == "" {
return Config{}, errors.New("S3_BUCKET is not set")
}
if cfg.Endpoint != "" && (cfg.AccessKey == "" || cfg.SecretKey == "") {
return Config{}, errors.New("S3_ACCESS_KEY/S3_SECRET_KEY are not set for the custom S3 endpoint")
}
if cfg.Region == "" {
cfg.Region = "us-east-1"
}
return cfg, nil
}
// S3Store — Store поверх AWS SDK. Один клиент на процесс.
type S3Store struct {
client *s3.Client
presigner *s3.PresignClient
bucket string
region string
}
// NewS3 — подключиться к S3-совместимому хранилищу. Ошибки конфигурации
// возвращаются сразу: падение на старте лучше, чем битая загрузка на
// первом же фото.
func NewS3(ctx context.Context, cfg Config) (*S3Store, error) {
if cfg.Bucket == "" {
return nil, errors.New("s3: bucket is not set")
}
region := cfg.Region
if region == "" {
region = "us-east-1"
}
opts := []func(*config.LoadOptions) error{config.WithRegion(region)}
// Статические ключи — только когда заданы: без них остаётся
// штатная цепочка AWS (env, IAM-роль).
if cfg.AccessKey != "" && cfg.SecretKey != "" {
opts = append(opts, config.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(cfg.AccessKey, cfg.SecretKey, ""),
))
}
awsCfg, err := config.LoadDefaultConfig(ctx, opts...)
if err != nil {
return nil, fmt.Errorf("s3 config: %w", err)
}
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
if cfg.Endpoint != "" {
o.BaseEndpoint = aws.String(cfg.Endpoint)
}
o.UsePathStyle = cfg.UsePathStyle
})
return &S3Store{
client: client,
presigner: s3.NewPresignClient(client),
bucket: cfg.Bucket,
region: region,
}, nil
}
// Bucket — имя бакета (нужен main для логов старта).
func (s *S3Store) Bucket() string { return s.bucket }
// EnsureBucket — бакет обязан существовать: проверить и создать при
// отсутствии. Вызывается один раз при старте сервера.
func (s *S3Store) EnsureBucket(ctx context.Context) error {
if _, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{
Bucket: aws.String(s.bucket),
}); err == nil {
return nil
}
input := &s3.CreateBucketInput{Bucket: aws.String(s.bucket)}
// Вне us-east-1 AWS требует LocationConstraint; self-hosted
// хранилища лишний параметр игнорируют.
if s.region != "" && s.region != "us-east-1" {
input.CreateBucketConfiguration = &s3types.CreateBucketConfiguration{
LocationConstraint: s3types.BucketLocationConstraint(s.region),
}
}
if _, err := s.client.CreateBucket(ctx, input); err != nil {
// Create мог упасть из-за того, что бакет уже есть (права,
// политика): главное — он существует. Сверяемся ещё раз.
if _, headErr := s.client.HeadBucket(ctx, &s3.HeadBucketInput{
Bucket: aws.String(s.bucket),
}); headErr == nil {
return nil
}
return fmt.Errorf("s3 bucket %q: %w", s.bucket, err)
}
return nil
}
// Put — записать объект. Content-Length считается из данных: SDK не
// гадает размер, а байты уходят как есть.
func (s *S3Store) Put(ctx context.Context, key string, data []byte, contentType string) error {
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: bytes.NewReader(data),
ContentLength: aws.Int64(int64(len(data))),
ContentType: aws.String(contentType),
})
if err != nil {
return fmt.Errorf("s3 put %q: %w", key, err)
}
return nil
}
// PresignGet — подписанная ссылка на чтение (S3 SigV4, срок — ttl).
// Подпись считается локально, запрос в хранилище не уходит.
func (s *S3Store) PresignGet(ctx context.Context, key string, ttl time.Duration) (string, error) {
out, err := s.presigner.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
}, s3.WithPresignExpires(ttl))
if err != nil {
return "", fmt.Errorf("s3 presign %q: %w", key, err)
}
return out.URL, nil
}
// Delete — удалить объект; отсутствующий объект не ошибка (так отвечает S3).
func (s *S3Store) Delete(ctx context.Context, key string) error {
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
return fmt.Errorf("s3 delete %q: %w", key, err)
}
return nil
}