131 lines
3.6 KiB
Go
131 lines
3.6 KiB
Go
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 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": "PublicReadAvatars",
|
|
"Effect": "Allow",
|
|
"Principal": "*",
|
|
"Action": ["s3:GetObject"],
|
|
"Resource": [
|
|
"arn:aws:s3:::%[1]s/perm/avatar/*",
|
|
"arn:aws:s3:::%[1]s/perm/group/*"
|
|
]
|
|
}]
|
|
}`, bucket)
|
|
}
|