package storage import ( "bytes" "context" "fmt" "log/slog" "os" "strings" "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" "github.com/aws/aws-sdk-go-v2/service/s3/types" ) type S3Storage struct { client *s3.Client bucket string cdnDomain string // if set, returned URLs use this instead of bucket name } // NewS3StorageFromEnv creates an S3Storage from environment variables. // Returns nil if S3_BUCKET is not set. // // Environment variables: // - S3_BUCKET (required) // - S3_REGION (default: us-west-2) // - AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (optional; falls back to default credential chain) func NewS3StorageFromEnv() *S3Storage { bucket := os.Getenv("S3_BUCKET") if bucket == "" { slog.Info("S3_BUCKET not set, file upload disabled") return nil } region := os.Getenv("S3_REGION") if region == "" { region = "us-west-2" } opts := []func(*config.LoadOptions) error{ config.WithRegion(region), } accessKey := os.Getenv("AWS_ACCESS_KEY_ID") secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY") if accessKey != "" && secretKey != "" { opts = append(opts, config.WithCredentialsProvider( credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""), )) } cfg, err := config.LoadDefaultConfig(context.Background(), opts...) if err != nil { slog.Error("failed to load AWS config", "error", err) return nil } cdnDomain := os.Getenv("CLOUDFRONT_DOMAIN") slog.Info("S3 storage initialized", "bucket", bucket, "region", region, "cdn_domain", cdnDomain) return &S3Storage{ client: s3.NewFromConfig(cfg), bucket: bucket, cdnDomain: cdnDomain, } } // sanitizeFilename removes characters that could cause header injection in Content-Disposition. func sanitizeFilename(name string) string { var b strings.Builder b.Grow(len(name)) for _, r := range name { // Strip control chars, newlines, null bytes, quotes, semicolons, backslashes if r < 0x20 || r == 0x7f || r == '"' || r == ';' || r == '\\' || r == '\x00' { b.WriteRune('_') } else { b.WriteRune(r) } } return b.String() } func (s *S3Storage) Upload(ctx context.Context, key string, data []byte, contentType string, filename string) (string, error) { safe := sanitizeFilename(filename) _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), Body: bytes.NewReader(data), ContentType: aws.String(contentType), ContentDisposition: aws.String(fmt.Sprintf(`inline; filename="%s"`, safe)), CacheControl: aws.String("max-age=432000,public"), StorageClass: types.StorageClassIntelligentTiering, }) if err != nil { return "", fmt.Errorf("s3 PutObject: %w", err) } domain := s.bucket if s.cdnDomain != "" { domain = s.cdnDomain } link := fmt.Sprintf("https://%s/%s", domain, key) return link, nil }