137 lines
4.0 KiB
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)
|