spark-server/internal/infrastructure/objectstore/policy.go
2026-09-18 18:27:49 +08:00

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)
}