package blob import ( "context" "fmt" "io" "os" "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" ) // Uploader stores public avatar objects. type Uploader interface { Enabled() bool Upload(ctx context.Context, obj FileUpload) (publicURL string, err error) Delete(ctx context.Context, key string) error } // FileUpload is a file body to store (e.g. an avatar). type FileUpload struct { Key string Body io.Reader ContentType string Size int64 } // Disabled is a no-op uploader used when Spaces is not configured. type Disabled struct{} // SpacesConfig holds DigitalOcean Spaces settings. type SpacesConfig struct { Key string Secret string Region string Bucket string Endpoint string // e.g. https://nyc3.digitaloceanspaces.com CDNBase string // optional public base URL without trailing slash } type spaces struct { client *s3.Client cfg SpacesConfig } func (Disabled) Enabled() bool { return false } func (Disabled) Upload(context.Context, FileUpload) (string, error) { return "", fmt.Errorf("avatar uploads are not configured") } func (Disabled) Delete(context.Context, string) error { return nil } // FromEnv builds an Uploader from SPACES_* environment variables. func FromEnv() Uploader { return NewSpaces(SpacesConfig{ Key: os.Getenv("SPACES_KEY"), Secret: os.Getenv("SPACES_SECRET"), Region: os.Getenv("SPACES_REGION"), Bucket: os.Getenv("SPACES_BUCKET"), Endpoint: os.Getenv("SPACES_ENDPOINT"), CDNBase: os.Getenv("SPACES_CDN_BASE"), }) } // NewSpaces returns an Uploader when required env is present; otherwise Disabled. func NewSpaces(cfg SpacesConfig) Uploader { cfg.Key = strings.TrimSpace(cfg.Key) cfg.Secret = strings.TrimSpace(cfg.Secret) cfg.Region = strings.TrimSpace(cfg.Region) cfg.Bucket = strings.TrimSpace(cfg.Bucket) cfg.Endpoint = strings.TrimSpace(cfg.Endpoint) cfg.CDNBase = strings.TrimRight(strings.TrimSpace(cfg.CDNBase), "/") if cfg.Key == "" || cfg.Secret == "" || cfg.Region == "" || cfg.Bucket == "" || cfg.Endpoint == "" { return Disabled{} } client := s3.New(s3.Options{ Region: cfg.Region, Credentials: credentials.NewStaticCredentialsProvider(cfg.Key, cfg.Secret, ""), BaseEndpoint: aws.String(cfg.Endpoint), }) return &spaces{client: client, cfg: cfg} } func (s *spaces) Enabled() bool { return true } func (s *spaces) Upload(ctx context.Context, obj FileUpload) (string, error) { key := strings.TrimPrefix(obj.Key, "/") input := &s3.PutObjectInput{ Bucket: aws.String(s.cfg.Bucket), Key: aws.String(key), Body: obj.Body, ContentType: aws.String(obj.ContentType), ACL: types.ObjectCannedACLPublicRead, } if obj.Size > 0 { input.ContentLength = aws.Int64(obj.Size) } if _, err := s.client.PutObject(ctx, input); err != nil { return "", err } return s.publicURL(key), nil } func (s *spaces) Delete(ctx context.Context, key string) error { key = strings.TrimPrefix(key, "/") if key == "" { return nil } _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: aws.String(s.cfg.Bucket), Key: aws.String(key), }) return err } func (s *spaces) publicURL(key string) string { if s.cfg.CDNBase != "" { return s.cfg.CDNBase + "/" + key } host := strings.TrimPrefix(s.cfg.Endpoint, "https://") host = strings.TrimPrefix(host, "http://") return fmt.Sprintf("https://%s.%s/%s", s.cfg.Bucket, host, key) } // KeyFromPublicURL extracts the object key from a Spaces/CDN URL when possible. func KeyFromPublicURL(publicURL, cdnBase, bucket, endpoint string) string { publicURL = strings.TrimSpace(publicURL) if publicURL == "" { return "" } cdnBase = strings.TrimRight(strings.TrimSpace(cdnBase), "/") if cdnBase != "" && strings.HasPrefix(publicURL, cdnBase+"/") { return strings.TrimPrefix(publicURL, cdnBase+"/") } host := strings.TrimPrefix(strings.TrimSpace(endpoint), "https://") host = strings.TrimPrefix(host, "http://") prefix := fmt.Sprintf("https://%s.%s/", bucket, host) if strings.HasPrefix(publicURL, prefix) { return strings.TrimPrefix(publicURL, prefix) } return "" }