mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 12:06:38 +08:00
feat: improve OpenList cloud storage support
This commit is contained in:
@@ -30,6 +30,7 @@ const (
|
||||
TypeQuark = "quark" // 夸克网盘
|
||||
Type115 = "cloud115" // 115 网盘
|
||||
TypeCloudDrive2 = "clouddrive2" // CloudDrive2 桥接网盘
|
||||
TypeOpenList = "openlist" // OpenList / AList-compatible bridge
|
||||
)
|
||||
|
||||
// ErrUnsupported is returned for an unknown provider type.
|
||||
@@ -84,6 +85,8 @@ func New(typ string, cfg map[string]any, client *http.Client) (Provider, error)
|
||||
return new115(cfg, client), nil
|
||||
case TypeCloudDrive2:
|
||||
return newCloudDrive2(cfg, client), nil
|
||||
case TypeOpenList:
|
||||
return newOpenList(cfg, client), nil
|
||||
default:
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
@@ -91,7 +94,7 @@ func New(typ string, cfg map[string]any, client *http.Client) (Provider, error)
|
||||
|
||||
// IsCloudType reports whether typ is a cloud-disk provider.
|
||||
func IsCloudType(typ string) bool {
|
||||
return typ == TypeQuark || typ == Type115 || typ == TypeCloudDrive2
|
||||
return typ == TypeQuark || typ == Type115 || typ == TypeCloudDrive2 || typ == TypeOpenList
|
||||
}
|
||||
|
||||
// str coerces a config value to a trimmed string.
|
||||
|
||||
@@ -372,6 +372,104 @@ func TestCloudDrive2WebDAVListAndResolve(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenListWebDAVListAndResolve(t *testing.T) {
|
||||
var gotPath, gotDepth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotDepth = r.Header.Get("Depth")
|
||||
if r.Method != "PROPFIND" {
|
||||
t.Fatalf("unexpected method %s", r.Method)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>/dav/</d:href>
|
||||
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav/Cloud/Movie.mkv</d:href>
|
||||
<d:propstat><d:prop><d:displayname>Movie.mkv</d:displayname><d:getcontentlength>1024</d:getcontentlength><d:resourcetype/></d:prop></d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "username": "u", "password": "p"}, srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Type() != TypeOpenList {
|
||||
t.Fatalf("type = %q, want %q", p.Type(), TypeOpenList)
|
||||
}
|
||||
entries, err := p.List(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if gotPath != "/dav" {
|
||||
t.Fatalf("path = %q, want /dav", gotPath)
|
||||
}
|
||||
if gotDepth != "1" {
|
||||
t.Fatalf("Depth = %q, want 1", gotDepth)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].ID != "/Cloud/Movie.mkv" || entries[0].Size != 1024 {
|
||||
t.Fatalf("entries = %#v", entries)
|
||||
}
|
||||
link, err := p.Resolve(context.Background(), entries[0].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if link.URL != srv.URL+"/dav/Cloud/Movie.mkv" {
|
||||
t.Fatalf("bad url: %s", link.URL)
|
||||
}
|
||||
if !link.Proxy {
|
||||
t.Fatalf("openlist should default to proxy mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenListRootURLDefaultsToDAV(t *testing.T) {
|
||||
var gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/dav/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response></d:multistatus>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeOpenList, map[string]any{"url": srv.URL + "/"}, srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := p.List(context.Background(), ""); err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if gotPath != "/dav" {
|
||||
t.Fatalf("path = %q, want /dav", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenListDAVStatusErrorIncludesBodyHint(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
_, _ = w.Write([]byte("请先填写有效Cookie并保存"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeOpenList, map[string]any{"url": srv.URL + "/dav"}, srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = p.List(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Fatal("want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "请先填写有效Cookie并保存") || !strings.Contains(err.Error(), "WebDAV 地址") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedProvider(t *testing.T) {
|
||||
if _, err := New("dropbox", nil, nil); err != ErrUnsupported {
|
||||
t.Fatalf("want ErrUnsupported, got %v", err)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -21,6 +20,8 @@ import (
|
||||
// browse, mount and upload to those disks without carrying every provider's
|
||||
// private chunk-upload protocol in this project.
|
||||
type cloudDrive2Provider struct {
|
||||
typ string
|
||||
name string
|
||||
base *url.URL
|
||||
username string
|
||||
password string
|
||||
@@ -31,10 +32,15 @@ type cloudDrive2Provider struct {
|
||||
}
|
||||
|
||||
func newCloudDrive2(cfg map[string]any, client *http.Client) *cloudDrive2Provider {
|
||||
rawURL := str(cfg["url"])
|
||||
if rawURL == "" {
|
||||
rawURL = str(cfg["server"])
|
||||
}
|
||||
return newCloudDAVProvider(TypeCloudDrive2, "clouddrive2", cfg, client, "/dav")
|
||||
}
|
||||
|
||||
func newOpenList(cfg map[string]any, client *http.Client) *cloudDrive2Provider {
|
||||
return newCloudDAVProvider(TypeOpenList, "openlist", cfg, client, "/dav")
|
||||
}
|
||||
|
||||
func newCloudDAVProvider(typ, name string, cfg map[string]any, client *http.Client, defaultDAVPath string) *cloudDrive2Provider {
|
||||
rawURL := webDAVURLFromConfig(cfg, defaultDAVPath)
|
||||
u, _ := url.Parse(strings.TrimRight(rawURL, "/"))
|
||||
ua := str(cfg["ua"])
|
||||
if ua == "" {
|
||||
@@ -45,6 +51,8 @@ func newCloudDrive2(cfg map[string]any, client *http.Client) *cloudDrive2Provide
|
||||
proxy = false
|
||||
}
|
||||
return &cloudDrive2Provider{
|
||||
typ: typ,
|
||||
name: name,
|
||||
base: u,
|
||||
username: str(cfg["username"]),
|
||||
password: str(cfg["password"]),
|
||||
@@ -55,7 +63,7 @@ func newCloudDrive2(cfg map[string]any, client *http.Client) *cloudDrive2Provide
|
||||
}
|
||||
}
|
||||
|
||||
func (p *cloudDrive2Provider) Type() string { return TypeCloudDrive2 }
|
||||
func (p *cloudDrive2Provider) Type() string { return p.typ }
|
||||
|
||||
func (p *cloudDrive2Provider) Ping(ctx context.Context) error {
|
||||
_, err := p.List(ctx, "")
|
||||
@@ -77,16 +85,16 @@ func (p *cloudDrive2Provider) List(ctx context.Context, dir string) ([]FileEntry
|
||||
req.Header.Set("Accept", "application/xml,text/xml,*/*")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, decorateDAVTransportError(p.name, p.urlFor(target), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("clouddrive2: list %s returned http %d", target, resp.StatusCode)
|
||||
return nil, p.decorateDAVStatusError(resp, target)
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
var multi cloudDAVMultiStatus
|
||||
if err := xml.Unmarshal(body, &multi); err != nil {
|
||||
return nil, fmt.Errorf("clouddrive2: decode webdav: %w", err)
|
||||
return nil, fmt.Errorf("%s: decode webdav: %w", p.name, err)
|
||||
}
|
||||
basePath := strings.TrimRight(p.base.EscapedPath(), "/")
|
||||
currentID := normalizeCloudDAVPath(target)
|
||||
@@ -119,7 +127,7 @@ func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*Dir
|
||||
}
|
||||
ref := normalizeCloudDAVPath(fileRef)
|
||||
if ref == "/" {
|
||||
return nil, errors.New("clouddrive2: file reference required")
|
||||
return nil, fmt.Errorf("%s: file reference required", p.name)
|
||||
}
|
||||
headers := map[string]string{
|
||||
"User-Agent": p.ua,
|
||||
@@ -134,7 +142,7 @@ func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*Dir
|
||||
|
||||
func (p *cloudDrive2Provider) validate() error {
|
||||
if p.base == nil || p.base.Scheme == "" || p.base.Host == "" {
|
||||
return errors.New("clouddrive2: missing WebDAV URL")
|
||||
return fmt.Errorf("%s: missing WebDAV URL", p.name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -150,6 +158,97 @@ func (p *cloudDrive2Provider) auth(req *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func webDAVURLFromConfig(cfg map[string]any, defaultDAVPath string) string {
|
||||
rawURL := str(cfg["url"])
|
||||
if rawURL == "" {
|
||||
rawURL = str(cfg["webdav_url"])
|
||||
}
|
||||
if rawURL != "" {
|
||||
return ensureDefaultDAVPath(rawURL, defaultDAVPath)
|
||||
}
|
||||
return defaultWebDAVURL(str(cfg["server"]), defaultDAVPath)
|
||||
}
|
||||
|
||||
func defaultWebDAVURL(server, defaultDAVPath string) string {
|
||||
server = strings.TrimRight(strings.TrimSpace(server), "/")
|
||||
if server == "" {
|
||||
return ""
|
||||
}
|
||||
davPath := strings.TrimSpace(defaultDAVPath)
|
||||
if davPath == "" {
|
||||
return server
|
||||
}
|
||||
if !strings.HasPrefix(davPath, "/") {
|
||||
davPath = "/" + davPath
|
||||
}
|
||||
return server + davPath
|
||||
}
|
||||
|
||||
func ensureDefaultDAVPath(rawURL, defaultDAVPath string) string {
|
||||
rawURL = strings.TrimRight(strings.TrimSpace(rawURL), "/")
|
||||
if rawURL == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return rawURL
|
||||
}
|
||||
if strings.TrimSpace(defaultDAVPath) == "" {
|
||||
return rawURL
|
||||
}
|
||||
if u.Path == "" || u.Path == "/" {
|
||||
davPath := strings.TrimSpace(defaultDAVPath)
|
||||
if !strings.HasPrefix(davPath, "/") {
|
||||
davPath = "/" + davPath
|
||||
}
|
||||
u.Path = davPath
|
||||
u.RawPath = ""
|
||||
return strings.TrimRight(u.String(), "/")
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
|
||||
func (p *cloudDrive2Provider) decorateDAVStatusError(resp *http.Response, target string) error {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
detail := compactDAVErrorBody(string(body))
|
||||
if detail == "" {
|
||||
return fmt.Errorf("%s: list %s returned http %d", p.name, target, resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode == http.StatusMethodNotAllowed {
|
||||
return fmt.Errorf("%s: list %s returned http %d:%s;请确认填写的是 WebDAV 地址(通常以 /dav 结尾),并且桥接网盘已在 OpenList/CloudDrive2 内完成登录或 Cookie 保存", p.name, target, resp.StatusCode, detail)
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return fmt.Errorf("%s: list %s returned http %d:%s;请检查 WebDAV 用户名/密码、Authorization Token,或先在 OpenList/CloudDrive2 中保存对应网盘 Cookie", p.name, target, resp.StatusCode, detail)
|
||||
}
|
||||
return fmt.Errorf("%s: list %s returned http %d:%s", p.name, target, resp.StatusCode, detail)
|
||||
}
|
||||
|
||||
func compactDAVErrorBody(raw string) string {
|
||||
raw = strings.TrimSpace(strings.ReplaceAll(raw, "\x00", ""))
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
raw = strings.Join(strings.Fields(raw), " ")
|
||||
if len([]rune(raw)) > 180 {
|
||||
return string([]rune(raw)[:180]) + "…"
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func decorateDAVTransportError(name, target string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
message := err.Error()
|
||||
if strings.Contains(message, "server gave HTTP response to HTTPS client") {
|
||||
return fmt.Errorf("%s: %w;当前地址使用 https://,但服务端返回 HTTP。请改用 http:// 地址,例如 OpenList 默认 WebDAV 通常是 http://host:5244/dav/;如果必须使用 https,请在 OpenList 前配置反向代理和证书", name, err)
|
||||
}
|
||||
if strings.Contains(message, "first record does not look like a TLS handshake") {
|
||||
return fmt.Errorf("%s: %w;疑似把 HTTP 服务配置成了 https://,请检查 %s 的协议头", name, err, target)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *cloudDrive2Provider) urlFor(remotePath string) string {
|
||||
u := *p.base
|
||||
basePath := strings.TrimRight(u.EscapedPath(), "/")
|
||||
|
||||
Reference in New Issue
Block a user