194 lines
7.6 KiB
Go
194 lines
7.6 KiB
Go
// 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
|
||
}
|