Distinguish auth/lookup failures, make votes idempotent on visible questions, bound shutdown, page admin users, LRU throttle, trusted-proxy CIDRs, avatar cleanup, versioned migrations, and session cleanup logging.
147 lines
4.2 KiB
Go
147 lines
4.2 KiB
Go
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 ""
|
|
}
|