// Package oss contains storage adapters that satisfy domain.OSSProvider. // // The S3 adapter targets any S3-compatible endpoint via aws-sdk-go-v2. package oss import ( "bytes" "context" "fmt" "net/url" "strings" awsv2 "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/mmmy/snapgo/internal/domain" ) // S3Provider implements domain.OSSProvider using aws-sdk-go-v2. // // We construct one client per Provider instance because the S3 client is // goroutine-safe and the configuration is immutable for the lifetime of the // provider. type S3Provider struct { cfg domain.S3Config client *s3.Client } // NewS3Provider builds an *S3Provider from the supplied user configuration. // // The endpoint is plumbed through BaseEndpoint + UsePathStyle so any // S3-compatible service (MinIO, R2, B2, Aliyun OSS s3 endpoint) works // without us writing per-vendor branches. func NewS3Provider(cfg domain.S3Config) (*S3Provider, error) { if cfg.Endpoint == "" || cfg.Bucket == "" || cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" { return nil, fmt.Errorf("s3 config: endpoint/bucket/accessKeyId/secretAccessKey are required") } region := cfg.Region if region == "" { region = "us-east-1" } awsCfg := awsv2.Config{ Region: region, Credentials: credentials.NewStaticCredentialsProvider( cfg.AccessKeyID, cfg.SecretAccessKey, "", ), } endpoint := cfg.Endpoint client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { o.BaseEndpoint = awsv2.String(endpoint) o.UsePathStyle = cfg.UsePathStyle }) return &S3Provider{cfg: cfg, client: client}, nil } // Name identifies this provider in logs and history records. func (p *S3Provider) Name() string { return "s3" } // Upload pushes the bytes to the configured bucket and returns a public URL. // // We pass the body via bytes.Reader so the SDK can compute Content-Length // and signed checksum without buffering the data twice. func (p *S3Provider) Upload(ctx context.Context, key string, data []byte, contentType string) (string, error) { if contentType == "" { contentType = "image/png" } _, err := p.client.PutObject(ctx, &s3.PutObjectInput{ Bucket: awsv2.String(p.cfg.Bucket), Key: awsv2.String(key), Body: bytes.NewReader(data), ContentType: awsv2.String(contentType), }) if err != nil { return "", fmt.Errorf("s3 put object: %w", err) } return p.BuildPublicURL(key), nil } // BuildPublicURL constructs the URL that the user pastes into Markdown. // // Priority: // 1. PublicURLBase if user filled it (best-fit for CDN-fronted buckets); // 2. {Endpoint}/{Bucket}/{Key} for path-style; // 3. virtual-hosted-style fallback ({bucket}.host). func (p *S3Provider) BuildPublicURL(key string) string { if p.cfg.PublicURLBase != "" { base := strings.TrimRight(p.cfg.PublicURLBase, "/") return base + "/" + key } endpoint := strings.TrimRight(p.cfg.Endpoint, "/") if p.cfg.UsePathStyle { return endpoint + "/" + p.cfg.Bucket + "/" + key } // Virtual-hosted-style: replace host with {bucket}.{host} if u, err := url.Parse(endpoint); err == nil && u.Host != "" { u.Host = p.cfg.Bucket + "." + u.Host u.Path = "/" + key return u.String() } return endpoint + "/" + p.cfg.Bucket + "/" + key } // Delete removes a previously uploaded object by key. // // The summary pipeline uses this to clean up the temporary screenshot it had // to upload only so a remote multimodal model could fetch it via public URL. func (p *S3Provider) Delete(ctx context.Context, key string) error { if _, err := p.client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: awsv2.String(p.cfg.Bucket), Key: awsv2.String(key), }); err != nil { return fmt.Errorf("s3 delete object: %w", err) } return nil } // TestConnection performs a small put + delete to verify credentials and // bucket reachability. Used by the "Test connection" button in settings. func (p *S3Provider) TestConnection(ctx context.Context) error { probeKey := strings.TrimRight(p.cfg.PathPrefix, "/") + "/.snapgo-probe" probeKey = strings.TrimLeft(probeKey, "/") if _, err := p.client.PutObject(ctx, &s3.PutObjectInput{ Bucket: awsv2.String(p.cfg.Bucket), Key: awsv2.String(probeKey), Body: bytes.NewReader([]byte("snapgo")), ContentType: awsv2.String("text/plain"), }); err != nil { return fmt.Errorf("probe put: %w", err) } if _, err := p.client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: awsv2.String(p.cfg.Bucket), Key: awsv2.String(probeKey), }); err != nil { return fmt.Errorf("probe delete: %w", err) } return nil }