diff --git a/azure.go b/azure.go index 375ec8e..0b9bd83 100644 --- a/azure.go +++ b/azure.go @@ -54,13 +54,31 @@ type azureBLOBStorage struct { serviceUrl string } +// serviceURL returns the base blob service URL for the account. A custom +// Endpoint (an Azurite emulator or a sovereign cloud such as +// *.blob.core.chinacloudapi.cn) overrides the default global-cloud host. +func (c *AzureConfig) serviceURL() (*url.URL, error) { + if c.Endpoint != "" { + return url.Parse(c.Endpoint) + } + return &url.URL{ + Scheme: "https", + Host: fmt.Sprintf("%s.blob.core.windows.net", c.AccountName), + }, nil +} + func NewAzure(conf *AzureConfig) (Storage, error) { cred, err := azblob.NewSharedKeyCredential(conf.AccountName, conf.AccountKey) if err != nil { return nil, err } - serviceUrl := fmt.Sprintf("https://%s.blob.core.windows.net/", conf.AccountName) + base, err := conf.serviceURL() + if err != nil { + return nil, err + } + serviceUrl := base.String() + client, err := azblob.NewClientWithSharedKeyCredential(serviceUrl, cred, nil) if err != nil { return nil, err @@ -74,11 +92,12 @@ func NewAzure(conf *AzureConfig) (Storage, error) { } func (s *azureBLOBStorage) location(storagePath string) string { - return (&url.URL{ - Scheme: "https", - Host: fmt.Sprintf("%s.blob.core.windows.net", s.conf.AccountName), - Path: path.Join(s.conf.ContainerName, storagePath), - }).String() + u, err := s.conf.serviceURL() + if err != nil { + return "" + } + u.Path = path.Join(u.Path, s.conf.ContainerName, storagePath) + return u.String() } func (s *azureBLOBStorage) UploadData(data []byte, storagePath, contentType string) (string, int64, error) { @@ -218,12 +237,12 @@ func (s *azureBLOBStorage) GeneratePresignedUrl(storagePath string, expiration t return "", err } - loc := &url.URL{ - Scheme: "https", - Host: fmt.Sprintf("%s.blob.core.windows.net", s.conf.AccountName), - Path: path.Join(s.conf.ContainerName, storagePath), - RawQuery: qp.Encode(), + loc, err := s.conf.serviceURL() + if err != nil { + return "", err } + loc.Path = path.Join(loc.Path, s.conf.ContainerName, storagePath) + loc.RawQuery = qp.Encode() return loc.String(), nil } diff --git a/config.go b/config.go index 4c719c6..d2766aa 100644 --- a/config.go +++ b/config.go @@ -37,7 +37,8 @@ type AzureConfig struct { AccountName string `yaml:"account_name,omitempty"` // (env AZURE_STORAGE_ACCOUNT) AccountKey string `yaml:"account_key,omitempty"` // (env AZURE_STORAGE_KEY) ContainerName string `yaml:"container_name,omitempty"` - TokenCredential azcore.TokenCredential `yaml:"-"` // required for presigned url generation + Endpoint string `yaml:"endpoint,omitempty"` // blob service URL; overrides the default https://.blob.core.windows.net (e.g. an Azurite emulator or a sovereign cloud like *.blob.core.chinacloudapi.cn) + TokenCredential azcore.TokenCredential `yaml:"-"` // required for presigned url generation } func (c *AzureConfig) newStorage() (Storage, error) { return NewAzure(c) } diff --git a/location_test.go b/location_test.go index b390b79..d37fb89 100644 --- a/location_test.go +++ b/location_test.go @@ -143,6 +143,38 @@ func TestAzureLocation(t *testing.T) { } } +func TestAzureLocationCustomEndpoint(t *testing.T) { + cases := []struct { + name string + conf AzureConfig + storagePath string + want string + }{ + { + name: "azurite emulator preserves scheme and account path", + conf: AzureConfig{AccountName: "devstoreaccount1", ContainerName: "mycontainer", Endpoint: "http://127.0.0.1:10000/devstoreaccount1"}, + storagePath: "foo.mp4", + want: "http://127.0.0.1:10000/devstoreaccount1/mycontainer/foo.mp4", + }, + { + name: "azurite emulator nested path", + conf: AzureConfig{AccountName: "devstoreaccount1", ContainerName: "mycontainer", Endpoint: "http://127.0.0.1:10000/devstoreaccount1"}, + storagePath: "a/b/c.mp4", + want: "http://127.0.0.1:10000/devstoreaccount1/mycontainer/a/b/c.mp4", + }, + { + name: "sovereign cloud host", + conf: AzureConfig{AccountName: "acct", ContainerName: "mycontainer", Endpoint: "https://acct.blob.core.chinacloudapi.cn"}, + storagePath: "foo.mp4", + want: "https://acct.blob.core.chinacloudapi.cn/mycontainer/foo.mp4", + }, + } + for _, tc := range cases { + s := &azureBLOBStorage{conf: &tc.conf} + require.Equal(t, tc.want, s.location(tc.storagePath), tc.name) + } +} + func TestGCPLocation(t *testing.T) { cases := []struct { name string