Files
NianAIGC/backend/internal/assets/oss.go

137 lines
4.0 KiB
Go

package assets
import (
"context"
"errors"
"fmt"
"io"
"net/url"
"path"
"strings"
)
const OSSACLPublicRead = "public-read"
type OSSConfig struct {
Endpoint string
Bucket string
PublicBaseURL string
PublicRead bool
}
type OSSPutRequest struct {
Endpoint, Bucket, Key, ContentType string
Body io.Reader
Size int64
}
type OSSObjectRequest struct{ Endpoint, Bucket, Key string }
type OSSACLRequest struct{ Endpoint, Bucket, Key, ACL string }
// OSSClient is the SDK-independent seam. Runtime composition can adapt any OSS
// SDK while tests remain deterministic and credential/network free.
type OSSClient interface {
Put(context.Context, OSSPutRequest) error
SetACL(context.Context, OSSACLRequest) error
Get(context.Context, OSSObjectRequest) (Blob, error)
Delete(context.Context, OSSObjectRequest) error
}
type OSSError struct {
Status int
Code string
Err error
}
func (e *OSSError) Error() string {
if e.Err != nil {
return e.Err.Error()
}
if e.Code != "" {
return e.Code
}
return "OSS request failed"
}
func (e *OSSError) Unwrap() error { return e.Err }
type OSS struct {
config OSSConfig
client OSSClient
}
func NewOSS(config OSSConfig, client OSSClient) (*OSS, error) {
config.Endpoint = strings.TrimRight(strings.TrimSpace(config.Endpoint), "/")
config.Bucket = strings.TrimSpace(config.Bucket)
config.PublicBaseURL = strings.TrimRight(strings.TrimSpace(config.PublicBaseURL), "/")
if config.Endpoint == "" || config.Bucket == "" || config.PublicBaseURL == "" || client == nil {
return nil, errors.New("OSS endpoint, bucket, public URL, and client are required")
}
if parsed, err := url.Parse(config.PublicBaseURL); err != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, errors.New("OSS public URL must be absolute")
}
return &OSS{config: config, client: client}, nil
}
func (s *OSS) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (StoredObject, error) {
if err := validOSSKey(key); err != nil {
return StoredObject{}, err
}
err := s.client.Put(ctx, OSSPutRequest{Endpoint: s.config.Endpoint, Bucket: s.config.Bucket, Key: key, Body: body, Size: size, ContentType: contentType})
if err != nil {
return StoredObject{}, mapOSSError(err)
}
if s.config.PublicRead {
if err = s.client.SetACL(ctx, OSSACLRequest{Endpoint: s.config.Endpoint, Bucket: s.config.Bucket, Key: key, ACL: OSSACLPublicRead}); err != nil {
return StoredObject{}, mapOSSError(err)
}
}
return StoredObject{Key: key, URL: s.config.PublicBaseURL + "/" + escapeOSSKey(key)}, nil
}
func (s *OSS) Read(ctx context.Context, key string) (Blob, error) {
if err := validOSSKey(key); err != nil {
return Blob{}, err
}
blob, err := s.client.Get(ctx, OSSObjectRequest{Endpoint: s.config.Endpoint, Bucket: s.config.Bucket, Key: key})
if err != nil {
return Blob{}, mapOSSError(err)
}
return blob, nil
}
func (s *OSS) Delete(ctx context.Context, key string) error {
if err := validOSSKey(key); err != nil {
return err
}
err := s.client.Delete(ctx, OSSObjectRequest{Endpoint: s.config.Endpoint, Bucket: s.config.Bucket, Key: key})
if isMissingOSS(err) {
return nil
}
return mapOSSError(err)
}
func mapOSSError(err error) error {
if err == nil {
return nil
}
if isMissingOSS(err) {
return ErrBlobNotFound
}
return fmt.Errorf("OSS operation failed: %w", err)
}
func isMissingOSS(err error) bool {
var ossErr *OSSError
return errors.As(err, &ossErr) && (ossErr.Status == 404 || ossErr.Code == "NoSuchKey" || ossErr.Code == "NoSuchObject")
}
func validOSSKey(key string) error {
if key == "" || strings.HasPrefix(key, "/") || strings.Contains(key, "\\") || path.Clean(key) != key || key == "." || strings.HasPrefix(key, "../") {
return ErrUnsafeBlobKey
}
return nil
}
func escapeOSSKey(key string) string {
parts := strings.Split(key, "/")
for i := range parts {
parts[i] = url.PathEscape(parts[i])
}
return strings.Join(parts, "/")
}
var _ BlobStore = (*OSS)(nil)