package assets import ( "context" "crypto/hmac" "crypto/sha1" "encoding/base64" "encoding/xml" "errors" "fmt" "io" "net/http" "net/url" "path" "sort" "strings" "time" ) const maxOSSErrorBody = 64 << 10 // OSSHTTPClient implements OSSClient using Aliyun OSS's HTTP authorization // protocol. The supplied HTTP client owns timeout and transport policy. type OSSHTTPClient struct { accessKeyID string accessKeySecret string client *http.Client now func() time.Time } // NewOSSHTTPClient constructs an OSS adapter with injected credentials, // transport, and clock. Callers should supply an http.Client with a bounded // Timeout. func NewOSSHTTPClient(accessKeyID, accessKeySecret string, client *http.Client, now func() time.Time) (*OSSHTTPClient, error) { if strings.TrimSpace(accessKeyID) == "" || strings.TrimSpace(accessKeySecret) == "" { return nil, errors.New("OSS access key ID and secret are required") } if client == nil { return nil, errors.New("OSS HTTP client is required") } if now == nil { now = time.Now } return &OSSHTTPClient{accessKeyID: accessKeyID, accessKeySecret: accessKeySecret, client: client, now: now}, nil } func (c *OSSHTTPClient) Put(ctx context.Context, request OSSPutRequest) error { req, err := c.newRequest(ctx, http.MethodPut, request.Endpoint, request.Bucket, request.Key, "", request.Body) if err != nil { return err } if request.Size < 0 { return errors.New("OSS object size must not be negative") } req.ContentLength = request.Size req.Header.Set("Content-Length", fmt.Sprintf("%d", request.Size)) if request.ContentType != "" { req.Header.Set("Content-Type", request.ContentType) } c.sign(req, request.Bucket, request.Key, "") response, err := c.do(req) if err != nil { return err } return consumeOSSResponse(response) } func (c *OSSHTTPClient) SetACL(ctx context.Context, request OSSACLRequest) error { req, err := c.newRequest(ctx, http.MethodPut, request.Endpoint, request.Bucket, request.Key, "acl", nil) if err != nil { return err } req.Header.Set("x-oss-object-acl", request.ACL) c.sign(req, request.Bucket, request.Key, "acl") response, err := c.do(req) if err != nil { return err } return consumeOSSResponse(response) } func (c *OSSHTTPClient) Get(ctx context.Context, request OSSObjectRequest) (Blob, error) { req, err := c.newRequest(ctx, http.MethodGet, request.Endpoint, request.Bucket, request.Key, "", nil) if err != nil { return Blob{}, err } c.sign(req, request.Bucket, request.Key, "") response, err := c.do(req) if err != nil { return Blob{}, err } if !successfulOSSStatus(response.StatusCode) { return Blob{}, readOSSError(response) } return Blob{Body: response.Body, ContentType: response.Header.Get("Content-Type"), Size: response.ContentLength}, nil } func (c *OSSHTTPClient) Delete(ctx context.Context, request OSSObjectRequest) error { req, err := c.newRequest(ctx, http.MethodDelete, request.Endpoint, request.Bucket, request.Key, "", nil) if err != nil { return err } c.sign(req, request.Bucket, request.Key, "") response, err := c.do(req) if err != nil { return err } return consumeOSSResponse(response) } func (c *OSSHTTPClient) newRequest(ctx context.Context, method, endpoint, bucket, key, subresource string, body io.Reader) (*http.Request, error) { requestURL, err := ossObjectURL(endpoint, bucket, key, subresource) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, method, requestURL.String(), body) if err != nil { return nil, errors.New("build OSS request") } return req, nil } func (c *OSSHTTPClient) sign(req *http.Request, bucket, key, subresource string) { req.Header.Set("Date", c.now().UTC().Format(http.TimeFormat)) canonicalResource := "/" + bucket + "/" + key if subresource != "" { canonicalResource += "?" + subresource } stringToSign := strings.Join([]string{ req.Method, req.Header.Get("Content-MD5"), req.Header.Get("Content-Type"), req.Header.Get("Date"), canonicalOSSHeaders(req.Header) + canonicalResource, }, "\n") mac := hmac.New(sha1.New, []byte(c.accessKeySecret)) _, _ = mac.Write([]byte(stringToSign)) signature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) req.Header.Set("Authorization", "OSS "+c.accessKeyID+":"+signature) } func (c *OSSHTTPClient) do(req *http.Request) (*http.Response, error) { response, err := c.client.Do(req) if err == nil { return response, nil } if ctxErr := req.Context().Err(); ctxErr != nil { return nil, ctxErr } return nil, &OSSError{Err: errors.New("OSS transport request failed")} } func ossObjectURL(endpoint, bucket, key, subresource string) (*url.URL, error) { endpoint = strings.TrimSpace(endpoint) if !strings.Contains(endpoint, "://") { endpoint = "https://" + endpoint } u, err := url.Parse(endpoint) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { return nil, errors.New("invalid OSS endpoint") } if bucket == "" || strings.ContainsAny(bucket, "/\\") || key == "" || strings.HasPrefix(key, "/") { return nil, errors.New("invalid OSS bucket or object key") } basePath := strings.TrimRight(u.Path, "/") pathStyle := basePath != "" && path.Base(basePath) == bucket hostHasBucket := strings.HasPrefix(strings.ToLower(u.Hostname()), strings.ToLower(bucket)+".") if !pathStyle && !hostHasBucket { hostname := bucket + "." + u.Hostname() if port := u.Port(); port != "" { hostname += ":" + port } u.Host = hostname } u.Path = basePath + "/" + key u.RawPath = strings.TrimRight(escapedURLPath(basePath), "/") + "/" + escapeOSSKey(key) if subresource != "" { u.RawQuery = subresource } return u, nil } func escapedURLPath(value string) string { if value == "" { return "" } parts := strings.Split(value, "/") for i := range parts { parts[i] = url.PathEscape(parts[i]) } return strings.Join(parts, "/") } func canonicalOSSHeaders(header http.Header) string { keys := make([]string, 0) values := make(map[string]string) for key, entries := range header { lowerKey := strings.ToLower(key) if !strings.HasPrefix(lowerKey, "x-oss-") { continue } keys = append(keys, lowerKey) trimmed := make([]string, len(entries)) for i, entry := range entries { trimmed[i] = strings.TrimSpace(entry) } values[lowerKey] = strings.Join(trimmed, ",") } sort.Strings(keys) var canonical strings.Builder for _, key := range keys { fmt.Fprintf(&canonical, "%s:%s\n", key, values[key]) } return canonical.String() } func consumeOSSResponse(response *http.Response) error { if successfulOSSStatus(response.StatusCode) { _, _ = io.Copy(io.Discard, response.Body) return response.Body.Close() } return readOSSError(response) } func successfulOSSStatus(status int) bool { return status >= 200 && status < 300 } func readOSSError(response *http.Response) error { defer response.Body.Close() var wire struct { Code string `xml:"Code"` } _ = xml.NewDecoder(io.LimitReader(response.Body, maxOSSErrorBody)).Decode(&wire) return &OSSError{Status: response.StatusCode, Code: strings.TrimSpace(wire.Code)} } var _ OSSClient = (*OSSHTTPClient)(nil)