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)