Skip to content

Commit 2e21df0

Browse files
NewFutureCopilot
andauthored
feat(driver): add Azure Blob Storage driver (#8261)
* add azure-blob driver * fix nested folders copy * feat(driver): add Azure Blob Storage driver 实现 Azure Blob Storage 驱动,支持以下功能: - 使用共享密钥身份验证初始化连接 - 列出目录和文件 - 生成临时 SAS URL 进行文件访问 - 创建目录 - 移动和重命名文件/文件夹 - 复制文件/文件夹 - 删除文件/文件夹 - 上传文件并支持进度跟踪 此驱动允许用户通过 AList 平台无缝访问和管理 Azure Blob Storage 中的数据。 * feat(driver): update help doc for Azure Blob * doc(readme): add new driver * Update drivers/azure_blob/driver.go fix(azure): fix name check Co-authored-by: Copilot <[email protected]> * Update README.md doc(readme): fix the link Co-authored-by: Copilot <[email protected]> * fix(azure): fix log and link --------- Co-authored-by: Copilot <[email protected]>
1 parent af18cb1 commit 2e21df0

File tree

8 files changed

+775
-0
lines changed

8 files changed

+775
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ English | [中文](./README_cn.md) | [日本語](./README_ja.md) | [Contributing
7777
- [x] [Dropbox](https://www.dropbox.com/)
7878
- [x] [FeijiPan](https://www.feijipan.com/)
7979
- [x] [dogecloud](https://www.dogecloud.com/product/oss)
80+
- [x] [Azure Blob Storage](https://azure.microsoft.com/products/storage/blobs)
8081
- [x] Easy to deploy and out-of-the-box
8182
- [x] File preview (PDF, markdown, code, plain text, ...)
8283
- [x] Image preview in gallery mode

drivers/all.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
_ "github.com/alist-org/alist/v3/drivers/aliyundrive"
1717
_ "github.com/alist-org/alist/v3/drivers/aliyundrive_open"
1818
_ "github.com/alist-org/alist/v3/drivers/aliyundrive_share"
19+
_ "github.com/alist-org/alist/v3/drivers/azure_blob"
1920
_ "github.com/alist-org/alist/v3/drivers/baidu_netdisk"
2021
_ "github.com/alist-org/alist/v3/drivers/baidu_photo"
2122
_ "github.com/alist-org/alist/v3/drivers/baidu_share"

drivers/azure_blob/driver.go

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
package azure_blob
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"path"
8+
"regexp"
9+
"strings"
10+
"time"
11+
12+
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
13+
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
14+
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
15+
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container"
16+
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/sas"
17+
"github.com/alist-org/alist/v3/internal/driver"
18+
"github.com/alist-org/alist/v3/internal/model"
19+
)
20+
// Azure Blob Storage based on the blob APIs
21+
// Link: https://learn.microsoft.com/rest/api/storageservices/blob-service-rest-api
22+
type AzureBlob struct {
23+
model.Storage
24+
Addition
25+
client *azblob.Client
26+
containerClient *container.Client
27+
config driver.Config
28+
}
29+
30+
// Config returns the driver configuration.
31+
func (d *AzureBlob) Config() driver.Config {
32+
return d.config
33+
}
34+
35+
// GetAddition returns additional settings specific to Azure Blob Storage.
36+
func (d *AzureBlob) GetAddition() driver.Additional {
37+
return &d.Addition
38+
}
39+
40+
// Init initializes the Azure Blob Storage client using shared key authentication.
41+
func (d *AzureBlob) Init(ctx context.Context) error {
42+
// Validate the endpoint URL
43+
accountName := extractAccountName(d.Addition.Endpoint)
44+
if !regexp.MustCompile(`^[a-z0-9]+$`).MatchString(accountName) {
45+
return fmt.Errorf("invalid storage account name: must be chars of lowercase letters or numbers only")
46+
}
47+
48+
credential, err := azblob.NewSharedKeyCredential(accountName, d.Addition.AccessKey)
49+
if err != nil {
50+
return fmt.Errorf("failed to create credential: %w", err)
51+
}
52+
53+
// Check if Endpoint is just account name
54+
endpoint := d.Addition.Endpoint
55+
if accountName == endpoint {
56+
endpoint = fmt.Sprintf("https://%s.blob.core.windows.net/", accountName)
57+
}
58+
// Initialize Azure Blob client with retry policy
59+
client, err := azblob.NewClientWithSharedKeyCredential(endpoint, credential,
60+
&azblob.ClientOptions{ClientOptions: azcore.ClientOptions{
61+
Retry: policy.RetryOptions{
62+
MaxRetries: MaxRetries,
63+
RetryDelay: RetryDelay,
64+
},
65+
}})
66+
if err != nil {
67+
return fmt.Errorf("failed to create client: %w", err)
68+
}
69+
d.client = client
70+
71+
// Ensure container exists or create it
72+
containerName := strings.Trim(d.Addition.ContainerName, "/ \\")
73+
if containerName == "" {
74+
return fmt.Errorf("container name cannot be empty")
75+
}
76+
return d.createContainerIfNotExists(ctx, containerName)
77+
}
78+
79+
// Drop releases resources associated with the Azure Blob client.
80+
func (d *AzureBlob) Drop(ctx context.Context) error {
81+
d.client = nil
82+
return nil
83+
}
84+
85+
// List retrieves blobs and directories under the specified path.
86+
func (d *AzureBlob) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
87+
prefix := ensureTrailingSlash(dir.GetPath())
88+
89+
pager := d.containerClient.NewListBlobsHierarchyPager("/", &container.ListBlobsHierarchyOptions{
90+
Prefix: &prefix,
91+
})
92+
93+
var objs []model.Obj
94+
for pager.More() {
95+
page, err := pager.NextPage(ctx)
96+
if err != nil {
97+
return nil, fmt.Errorf("failed to list blobs: %w", err)
98+
}
99+
100+
// Process directories
101+
for _, blobPrefix := range page.Segment.BlobPrefixes {
102+
objs = append(objs, &model.Object{
103+
Name: path.Base(strings.TrimSuffix(*blobPrefix.Name, "/")),
104+
Path: *blobPrefix.Name,
105+
Modified: *blobPrefix.Properties.LastModified,
106+
Ctime: *blobPrefix.Properties.CreationTime,
107+
IsFolder: true,
108+
})
109+
}
110+
111+
// Process files
112+
for _, blob := range page.Segment.BlobItems {
113+
if strings.HasSuffix(*blob.Name, "/") {
114+
continue
115+
}
116+
objs = append(objs, &model.Object{
117+
Name: path.Base(*blob.Name),
118+
Path: *blob.Name,
119+
Size: *blob.Properties.ContentLength,
120+
Modified: *blob.Properties.LastModified,
121+
Ctime: *blob.Properties.CreationTime,
122+
IsFolder: false,
123+
})
124+
}
125+
}
126+
return objs, nil
127+
}
128+
129+
// Link generates a temporary SAS URL for accessing a blob.
130+
func (d *AzureBlob) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
131+
blobClient := d.containerClient.NewBlobClient(file.GetPath())
132+
expireDuration := time.Hour * time.Duration(d.SignURLExpire)
133+
134+
sasURL, err := blobClient.GetSASURL(sas.BlobPermissions{Read: true}, time.Now().Add(expireDuration), nil)
135+
if err != nil {
136+
return nil, fmt.Errorf("failed to generate SAS URL: %w", err)
137+
}
138+
return &model.Link{URL: sasURL}, nil
139+
}
140+
141+
// MakeDir creates a virtual directory by uploading an empty blob as a marker.
142+
func (d *AzureBlob) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
143+
dirPath := path.Join(parentDir.GetPath(), dirName)
144+
if err := d.mkDir(ctx, dirPath); err != nil {
145+
return nil, fmt.Errorf("failed to create directory marker: %w", err)
146+
}
147+
148+
return &model.Object{
149+
Path: dirPath,
150+
Name: dirName,
151+
IsFolder: true,
152+
}, nil
153+
}
154+
155+
// Move relocates an object (file or directory) to a new directory.
156+
func (d *AzureBlob) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
157+
srcPath := srcObj.GetPath()
158+
dstPath := path.Join(dstDir.GetPath(), srcObj.GetName())
159+
160+
if err := d.moveOrRename(ctx, srcPath, dstPath, srcObj.IsDir(), srcObj.GetSize()); err != nil {
161+
return nil, fmt.Errorf("move operation failed: %w", err)
162+
}
163+
164+
return &model.Object{
165+
Path: dstPath,
166+
Name: srcObj.GetName(),
167+
Modified: time.Now(),
168+
IsFolder: srcObj.IsDir(),
169+
Size: srcObj.GetSize(),
170+
}, nil
171+
}
172+
173+
// Rename changes the name of an existing object.
174+
func (d *AzureBlob) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) {
175+
srcPath := srcObj.GetPath()
176+
dstPath := path.Join(path.Dir(srcPath), newName)
177+
178+
if err := d.moveOrRename(ctx, srcPath, dstPath, srcObj.IsDir(), srcObj.GetSize()); err != nil {
179+
return nil, fmt.Errorf("rename operation failed: %w", err)
180+
}
181+
182+
return &model.Object{
183+
Path: dstPath,
184+
Name: newName,
185+
Modified: time.Now(),
186+
IsFolder: srcObj.IsDir(),
187+
Size: srcObj.GetSize(),
188+
}, nil
189+
}
190+
191+
// Copy duplicates an object (file or directory) to a specified destination directory.
192+
func (d *AzureBlob) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
193+
dstPath := path.Join(dstDir.GetPath(), srcObj.GetName())
194+
195+
// Handle directory copying using flat listing
196+
if srcObj.IsDir() {
197+
srcPrefix := srcObj.GetPath()
198+
srcPrefix = ensureTrailingSlash(srcPrefix)
199+
200+
// Get all blobs under the source directory
201+
blobs, err := d.flattenListBlobs(ctx, srcPrefix)
202+
if err != nil {
203+
return nil, fmt.Errorf("failed to list source directory contents: %w", err)
204+
}
205+
206+
// Process each blob - copy to destination
207+
for _, blob := range blobs {
208+
// Skip the directory marker itself
209+
if *blob.Name == srcPrefix {
210+
continue
211+
}
212+
213+
// Calculate relative path from source
214+
relPath := strings.TrimPrefix(*blob.Name, srcPrefix)
215+
itemDstPath := path.Join(dstPath, relPath)
216+
217+
if strings.HasSuffix(itemDstPath, "/") || (blob.Metadata["hdi_isfolder"] != nil && *blob.Metadata["hdi_isfolder"] == "true") {
218+
// Create directory marker at destination
219+
err := d.mkDir(ctx, itemDstPath)
220+
if err != nil {
221+
return nil, fmt.Errorf("failed to create directory marker [%s]: %w", itemDstPath, err)
222+
}
223+
} else {
224+
// Copy the blob
225+
if err := d.copyFile(ctx, *blob.Name, itemDstPath); err != nil {
226+
return nil, fmt.Errorf("failed to copy %s: %w", *blob.Name, err)
227+
}
228+
}
229+
230+
}
231+
232+
// Create directory marker at destination if needed
233+
if len(blobs) == 0 {
234+
err := d.mkDir(ctx, dstPath)
235+
if err != nil {
236+
return nil, fmt.Errorf("failed to create directory [%s]: %w", dstPath, err)
237+
}
238+
}
239+
240+
return &model.Object{
241+
Path: dstPath,
242+
Name: srcObj.GetName(),
243+
Modified: time.Now(),
244+
IsFolder: true,
245+
}, nil
246+
}
247+
248+
// Copy a single file
249+
if err := d.copyFile(ctx, srcObj.GetPath(), dstPath); err != nil {
250+
return nil, fmt.Errorf("failed to copy blob: %w", err)
251+
}
252+
return &model.Object{
253+
Path: dstPath,
254+
Name: srcObj.GetName(),
255+
Size: srcObj.GetSize(),
256+
Modified: time.Now(),
257+
IsFolder: false,
258+
}, nil
259+
}
260+
261+
// Remove deletes a specified blob or recursively deletes a directory and its contents.
262+
func (d *AzureBlob) Remove(ctx context.Context, obj model.Obj) error {
263+
path := obj.GetPath()
264+
265+
// Handle recursive directory deletion
266+
if obj.IsDir() {
267+
return d.deleteFolder(ctx, path)
268+
}
269+
270+
// Delete single file
271+
return d.deleteFile(ctx, path, false)
272+
}
273+
274+
// Put uploads a file stream to Azure Blob Storage with progress tracking.
275+
func (d *AzureBlob) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) {
276+
blobPath := path.Join(dstDir.GetPath(), stream.GetName())
277+
blobClient := d.containerClient.NewBlockBlobClient(blobPath)
278+
279+
// Determine optimal upload options based on file size
280+
options := optimizedUploadOptions(stream.GetSize())
281+
282+
// Track upload progress
283+
progressTracker := &progressTracker{
284+
total: stream.GetSize(),
285+
updateProgress: up,
286+
}
287+
288+
// Wrap stream to handle context cancellation and progress tracking
289+
limitedStream := driver.NewLimitedUploadStream(ctx, io.TeeReader(stream, progressTracker))
290+
291+
// Upload the stream to Azure Blob Storage
292+
_, err := blobClient.UploadStream(ctx, limitedStream, options)
293+
if err != nil {
294+
return nil, fmt.Errorf("failed to upload file: %w", err)
295+
}
296+
297+
return &model.Object{
298+
Path: blobPath,
299+
Name: stream.GetName(),
300+
Size: stream.GetSize(),
301+
Modified: time.Now(),
302+
IsFolder: false,
303+
}, nil
304+
}
305+
306+
// The following methods related to archive handling are not implemented yet.
307+
// func (d *AzureBlob) GetArchiveMeta(...) {...}
308+
// func (d *AzureBlob) ListArchive(...) {...}
309+
// func (d *AzureBlob) Extract(...) {...}
310+
// func (d *AzureBlob) ArchiveDecompress(...) {...}
311+
312+
// Ensure AzureBlob implements the driver.Driver interface.
313+
var _ driver.Driver = (*AzureBlob)(nil)

drivers/azure_blob/meta.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package azure_blob
2+
3+
import (
4+
"github.com/alist-org/alist/v3/internal/driver"
5+
"github.com/alist-org/alist/v3/internal/op"
6+
)
7+
8+
type Addition struct {
9+
Endpoint string `json:"endpoint" required:"true" default:"https://<accountname>.blob.core.windows.net/" help:"e.g. https://accountname.blob.core.windows.net/. The full endpoint URL for Azure Storage, including the unique storage account name (3 ~ 24 numbers and lowercase letters only)."`
10+
AccessKey string `json:"access_key" required:"true" help:"The access key for Azure Storage, used for authentication. https://learn.microsoft.com/azure/storage/common/storage-account-keys-manage"`
11+
ContainerName string `json:"container_name" required:"true" help:"The name of the container in Azure Storage (created in the Azure portal). https://learn.microsoft.com/azure/storage/blobs/blob-containers-portal"`
12+
SignURLExpire int `json:"sign_url_expire" type:"number" default:"4" help:"The expiration time for SAS URLs, in hours."`
13+
}
14+
15+
var config = driver.Config{
16+
Name: "Azure Blob Storage",
17+
LocalSort: true,
18+
CheckStatus: true,
19+
}
20+
21+
func init() {
22+
op.RegisterDriver(func() driver.Driver {
23+
return &AzureBlob{
24+
config: config,
25+
}
26+
})
27+
}

drivers/azure_blob/types.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package azure_blob
2+
3+
import "github.com/alist-org/alist/v3/internal/driver"
4+
5+
// progressTracker is used to track upload progress
6+
type progressTracker struct {
7+
total int64
8+
current int64
9+
updateProgress driver.UpdateProgress
10+
}
11+
12+
// Write implements io.Writer to track progress
13+
func (pt *progressTracker) Write(p []byte) (n int, err error) {
14+
n = len(p)
15+
pt.current += int64(n)
16+
if pt.updateProgress != nil && pt.total > 0 {
17+
pt.updateProgress(float64(pt.current) * 100 / float64(pt.total))
18+
}
19+
return n, nil
20+
}

0 commit comments

Comments
 (0)