package objectstore import ( "context" "errors" "fmt" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "go.uber.org/zap" ) func (s *Store) ensureBucket(ctx context.Context, region string, chatExpireDays int) error { _, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.bucket)}) if err != nil { if !isMissingObject(err) { return fmt.Errorf("objectstore ping: %w", err) } if err := s.createBucket(ctx, region); err != nil { return err } } s.applyLifecycle(ctx, chatExpireDays) s.applyCORS(ctx) s.applyPublicRead(ctx) return nil } func (s *Store) createBucket(ctx context.Context, region string) error { in := &s3.CreateBucketInput{Bucket: aws.String(s.bucket)} if region != "" && region != defaultRegion { in.CreateBucketConfiguration = &types.CreateBucketConfiguration{ LocationConstraint: types.BucketLocationConstraint(region), } } if _, err := s.client.CreateBucket(ctx, in); err != nil { var owned *types.BucketAlreadyOwnedByYou var exists *types.BucketAlreadyExists if errors.As(err, &owned) || errors.As(err, &exists) { return nil } return fmt.Errorf("objectstore make bucket: %w", err) } return nil } func (s *Store) applyLifecycle(ctx context.Context, days int) { if days < 1 { return } _, err := s.client.PutBucketLifecycleConfiguration(ctx, &s3.PutBucketLifecycleConfigurationInput{ Bucket: aws.String(s.bucket), LifecycleConfiguration: &types.BucketLifecycleConfiguration{ Rules: []types.LifecycleRule{{ ID: aws.String("expire-chat"), Status: types.ExpirationStatusEnabled, Filter: &types.LifecycleRuleFilter{Prefix: aws.String("chat/")}, Expiration: &types.LifecycleExpiration{ Days: aws.Int32(int32(days)), }, }}, }, }) if err != nil { s.log.Warn("objectstore lifecycle", zap.Error(err)) } } func (s *Store) applyCORS(ctx context.Context) { _, err := s.client.PutBucketCors(ctx, &s3.PutBucketCorsInput{ Bucket: aws.String(s.bucket), CORSConfiguration: &types.CORSConfiguration{ CORSRules: []types.CORSRule{{ AllowedOrigins: []string{"*"}, AllowedMethods: []string{"GET", "PUT", "HEAD"}, AllowedHeaders: []string{"*"}, ExposeHeaders: []string{"ETag", "Content-Length"}, MaxAgeSeconds: aws.Int32(3600), }}, }, }) if err != nil { s.log.Warn("objectstore cors", zap.Error(err)) } } // applyPublicRead lets browsers load avatars / group icons / music assets without a // presigned GET. chat/ and perm/fav/ stay private. func (s *Store) applyPublicRead(ctx context.Context) { pabCtx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() _, err := s.client.PutPublicAccessBlock(pabCtx, &s3.PutPublicAccessBlockInput{ Bucket: aws.String(s.bucket), PublicAccessBlockConfiguration: &types.PublicAccessBlockConfiguration{ BlockPublicAcls: aws.Bool(false), IgnorePublicAcls: aws.Bool(false), BlockPublicPolicy: aws.Bool(false), RestrictPublicBuckets: aws.Bool(false), }, }) if err != nil { s.log.Warn("objectstore public access block", zap.Error(err)) } policy := publicReadPolicy(s.bucket) _, err = s.client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{ Bucket: aws.String(s.bucket), Policy: aws.String(policy), }) if err != nil { s.log.Warn("objectstore public policy", zap.Error(err)) } } func publicReadPolicy(bucket string) string { return fmt.Sprintf(`{ "Version": "2012-10-17", "Statement": [{ "Sid": "PublicReadPerm", "Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"], "Resource": [ "arn:aws:s3:::%[1]s/perm/avatar/*", "arn:aws:s3:::%[1]s/perm/group/*", "arn:aws:s3:::%[1]s/perm/music/*" ] }] }`, bucket) }