axe/internal/storage/s3.go
2026-10-18 22:07:25 +03:00

194 lines
7.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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