package filestorage import ( "context" "errors" "fmt" "io" "os" "strconv" "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/aws/smithy-go" ) type S3Storage struct { client *s3.Client bucket string } func NewS3FromEnv() (*S3Storage, error) { bucket := strings.TrimSpace(os.Getenv("S3_BUCKET")) accessKey := strings.TrimSpace(os.Getenv("S3_ACCESS_KEY")) secretKey := strings.TrimSpace(os.Getenv("S3_SECRET_KEY")) if bucket == "" || accessKey == "" || secretKey == "" { return nil, errors.New("S3_BUCKET, S3_ACCESS_KEY, and S3_SECRET_KEY are required for FILE_STORAGE_PROVIDER=s3") } region := strings.TrimSpace(os.Getenv("S3_REGION")) if region == "" { region = "us-east-1" } endpoint := strings.TrimSpace(os.Getenv("S3_ENDPOINT")) usePathStyle, _ := strconv.ParseBool(strings.TrimSpace(os.Getenv("S3_USE_PATH_STYLE"))) cfg := aws.Config{ Region: region, Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")), } client := s3.NewFromConfig(cfg, func(options *s3.Options) { options.UsePathStyle = usePathStyle if endpoint != "" { options.BaseEndpoint = aws.String(endpoint) } }) return &S3Storage{client: client, bucket: bucket}, nil } func (s *S3Storage) Provider() string { return "s3" } func (s *S3Storage) Probe(ctx context.Context) error { _, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.bucket)}) return err } func (s *S3Storage) Put(ctx context.Context, key string, reader io.Reader, contentType string, size int64) error { _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), Body: reader, ContentType: aws.String(contentType), ContentLength: aws.Int64(size), }) return err } func (s *S3Storage) Get(ctx context.Context, key string) (Object, error) { response, err := s.client.GetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), }) if err != nil { if isS3NotFoundError(err) { return Object{}, ErrNotFound } return Object{}, err } contentType := "" if response.ContentType != nil { contentType = *response.ContentType } size := int64(0) if response.ContentLength != nil { size = *response.ContentLength } return Object{ Body: response.Body, ContentType: contentType, Size: size, }, nil } func (s *S3Storage) 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 { if isS3NotFoundError(err) { return nil } return fmt.Errorf("delete s3 object: %w", err) } return nil } func isS3NotFoundError(err error) bool { var noSuchKey *types.NoSuchKey if errors.As(err, &noSuchKey) { return true } var apiErr smithy.APIError if errors.As(err, &apiErr) { switch apiErr.ErrorCode() { case "NoSuchKey", "NotFound", "NoSuchBucket": return true } } return false }