xtablo-source/backend/internal/files/store.go
2026-05-15 19:57:46 +02:00

102 lines
3.3 KiB
Go

package files
import (
"bytes"
"context"
"io"
"net/http"
"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"
)
// FileStorer is the interface satisfied by Store and used for test injection.
type FileStorer interface {
Upload(ctx context.Context, key string, file io.Reader) (contentType string, bytesWritten int64, err error)
Delete(ctx context.Context, key string) error
PresignDownload(ctx context.Context, key string) (string, error)
}
// Store is a thin wrapper around an S3-compatible client.
type Store struct {
client *s3.Client
bucket string
}
// NewStore constructs a Store pointed at an S3-compatible endpoint.
//
// endpoint: e.g. "http://localhost:9000" (MinIO) or "https://<account>.r2.cloudflarestorage.com" (R2)
// usePathStyle: true for MinIO (required per Pitfall 1), false for R2
func NewStore(ctx context.Context, endpoint, bucket, region, accessKey, secretKey string, usePathStyle bool) (*Store, error) {
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(region),
config.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""),
),
)
if err != nil {
return nil, err
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint)
o.UsePathStyle = usePathStyle // true for MinIO; false or omit for R2
// Only compute checksums when the server explicitly requires them.
// Without this, SDK v2 tries to use trailing checksums over HTTP, which
// requires a seekable stream — incompatible with io.MultiReader (MinIO local dev).
o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired
})
return &Store{client: client, bucket: bucket}, nil
}
// Upload buffers the file, sniffs its content-type, then PUTs it to S3.
//
// Buffering is required because AWS SDK v2 needs a seekable body to compute
// the SigV4 payload hash over plain HTTP (MinIO dev). The 25 MB cap enforced
// upstream by http.MaxBytesReader makes this safe.
func (s *Store) Upload(ctx context.Context, key string, file io.Reader) (contentType string, bytesWritten int64, err error) {
buf, err := io.ReadAll(file)
if err != nil {
return "", 0, err
}
contentType = http.DetectContentType(buf)
bytesWritten = int64(len(buf))
_, err = s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: bytes.NewReader(buf),
ContentType: aws.String(contentType),
ContentLength: aws.Int64(bytesWritten),
})
return contentType, bytesWritten, err
}
// Delete removes an object from S3.
func (s *Store) Delete(ctx context.Context, key string) error {
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
return err
}
// PresignDownload returns a time-limited presigned GET URL (5-minute TTL).
func (s *Store) PresignDownload(ctx context.Context, key string) (string, error) {
presignClient := s3.NewPresignClient(s.client)
req, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
}, func(o *s3.PresignOptions) {
o.Expires = 5 * time.Minute
})
if err != nil {
return "", err
}
return req.URL, nil
}