package assets import ( "context" "crypto/hmac" "crypto/sha1" "crypto/tls" "encoding/base64" "encoding/xml" "errors" "fmt" "io" "log" "net" "net/http" "net/url" "path" "sort" "strconv" "strings" "syscall" "time" ) const maxOSSErrorBody = 64 << 10 const ( DefaultSignedURLTTL = time.Hour MaximumSignedURLTTL = 9 * time.Hour ) // 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) } // SignGetURL creates an OSS V1 query-signed GET URL. The expiration is bounded // to OSS's documented maximum and the operation is local: no network request is // performed and no secret is embedded in the resulting URL. func (c *OSSHTTPClient) SignGetURL(request OSSObjectRequest, ttl time.Duration) (string, error) { if ttl <= 0 || ttl > MaximumSignedURLTTL { return "", errors.New("OSS signed URL lifetime must be between zero and nine hours") } requestURL, err := ossObjectURL(request.Endpoint, request.Bucket, request.Key, "") if err != nil { return "", err } expires := c.now().UTC().Add(ttl).Unix() canonicalResource := "/" + request.Bucket + "/" + request.Key stringToSign := strings.Join([]string{http.MethodGet, "", "", strconv.FormatInt(expires, 10), canonicalResource}, "\n") mac := hmac.New(sha1.New, []byte(c.accessKeySecret)) _, _ = mac.Write([]byte(stringToSign)) signature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) query := requestURL.Query() query.Set("OSSAccessKeyId", c.accessKeyID) query.Set("Expires", strconv.FormatInt(expires, 10)) query.Set("Signature", signature) requestURL.RawQuery = query.Encode() return requestURL.String(), nil } 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 } diagnostic := diagnoseOSSTransport(req, err) log.Printf( "zhinian-api OSS transport request failed method=%s endpointClass=%s errorClass=%s", diagnostic.Method, diagnostic.EndpointClass, diagnostic.ErrorClass, ) if ctxErr := req.Context().Err(); ctxErr != nil { return nil, ctxErr } return nil, &OSSError{Err: errors.New("OSS transport request failed")} } type ossTransportDiagnostic struct { Method string EndpointClass string ErrorClass string } func diagnoseOSSTransport(req *http.Request, err error) ossTransportDiagnostic { diagnostic := ossTransportDiagnostic{ErrorClass: classifyOSSTransportError(err)} if req == nil { diagnostic.Method = "unknown" diagnostic.EndpointClass = "unknown" return diagnostic } diagnostic.Method = req.Method host := strings.ToLower(req.URL.Hostname()) switch { case strings.Contains(host, "-internal.") || strings.HasSuffix(host, "-internal"): diagnostic.EndpointClass = "internal" case strings.HasSuffix(host, ".aliyuncs.com"): diagnostic.EndpointClass = "public" default: diagnostic.EndpointClass = "custom" } return diagnostic } func classifyOSSTransportError(err error) string { if errors.Is(err, context.Canceled) { return "canceled" } if errors.Is(err, context.DeadlineExceeded) { return "timeout" } var dnsErr *net.DNSError if errors.As(err, &dnsErr) { if dnsErr.IsTimeout { return "dns_timeout" } return "dns" } var tlsErr *tls.CertificateVerificationError if errors.As(err, &tlsErr) { return "tls_certificate" } switch { case errors.Is(err, syscall.ECONNRESET): return "connection_reset" case errors.Is(err, syscall.ECONNREFUSED): return "connection_refused" case errors.Is(err, syscall.ENETUNREACH): return "network_unreachable" case errors.Is(err, syscall.EHOSTUNREACH): return "host_unreachable" } var netErr net.Error if errors.As(err, &netErr) && netErr.Timeout() { return "timeout" } return "transport" } 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) var _ OSSURLSigner = (*OSSHTTPClient)(nil)