Files
mamamiyear 87874e64cf feat: prefer base64 inline image for summary, fall back to S3
Send the screenshot to the multimodal model inline as a base64 data URL
so summarisation no longer requires S3 to be configured or publicly
reachable. Only when the PNG exceeds the provider MaxInlineBytes limit
do we upload to S3, verify the public URL is reachable, and delete the
temporary object once done. Oversized images without S3 now surface a
clear hint instead of a hard S3 requirement.
2026-07-07 13:39:23 +08:00

139 lines
4.5 KiB
Go

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