// 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 }