Skip to content

Commit 1e327b7

Browse files
committed
feat(driver): add CSTCloud Capsule (数据胶囊) driver
Add a dedicated driver for China Science and Technology Cloud's data capsule service (https://data.cstcloud.cn), requested in #9584. The service exposes WebDAV at the fixed endpoint https://data.cstcloud.cn/dav, but gates every request on the app type the credential was created for: requests whose User-Agent does not contain the app type name are rejected with 403 "Client type mismatch". Zotero is currently the only WebDAV app type offered, which is why generic WebDAV clients (including the generic WebDav driver, rclone, Cyberduck, ...) cannot mount the service at all. The driver bakes in the endpoint, sends a configurable User-Agent that satisfies the client-type gate (defaults to a Zotero-compatible one), propagates it on download links, and validates credentials at mount time so misconfiguration surfaces immediately with a clear error. Note: the service additionally restricts WebDAV uploads to .zip/.prop files server-side (Zotero sync's file types); this is documented in the driver help text. Verified end to end against a real account: mount, list, mkdir, upload (.zip), download (checksum-identical), move and delete all work. Unit tests cover the Basic auth gate, the client-type gate and listing against a stub of the DC WebDAV endpoint.
1 parent aead76e commit 1e327b7

4 files changed

Lines changed: 277 additions & 0 deletions

File tree

drivers/all.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
_ "github.com/alist-org/alist/v3/drivers/cloudreve"
2929
_ "github.com/alist-org/alist/v3/drivers/cloudreve_v4"
3030
_ "github.com/alist-org/alist/v3/drivers/crypt"
31+
_ "github.com/alist-org/alist/v3/drivers/cstcloud_capsule"
3132
_ "github.com/alist-org/alist/v3/drivers/darkibox"
3233
_ "github.com/alist-org/alist/v3/drivers/doubao"
3334
_ "github.com/alist-org/alist/v3/drivers/doubao_new"

drivers/cstcloud_capsule/driver.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package cstcloud_capsule
2+
3+
import (
4+
"context"
5+
"crypto/tls"
6+
"net/http"
7+
"net/http/cookiejar"
8+
"os"
9+
"path"
10+
11+
"github.com/alist-org/alist/v3/internal/conf"
12+
"github.com/alist-org/alist/v3/internal/driver"
13+
"github.com/alist-org/alist/v3/internal/model"
14+
"github.com/alist-org/alist/v3/pkg/gowebdav"
15+
"github.com/alist-org/alist/v3/pkg/utils"
16+
)
17+
18+
// CSTCloudCapsule mounts 中国科技云·数据胶囊 (https://data.cstcloud.cn) via its
19+
// WebDAV endpoint. The endpoint is fixed for the whole service; credentials
20+
// are the per-space WebDAV username/password created on the client access page.
21+
var webdavAddress = "https://data.cstcloud.cn/dav"
22+
23+
type CSTCloudCapsule struct {
24+
model.Storage
25+
Addition
26+
client *gowebdav.Client
27+
}
28+
29+
func (d *CSTCloudCapsule) Config() driver.Config {
30+
return config
31+
}
32+
33+
func (d *CSTCloudCapsule) GetAddition() driver.Additional {
34+
return &d.Addition
35+
}
36+
37+
func (d *CSTCloudCapsule) Init(ctx context.Context) error {
38+
c := gowebdav.NewClient(webdavAddress, d.Username, d.Password)
39+
c.SetTransport(&http.Transport{
40+
Proxy: http.ProxyFromEnvironment,
41+
TLSClientConfig: &tls.Config{InsecureSkipVerify: conf.Conf.TlsInsecureSkipVerify},
42+
})
43+
jar, err := cookiejar.New(nil)
44+
if err != nil {
45+
return err
46+
}
47+
c.SetJar(jar)
48+
// the server gates every request on the credential's app type via
49+
// User-Agent; without a matching UA it responds 403 "Client type mismatch"
50+
c.SetInterceptor(func(method string, rq *http.Request) {
51+
rq.Header.Set("User-Agent", d.UserAgent)
52+
})
53+
d.client = c
54+
// validate credentials at mount time so misconfiguration surfaces here
55+
// instead of as an empty/broken listing later
56+
_, err = d.client.ReadDir(d.GetRootPath())
57+
return err
58+
}
59+
60+
func (d *CSTCloudCapsule) Drop(ctx context.Context) error {
61+
return nil
62+
}
63+
64+
func (d *CSTCloudCapsule) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
65+
files, err := d.client.ReadDir(dir.GetPath())
66+
if err != nil {
67+
return nil, err
68+
}
69+
return utils.SliceConvert(files, func(src os.FileInfo) (model.Obj, error) {
70+
return &model.Object{
71+
Name: src.Name(),
72+
Size: src.Size(),
73+
Modified: src.ModTime(),
74+
IsFolder: src.IsDir(),
75+
}, nil
76+
})
77+
}
78+
79+
func (d *CSTCloudCapsule) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
80+
url, header, err := d.client.Link(file.GetPath())
81+
if err != nil {
82+
return nil, err
83+
}
84+
if header == nil {
85+
header = http.Header{}
86+
}
87+
header.Set("User-Agent", d.UserAgent)
88+
return &model.Link{
89+
URL: url,
90+
Header: header,
91+
}, nil
92+
}
93+
94+
func (d *CSTCloudCapsule) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
95+
return d.client.MkdirAll(path.Join(parentDir.GetPath(), dirName), 0644)
96+
}
97+
98+
func (d *CSTCloudCapsule) Move(ctx context.Context, srcObj, dstDir model.Obj) error {
99+
return d.client.Rename(getPath(srcObj), path.Join(dstDir.GetPath(), srcObj.GetName()), true)
100+
}
101+
102+
func (d *CSTCloudCapsule) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
103+
return d.client.Rename(getPath(srcObj), path.Join(path.Dir(srcObj.GetPath()), newName), true)
104+
}
105+
106+
func (d *CSTCloudCapsule) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
107+
return d.client.Copy(getPath(srcObj), path.Join(dstDir.GetPath(), srcObj.GetName()), true)
108+
}
109+
110+
func (d *CSTCloudCapsule) Remove(ctx context.Context, obj model.Obj) error {
111+
return d.client.RemoveAll(getPath(obj))
112+
}
113+
114+
func (d *CSTCloudCapsule) Put(ctx context.Context, dstDir model.Obj, s model.FileStreamer, up driver.UpdateProgress) error {
115+
callback := func(r *http.Request) {
116+
r.Header.Set("Content-Type", s.GetMimetype())
117+
r.ContentLength = s.GetSize()
118+
}
119+
reader := driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{
120+
Reader: s,
121+
UpdateProgress: up,
122+
})
123+
return d.client.WriteStream(path.Join(dstDir.GetPath(), s.GetName()), reader, 0644, callback)
124+
}
125+
126+
func getPath(obj model.Obj) string {
127+
if obj.IsDir() {
128+
return obj.GetPath() + "/"
129+
}
130+
return obj.GetPath()
131+
}
132+
133+
var _ driver.Driver = (*CSTCloudCapsule)(nil)
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package cstcloud_capsule
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
11+
"github.com/alist-org/alist/v3/internal/conf"
12+
"github.com/alist-org/alist/v3/internal/model"
13+
)
14+
15+
const multistatus = `<?xml version="1.0" encoding="utf-8"?>
16+
<d:multistatus xmlns:d="DAV:">
17+
<d:response>
18+
<d:href>/dav/</d:href>
19+
<d:propstat>
20+
<d:prop><d:resourcetype><d:collection/></d:resourcetype><d:displayname></d:displayname></d:prop>
21+
<d:status>HTTP/1.1 200 OK</d:status>
22+
</d:propstat>
23+
</d:response>
24+
<d:response>
25+
<d:href>/dav/hello.txt</d:href>
26+
<d:propstat>
27+
<d:prop>
28+
<d:resourcetype/>
29+
<d:displayname>hello.txt</d:displayname>
30+
<d:getcontentlength>5</d:getcontentlength>
31+
<d:getlastmodified>Mon, 02 Jan 2006 15:04:05 GMT</d:getlastmodified>
32+
</d:prop>
33+
<d:status>HTTP/1.1 200 OK</d:status>
34+
</d:propstat>
35+
</d:response>
36+
</d:multistatus>`
37+
38+
// stub mimicking the DC WebDAV endpoint: Basic auth guarded PROPFIND with
39+
// the same client-type gate as the real server (UA must contain the app
40+
// type the credential was created for, case-insensitive)
41+
func newStub(t *testing.T) *httptest.Server {
42+
t.Helper()
43+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
44+
user, pass, ok := r.BasicAuth()
45+
if !ok || user != "spaceuser" || pass != "spacepass" {
46+
w.Header().Set("WWW-Authenticate", `Basic realm="DC WebDAV"`)
47+
w.WriteHeader(http.StatusUnauthorized)
48+
return
49+
}
50+
if !strings.Contains(strings.ToLower(r.UserAgent()), "zotero") {
51+
w.WriteHeader(http.StatusForbidden)
52+
fmt.Fprint(w, "Client type mismatch.")
53+
return
54+
}
55+
if r.Method != "PROPFIND" {
56+
w.WriteHeader(http.StatusMethodNotAllowed)
57+
return
58+
}
59+
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
60+
w.WriteHeader(207)
61+
fmt.Fprint(w, multistatus)
62+
}))
63+
}
64+
65+
func setup(t *testing.T, username, password string) *CSTCloudCapsule {
66+
t.Helper()
67+
if conf.Conf == nil {
68+
conf.Conf = conf.DefaultConfig()
69+
}
70+
srv := newStub(t)
71+
t.Cleanup(srv.Close)
72+
old := webdavAddress
73+
webdavAddress = srv.URL + "/dav"
74+
t.Cleanup(func() { webdavAddress = old })
75+
76+
d := &CSTCloudCapsule{}
77+
d.Username = username
78+
d.Password = password
79+
d.UserAgent = "Mozilla/5.0 (compatible; Zotero/8.0) AList"
80+
d.RootFolderPath = "/"
81+
return d
82+
}
83+
84+
func TestInitRejectsMismatchedClientType(t *testing.T) {
85+
d := setup(t, "spaceuser", "spacepass")
86+
d.UserAgent = "gowebdav"
87+
if err := d.Init(context.Background()); err == nil {
88+
t.Fatal("Init succeeded with non-matching User-Agent, want client type error")
89+
}
90+
}
91+
92+
func TestInitRejectsBadCredentials(t *testing.T) {
93+
d := setup(t, "spaceuser", "wrong")
94+
if err := d.Init(context.Background()); err == nil {
95+
t.Fatal("Init succeeded with wrong password, want error")
96+
}
97+
}
98+
99+
func TestInitAndList(t *testing.T) {
100+
d := setup(t, "spaceuser", "spacepass")
101+
if err := d.Init(context.Background()); err != nil {
102+
t.Fatalf("Init: %v", err)
103+
}
104+
objs, err := d.List(context.Background(), &model.Object{Path: "/"}, model.ListArgs{})
105+
if err != nil {
106+
t.Fatalf("List: %v", err)
107+
}
108+
if len(objs) != 1 {
109+
t.Fatalf("List returned %d objects, want 1", len(objs))
110+
}
111+
if objs[0].GetName() != "hello.txt" || objs[0].GetSize() != 5 || objs[0].IsDir() {
112+
t.Fatalf("unexpected object: name=%s size=%d dir=%v", objs[0].GetName(), objs[0].GetSize(), objs[0].IsDir())
113+
}
114+
}

drivers/cstcloud_capsule/meta.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package cstcloud_capsule
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+
Username string `json:"username" required:"true" help:"WebDAV username created in the data space's client access page. Note: the service currently only allows uploading .zip/.prop files over WebDAV / 在数据空间「客户端访问」中创建的 WebDAV 用户名。注意:服务端目前仅允许通过 WebDAV 上传 .zip/.prop 文件"`
10+
Password string `json:"password" required:"true" help:"WebDAV password shown when the credential is created / 创建凭证时显示的 WebDAV 密码"`
11+
// The server rejects requests whose User-Agent does not contain the app
12+
// type the credential was created for ("Client type mismatch"). Zotero is
13+
// currently the only WebDAV app type offered, so it is the default here.
14+
UserAgent string `json:"user_agent" required:"true" default:"Mozilla/5.0 (compatible; Zotero/8.0) AList" help:"Must contain the app type chosen when creating the credential / 必须包含创建凭证时所选的应用类型(如 Zotero)"`
15+
driver.RootPath
16+
}
17+
18+
var config = driver.Config{
19+
Name: "CSTCloudCapsule",
20+
LocalSort: true,
21+
OnlyProxy: true,
22+
DefaultRoot: "/",
23+
}
24+
25+
func init() {
26+
op.RegisterDriver(func() driver.Driver {
27+
return &CSTCloudCapsule{}
28+
})
29+
}

0 commit comments

Comments
 (0)