refactor: split modules and harden scraping workflows

This commit is contained in:
ShukeBta
2026-06-24 11:59:18 +08:00
parent efca3cbe69
commit 192f35d9fa
470 changed files with 47957 additions and 33839 deletions
+19 -7
View File
@@ -27,7 +27,6 @@ var timeNow = time.Now
// Provider types recognised by the registry.
const (
TypeQuark = "quark" // 夸克网盘
Type115 = "cloud115" // 115 网盘
TypeCloudDrive2 = "clouddrive2" // CloudDrive2 桥接网盘
TypeOpenList = "openlist" // OpenList / AList-compatible bridge
@@ -42,7 +41,7 @@ type FileEntry struct {
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
// PickCode is 115-specific; quark uses ID directly.
// PickCode is 115-specific; other providers use ID directly.
PickCode string `json:"pick_code,omitempty"`
}
@@ -59,7 +58,7 @@ type DirectLink struct {
// Provider is the common cloud-disk interface.
type Provider interface {
// Type returns the provider key (TypeQuark / Type115).
// Type returns the provider key.
Type() string
// Ping validates the stored credentials (cookie). Cheap, used by the
// storage-config Test() probe.
@@ -71,6 +70,21 @@ type Provider interface {
Resolve(ctx context.Context, fileRef string) (*DirectLink, error)
}
// MutableProvider is implemented by cloud bridges that support safe folder
// management through their official API or standard WebDAV methods.
type MutableProvider interface {
Provider
Mkdir(ctx context.Context, parentDir, name string) (*FileEntry, error)
Rename(ctx context.Context, ref, name string) (*FileEntry, error)
}
// MovableProvider is implemented by writable cloud bridges that can move an
// entry across directories, optionally renaming it in the same operation.
type MovableProvider interface {
MutableProvider
Move(ctx context.Context, ref, targetDir, name string) (*FileEntry, error)
}
// New constructs a provider of the given type from a free-form config map
// (as persisted by StorageConfigService). The client is shared so callers can
// inject timeouts / test transports.
@@ -79,8 +93,6 @@ func New(typ string, cfg map[string]any, client *http.Client) (Provider, error)
client = http.DefaultClient
}
switch typ {
case TypeQuark:
return newQuark(cfg, client), nil
case Type115:
return new115(cfg, client), nil
case TypeCloudDrive2:
@@ -94,7 +106,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 || typ == TypeOpenList
return typ == Type115 || typ == TypeCloudDrive2 || typ == TypeOpenList
}
// str coerces a config value to a trimmed string.
@@ -121,5 +133,5 @@ func boolish(v any) bool {
}
}
// defaultUA is a desktop browser UA accepted by both 115 and quark.
// defaultUA is a desktop browser UA accepted by upstream cloud providers.
const defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
+212
View File
@@ -0,0 +1,212 @@
package cloud
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
func Test115ListAndResolve(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/files":
if r.URL.Query().Get("cid") != "0" {
t.Errorf("bad cid %q", r.URL.Query().Get("cid"))
}
w.Write([]byte(`{"state":true,"data":[
{"cid":"100","n":"Movies","s":0},
{"fid":"200","n":"Inception.mkv","s":456,"pc":"pick200"}]}`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(Type115, map[string]any{"cookie": "UID=1; CID=2", "base": srv.URL}, srv.Client())
if err != nil {
t.Fatal(err)
}
// The downurl endpoint is m115-encrypted end-to-end (the server side
// requires 115's private key), so stub the decrypted payload via the seam
// and assert the pickcode->URL extraction. The live crypto/transport path is
// exercised by integration testing against the real 115 API.
p115, ok := p.(*pan115Provider)
if !ok {
t.Fatalf("expected *pan115Provider, got %T", p)
}
p115.downURLPayload = func(ctx context.Context, pickcode string) ([]byte, error) {
if pickcode != "pick200" {
t.Errorf("bad pickcode %q", pickcode)
}
return []byte(`{"200":{"file_name":"Inception.mkv","file_size":"456","url":{"url":"https://cdn.115/x.mkv?t=1"}}}`), nil
}
entries, err := p.List(context.Background(), "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(entries) != 2 {
t.Fatalf("want 2 entries: %#v", entries)
}
if !entries[0].IsDir || entries[0].ID != "100" {
t.Fatalf("dir entry wrong: %#v", entries[0])
}
if entries[1].IsDir || entries[1].PickCode != "pick200" || entries[1].Size != 456 {
t.Fatalf("file entry wrong: %#v", entries[1])
}
link, err := p.Resolve(context.Background(), "pick200")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if link.URL != "https://cdn.115/x.mkv?t=1" {
t.Fatalf("bad url: %s", link.URL)
}
if link.Proxy {
t.Fatalf("115 should default to 302 (no proxy)")
}
}
func Test115ListPaginates(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/files" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
count := 100
if offset > 0 {
count = 1
}
items := make([]string, 0, count)
for i := 0; i < count; i++ {
n := offset + i
items = append(items, fmt.Sprintf(`{"fid":"%d","n":"Movie.%03d.mkv","s":%d,"pc":"pick%d"}`, n, n, n, n))
}
w.Write([]byte(`{"state":true,"data":[` + strings.Join(items, ",") + `]}`))
}))
defer srv.Close()
p, err := New(Type115, map[string]any{"cookie": "UID=1; CID=2", "base": srv.URL}, srv.Client())
if err != nil {
t.Fatal(err)
}
entries, err := p.List(context.Background(), "0")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(entries) != 101 {
t.Fatalf("entries = %d, want 101", len(entries))
}
if entries[100].ID != "100" || entries[100].PickCode != "pick100" {
t.Fatalf("last entry wrong: %#v", entries[100])
}
}
// Test115DownURLEndpointAndError exercises the live fetchDownURLPayload path:
// it must POST an m115-encrypted `data` body to /app/chrome/downurl?t=... and
// surface 115's error when state=false (no decryption needed for that branch).
func Test115DownURLEndpointAndError(t *testing.T) {
var gotData, gotT string
pro := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/app/chrome/downurl" {
t.Errorf("unexpected path %s", r.URL.Path)
}
gotT = r.URL.Query().Get("t")
_ = r.ParseForm()
gotData = r.PostFormValue("data")
w.Write([]byte(`{"state":false,"error":"not exist"}`))
}))
defer pro.Close()
p, err := New(Type115, map[string]any{"cookie": "UID=1", "pro_base": pro.URL}, pro.Client())
if err != nil {
t.Fatal(err)
}
_, err = p.Resolve(context.Background(), "pickX")
if err == nil || !strings.Contains(err.Error(), "not exist") {
t.Fatalf("want upstream error surfaced, got %v", err)
}
if gotT == "" {
t.Errorf("missing t query param")
}
if gotData == "" {
t.Errorf("missing encrypted data body")
}
if _, derr := base64.StdEncoding.DecodeString(gotData); derr != nil {
t.Errorf("data body is not base64: %v", derr)
}
}
func Test115QRFlow(t *testing.T) {
// status sequence: waiting -> scanned -> confirmed
calls := 0
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/1.0/web/1.0/token/":
w.Write([]byte(`{"state":1,"data":{"uid":"U1","time":1700,"sign":"S1"}}`))
case "/get/status/":
if r.URL.Query().Get("uid") != "U1" {
t.Errorf("bad uid %q", r.URL.Query().Get("uid"))
}
calls++
switch calls {
case 1:
w.Write([]byte(`{"state":1,"data":{"status":0}}`))
case 2:
w.Write([]byte(`{"state":1,"data":{"status":1}}`))
default:
w.Write([]byte(`{"state":1,"data":{"status":2}}`))
}
default:
t.Errorf("unexpected api path %s", r.URL.Path)
}
}))
defer api.Close()
passport := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/app/1.0/web/1.0/login/qrcode/" {
t.Errorf("unexpected passport path %s", r.URL.Path)
}
w.Write([]byte(`{"state":1,"data":{"cookie":{"UID":"u","CID":"c","SEID":"s"}}}`))
}))
defer passport.Close()
oldA, oldP := qr115APIBase, qr115PassportBase
qr115APIBase, qr115PassportBase = api.URL, passport.URL
defer func() { qr115APIBase, qr115PassportBase = oldA, oldP }()
ctx := context.Background()
sess, err := QRStart(ctx, api.Client())
if err != nil {
t.Fatalf("qr start: %v", err)
}
if sess.UID != "U1" || sess.QRImageURL == "" {
t.Fatalf("bad session: %#v", sess)
}
want := []string{"waiting", "scanned", "confirmed"}
for i, exp := range want {
st, err := QRPoll(ctx, api.Client(), sess)
if err != nil {
t.Fatalf("poll %d: %v", i, err)
}
if st.State != exp {
t.Fatalf("poll %d: want %s got %s", i, exp, st.State)
}
if exp == "confirmed" {
if st.Cookie == "" || !containsAll(st.Cookie, "UID=u", "SEID=s") {
t.Fatalf("confirmed must yield cookie: %q", st.Cookie)
}
}
}
}
func containsAll(s string, subs ...string) bool {
for _, sub := range subs {
if !strings.Contains(s, sub) {
return false
}
}
return true
}
+143 -306
View File
@@ -2,115 +2,15 @@ package cloud
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
func TestQuarkListAndResolve(t *testing.T) {
var gotCookie string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotCookie = r.Header.Get("Cookie")
switch {
case r.URL.Path == "/file/sort":
if r.URL.Query().Get("pdir_fid") != "0" {
t.Errorf("unexpected pdir_fid %q", r.URL.Query().Get("pdir_fid"))
}
w.Write([]byte(`{"status":200,"code":0,"data":{"list":[
{"fid":"d1","file_name":"Movies","dir":true,"size":0},
{"fid":"f1","file_name":"Inception.mkv","dir":false,"size":123}]}}`))
case r.URL.Path == "/file/download":
if r.Method != http.MethodPost {
t.Errorf("download must be POST, got %s", r.Method)
}
w.Write([]byte(`{"status":200,"code":0,"data":[{"fid":"f1","download_url":"https://cdn.quark/x.mkv?sign=1"}]}`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeQuark, map[string]any{"cookie": "kps=abc", "base": srv.URL}, srv.Client())
if err != nil {
t.Fatal(err)
}
entries, err := p.List(context.Background(), "0")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(entries) != 2 || !entries[0].IsDir || entries[1].Name != "Inception.mkv" || entries[1].Size != 123 {
t.Fatalf("unexpected entries: %#v", entries)
}
if gotCookie != "kps=abc" {
t.Fatalf("cookie not forwarded: %q", gotCookie)
}
link, err := p.Resolve(context.Background(), "f1")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if link.URL != "https://cdn.quark/x.mkv?sign=1" {
t.Fatalf("bad url: %s", link.URL)
}
if !link.Proxy {
t.Fatalf("quark should default to proxy mode")
}
if link.Headers["Cookie"] != "kps=abc" {
t.Fatalf("resolve must carry cookie header: %#v", link.Headers)
}
}
func TestQuarkListPaginates(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/file/sort" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
page, _ := strconv.Atoi(r.URL.Query().Get("_page"))
w.Write([]byte(`{"status":200,"code":0,"data":{"list":[` + quarkPagePayload(page) + `]}}`))
}))
defer srv.Close()
p, err := New(TypeQuark, map[string]any{"cookie": "kps=abc", "base": srv.URL}, srv.Client())
if err != nil {
t.Fatal(err)
}
entries, err := p.List(context.Background(), "0")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(entries) != 101 {
t.Fatalf("entries = %d, want 101", len(entries))
}
if entries[100].ID != "f100" || entries[100].Name != "Movie.100.mkv" {
t.Fatalf("last entry wrong: %#v", entries[100])
}
}
func quarkPagePayload(page int) string {
count := 100
offset := 0
if page > 1 {
count = 1
offset = 100
}
items := make([]string, 0, count)
for i := 0; i < count; i++ {
n := offset + i
items = append(items, fmt.Sprintf(`{"fid":"f%d","file_name":"Movie.%03d.mkv","dir":false,"size":%d}`, n, n, n))
}
return strings.Join(items, ",")
}
func TestDeprecatedProviderPlaybackOverrideKeysAreIgnored(t *testing.T) {
quark := newQuark(map[string]any{"cookie": "c", "force_302": "true"}, http.DefaultClient)
if !quark.proxy {
t.Fatalf("quark should keep safe proxy mode; force_302 is deprecated")
}
pan115 := new115(map[string]any{"cookie": "UID=1; CID=2", "force_proxy": "true"}, http.DefaultClient)
if pan115.proxy {
t.Fatalf("115 should keep safe direct mode; force_proxy is deprecated")
@@ -121,197 +21,6 @@ func TestDeprecatedProviderPlaybackOverrideKeysAreIgnored(t *testing.T) {
}
}
func Test115ListAndResolve(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/files":
if r.URL.Query().Get("cid") != "0" {
t.Errorf("bad cid %q", r.URL.Query().Get("cid"))
}
w.Write([]byte(`{"state":true,"data":[
{"cid":"100","n":"Movies","s":0},
{"fid":"200","n":"Inception.mkv","s":456,"pc":"pick200"}]}`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(Type115, map[string]any{"cookie": "UID=1; CID=2", "base": srv.URL}, srv.Client())
if err != nil {
t.Fatal(err)
}
// The downurl endpoint is m115-encrypted end-to-end (the server side
// requires 115's private key), so stub the decrypted payload via the seam
// and assert the pickcode→URL extraction. The live crypto/transport path is
// exercised by integration testing against the real 115 API.
p115, ok := p.(*pan115Provider)
if !ok {
t.Fatalf("expected *pan115Provider, got %T", p)
}
p115.downURLPayload = func(ctx context.Context, pickcode string) ([]byte, error) {
if pickcode != "pick200" {
t.Errorf("bad pickcode %q", pickcode)
}
return []byte(`{"200":{"file_name":"Inception.mkv","file_size":"456","url":{"url":"https://cdn.115/x.mkv?t=1"}}}`), nil
}
entries, err := p.List(context.Background(), "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(entries) != 2 {
t.Fatalf("want 2 entries: %#v", entries)
}
if !entries[0].IsDir || entries[0].ID != "100" {
t.Fatalf("dir entry wrong: %#v", entries[0])
}
if entries[1].IsDir || entries[1].PickCode != "pick200" || entries[1].Size != 456 {
t.Fatalf("file entry wrong: %#v", entries[1])
}
link, err := p.Resolve(context.Background(), "pick200")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if link.URL != "https://cdn.115/x.mkv?t=1" {
t.Fatalf("bad url: %s", link.URL)
}
if link.Proxy {
t.Fatalf("115 should default to 302 (no proxy)")
}
}
func Test115ListPaginates(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/files" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
count := 100
if offset > 0 {
count = 1
}
items := make([]string, 0, count)
for i := 0; i < count; i++ {
n := offset + i
items = append(items, fmt.Sprintf(`{"fid":"%d","n":"Movie.%03d.mkv","s":%d,"pc":"pick%d"}`, n, n, n, n))
}
w.Write([]byte(`{"state":true,"data":[` + strings.Join(items, ",") + `]}`))
}))
defer srv.Close()
p, err := New(Type115, map[string]any{"cookie": "UID=1; CID=2", "base": srv.URL}, srv.Client())
if err != nil {
t.Fatal(err)
}
entries, err := p.List(context.Background(), "0")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(entries) != 101 {
t.Fatalf("entries = %d, want 101", len(entries))
}
if entries[100].ID != "100" || entries[100].PickCode != "pick100" {
t.Fatalf("last entry wrong: %#v", entries[100])
}
}
// Test115DownURLEndpointAndError exercises the live fetchDownURLPayload path:
// it must POST an m115-encrypted `data` body to /app/chrome/downurl?t=... and
// surface 115's error when state=false (no decryption needed for that branch).
func Test115DownURLEndpointAndError(t *testing.T) {
var gotData, gotT string
pro := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/app/chrome/downurl" {
t.Errorf("unexpected path %s", r.URL.Path)
}
gotT = r.URL.Query().Get("t")
_ = r.ParseForm()
gotData = r.PostFormValue("data")
w.Write([]byte(`{"state":false,"error":"not exist"}`))
}))
defer pro.Close()
p, err := New(Type115, map[string]any{"cookie": "UID=1", "pro_base": pro.URL}, pro.Client())
if err != nil {
t.Fatal(err)
}
_, err = p.Resolve(context.Background(), "pickX")
if err == nil || !strings.Contains(err.Error(), "not exist") {
t.Fatalf("want upstream error surfaced, got %v", err)
}
if gotT == "" {
t.Errorf("missing t query param")
}
if gotData == "" {
t.Errorf("missing encrypted data body")
}
if _, derr := base64.StdEncoding.DecodeString(gotData); derr != nil {
t.Errorf("data body is not base64: %v", derr)
}
}
func Test115QRFlow(t *testing.T) {
// status sequence: waiting → scanned → confirmed
calls := 0
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/1.0/web/1.0/token/":
w.Write([]byte(`{"state":1,"data":{"uid":"U1","time":1700,"sign":"S1"}}`))
case "/get/status/":
if r.URL.Query().Get("uid") != "U1" {
t.Errorf("bad uid %q", r.URL.Query().Get("uid"))
}
calls++
switch calls {
case 1:
w.Write([]byte(`{"state":1,"data":{"status":0}}`))
case 2:
w.Write([]byte(`{"state":1,"data":{"status":1}}`))
default:
w.Write([]byte(`{"state":1,"data":{"status":2}}`))
}
default:
t.Errorf("unexpected api path %s", r.URL.Path)
}
}))
defer api.Close()
passport := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/app/1.0/web/1.0/login/qrcode/" {
t.Errorf("unexpected passport path %s", r.URL.Path)
}
w.Write([]byte(`{"state":1,"data":{"cookie":{"UID":"u","CID":"c","SEID":"s"}}}`))
}))
defer passport.Close()
oldA, oldP := qr115APIBase, qr115PassportBase
qr115APIBase, qr115PassportBase = api.URL, passport.URL
defer func() { qr115APIBase, qr115PassportBase = oldA, oldP }()
ctx := context.Background()
sess, err := QRStart(ctx, api.Client())
if err != nil {
t.Fatalf("qr start: %v", err)
}
if sess.UID != "U1" || sess.QRImageURL == "" {
t.Fatalf("bad session: %#v", sess)
}
want := []string{"waiting", "scanned", "confirmed"}
for i, exp := range want {
st, err := QRPoll(ctx, api.Client(), sess)
if err != nil {
t.Fatalf("poll %d: %v", i, err)
}
if st.State != exp {
t.Fatalf("poll %d: want %s got %s", i, exp, st.State)
}
if exp == "confirmed" {
if st.Cookie == "" || !containsAll(st.Cookie, "UID=u", "SEID=s") {
t.Fatalf("confirmed must yield cookie: %q", st.Cookie)
}
}
}
}
func TestCloudDrive2WebDAVListAndResolve(t *testing.T) {
var gotAuth, gotDepth, gotRange string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -523,6 +232,144 @@ func TestOpenListListUsesAPIUsernamePasswordWithoutWebDAVFallback(t *testing.T)
}
}
func TestOpenListMutableProviderUsesAPI(t *testing.T) {
var mkdirPath, renamePath, renameName, moveSrcDir, moveDstDir string
var moveNames []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/fs/mkdir":
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode mkdir body: %v", err)
}
mkdirPath = body["path"]
if r.Header.Get("Authorization") != "alist-token" {
t.Fatalf("mkdir Authorization = %q", r.Header.Get("Authorization"))
}
_, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
case "/api/fs/rename":
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode rename body: %v", err)
}
renamePath = body["path"]
renameName = body["name"]
_, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
case "/api/fs/move":
var body struct {
SrcDir string `json:"src_dir"`
DstDir string `json:"dst_dir"`
Names []string `json:"names"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode move body: %v", err)
}
moveSrcDir = body.SrcDir
moveDstDir = body.DstDir
moveNames = body.Names
_, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client())
if err != nil {
t.Fatal(err)
}
mutable, ok := p.(MutableProvider)
if !ok {
t.Fatal("openlist should support mutable provider")
}
created, err := mutable.Mkdir(context.Background(), "/电视剧", "欧美剧")
if err != nil {
t.Fatalf("mkdir: %v", err)
}
if mkdirPath != "/电视剧/欧美剧" || created.ID != "/电视剧/欧美剧" || !created.IsDir {
t.Fatalf("mkdir path=%q entry=%#v", mkdirPath, created)
}
renamed, err := mutable.Rename(context.Background(), "/电视剧/欧美剧", "美剧")
if err != nil {
t.Fatalf("rename: %v", err)
}
if renamePath != "/电视剧/欧美剧" || renameName != "美剧" || renamed.ID != "/电视剧/美剧" {
t.Fatalf("rename path=%q name=%q entry=%#v", renamePath, renameName, renamed)
}
moved, err := mutable.(MovableProvider).Move(context.Background(), "/待整理/Show.S01E01.mkv", "/动漫/国漫/Show/Season 01", "Show - S01E01.mkv")
if err != nil {
t.Fatalf("move: %v", err)
}
if moveSrcDir != "/待整理" || moveDstDir != "/动漫/国漫/Show/Season 01" || len(moveNames) != 1 || moveNames[0] != "Show.S01E01.mkv" {
t.Fatalf("move src=%q dst=%q names=%#v", moveSrcDir, moveDstDir, moveNames)
}
if renamePath != "/动漫/国漫/Show/Season 01/Show.S01E01.mkv" || renameName != "Show - S01E01.mkv" {
t.Fatalf("post-move rename path=%q name=%q", renamePath, renameName)
}
if moved.ID != "/动漫/国漫/Show/Season 01/Show - S01E01.mkv" {
t.Fatalf("moved entry = %#v", moved)
}
}
func TestCloudDrive2MutableProviderUsesWebDAV(t *testing.T) {
var mkcolSeen bool
var destinations []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "MKCOL" && r.URL.Path == "/dav/TV":
mkcolSeen = true
w.WriteHeader(http.StatusCreated)
case r.Method == "MOVE" && r.URL.Path == "/dav/TV":
destinations = append(destinations, r.Header.Get("Destination"))
if r.Header.Get("Overwrite") != "F" {
t.Fatalf("Overwrite = %q, want F", r.Header.Get("Overwrite"))
}
w.WriteHeader(http.StatusCreated)
case r.Method == "MOVE" && r.URL.Path == "/dav/Inbox/Movie.mkv":
destinations = append(destinations, r.Header.Get("Destination"))
if r.Header.Get("Overwrite") != "F" {
t.Fatalf("Overwrite = %q, want F", r.Header.Get("Overwrite"))
}
w.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
p, err := New(TypeCloudDrive2, map[string]any{"url": srv.URL + "/dav", "username": "u", "password": "p"}, srv.Client())
if err != nil {
t.Fatal(err)
}
mutable, ok := p.(MutableProvider)
if !ok {
t.Fatal("clouddrive2 should support mutable provider")
}
if _, err := mutable.Mkdir(context.Background(), "", "TV"); err != nil {
t.Fatalf("mkdir: %v", err)
}
if _, err := mutable.Rename(context.Background(), "/TV", "电视剧"); err != nil {
t.Fatalf("rename: %v", err)
}
moved, err := mutable.(MovableProvider).Move(context.Background(), "/Inbox/Movie.mkv", "/电影/外语电影/Movie (2026)", "Movie (2026).mkv")
if err != nil {
t.Fatalf("move: %v", err)
}
if !mkcolSeen || len(destinations) != 2 {
t.Fatalf("mkcol=%v destinations=%#v, want mkdir and two MOVE calls", mkcolSeen, destinations)
}
if destinations[0] != srv.URL+"/dav/%E7%94%B5%E8%A7%86%E5%89%A7" {
t.Fatalf("rename Destination = %q", destinations[0])
}
if destinations[1] != srv.URL+"/dav/%E7%94%B5%E5%BD%B1/%E5%A4%96%E8%AF%AD%E7%94%B5%E5%BD%B1/Movie%20%282026%29/Movie%20%282026%29.mkv" {
t.Fatalf("move Destination = %q", destinations[1])
}
if moved.ID != "/电影/外语电影/Movie (2026)/Movie (2026).mkv" {
t.Fatalf("moved entry = %#v", moved)
}
}
func TestOpenListListAPIFailureDoesNotFallbackToWebDAV(t *testing.T) {
var davSeen bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -804,22 +651,12 @@ func TestUnsupportedProvider(t *testing.T) {
if _, err := New("dropbox", nil, nil); err != ErrUnsupported {
t.Fatalf("want ErrUnsupported, got %v", err)
}
}
func containsAll(s string, subs ...string) bool {
for _, sub := range subs {
found := false
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
found = true
break
}
}
if !found {
return false
}
if _, err := New("quark", nil, nil); err != ErrUnsupported {
t.Fatalf("quark should be unsupported, got %v", err)
}
if IsCloudType("quark") {
t.Fatal("quark should not be an active cloud provider")
}
return true
}
var _ = time.Second
+2 -624
View File
@@ -1,25 +1,19 @@
package cloud
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"path"
"sort"
"strconv"
"strings"
)
// cloudDrive2Provider bridges CloudDrive2 through its WebDAV endpoint.
//
// CloudDrive2 already integrates many cloud disks (115 / 123 / Aliyun / Quark
// and more). Treating it as a WebDAV-backed cloud provider lets MediaStationGo
// CloudDrive2 integrates many cloud disks (115 / 123 / Aliyun and more).
// Treating it as a WebDAV-backed cloud provider lets MediaStationGo
// browse, mount and upload to those disks without carrying every provider's
// private chunk-upload protocol in this project.
type cloudDrive2Provider struct {
@@ -76,133 +70,6 @@ func (p *cloudDrive2Provider) Ping(ctx context.Context) error {
return err
}
func (p *cloudDrive2Provider) List(ctx context.Context, dir string) ([]FileEntry, error) {
if err := p.validate(); err != nil {
return nil, err
}
if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() {
return p.listOpenListAPI(ctx, dir)
}
target := normalizeCloudDAVPath(dir)
req, err := http.NewRequestWithContext(ctx, "PROPFIND", p.urlFor(target), strings.NewReader(cloudDAVPropfindBody))
if err != nil {
return nil, err
}
p.auth(req)
req.Header.Set("Depth", "1")
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
req.Header.Set("Accept", "application/xml,text/xml,*/*")
resp, err := p.client.Do(req)
if err != nil {
return nil, decorateDAVTransportError(p.name, p.urlFor(target), err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
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("%s: decode webdav: %w", p.name, err)
}
basePath := strings.TrimRight(p.base.EscapedPath(), "/")
currentID := normalizeCloudDAVPath(target)
out := make([]FileEntry, 0, len(multi.Responses))
for _, item := range multi.Responses {
entryPath, err := p.entryIDFromHref(item.Href, basePath)
if err != nil || entryPath == "" || sameCloudDAVPath(entryPath, currentID) {
continue
}
name := firstNonEmpty(item.PropStat.Prop.DisplayName, path.Base(strings.TrimRight(entryPath, "/")))
if decoded, err := url.PathUnescape(name); err == nil {
name = decoded
}
if name == "" || name == "." || name == "/" {
continue
}
out = append(out, FileEntry{
ID: entryPath,
Name: name,
IsDir: item.PropStat.Prop.ResourceType.Collection != nil || strings.HasSuffix(item.Href, "/"),
Size: parseDAVSize(item.PropStat.Prop.ContentLength),
})
}
return out, nil
}
func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) ([]FileEntry, error) {
token, err := p.openListAPIToken(ctx)
if err != nil {
return nil, err
}
const pageSize = 500
target := normalizeCloudDAVPath(dir)
out := make([]FileEntry, 0, pageSize)
for pageNum := 1; ; pageNum++ {
payload := map[string]any{
"path": target,
"password": "",
"page": pageNum,
"per_page": pageSize,
"refresh": false,
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/fs/list"), bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", p.ua)
if token != "" {
req.Header.Set("Authorization", token)
}
resp, err := p.client.Do(req)
if err != nil {
return nil, decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/list"), err)
}
var decoded openListListResponse
decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&decoded)
resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%s: api list %s returned http %d", p.name, target, resp.StatusCode)
}
if decodeErr != nil {
return nil, fmt.Errorf("%s: decode api list: %w", p.name, decodeErr)
}
if decoded.Code != 0 && decoded.Code != 200 {
msg := strings.TrimSpace(decoded.Message)
if msg == "" {
msg = fmt.Sprintf("code %d", decoded.Code)
}
return nil, fmt.Errorf("%s: api list %s failed: %s", p.name, target, msg)
}
for _, item := range decoded.Data.Content {
name := strings.TrimSpace(item.Name)
if name == "" || name == "." || name == "/" {
continue
}
out = append(out, FileEntry{
ID: joinOpenListAPIPath(target, name),
Name: name,
IsDir: item.IsDir,
Size: item.Size,
})
}
total := decoded.Data.Total
if total > 0 {
if len(out) >= total || len(decoded.Data.Content) == 0 {
break
}
continue
}
if len(decoded.Data.Content) == 0 || len(decoded.Data.Content) < pageSize {
break
}
}
return out, nil
}
func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) {
if err := p.validate(); err != nil {
return nil, err
@@ -239,323 +106,6 @@ func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*Dir
return &DirectLink{URL: p.urlFor(ref), Headers: headers, Proxy: p.proxy}, nil
}
func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, fileRef string) (*DirectLink, error) {
token, err := p.openListAPIToken(ctx)
if err != nil {
return nil, err
}
payload, _ := json.Marshal(map[string]string{"path": normalizeCloudDAVPath(fileRef), "password": ""})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/fs/get"), bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", p.ua)
if token != "" {
req.Header.Set("Authorization", token)
}
resp, err := p.client.Do(req)
if err != nil {
return nil, decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/get"), err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%s: api get %s returned http %d", p.name, fileRef, resp.StatusCode)
}
var decoded openListGetResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil {
return nil, fmt.Errorf("%s: decode api get: %w", p.name, err)
}
if decoded.Code != 0 && decoded.Code != 200 {
msg := strings.TrimSpace(decoded.Message)
if msg == "" {
msg = fmt.Sprintf("code %d", decoded.Code)
}
return nil, fmt.Errorf("%s: api get %s failed: %s", p.name, fileRef, msg)
}
raw := firstNonEmpty(decoded.Data.RawURL, decoded.Data.URL)
if raw == "" {
return nil, fmt.Errorf("%s: api get %s returned empty raw_url", p.name, fileRef)
}
resolved, err := p.resolveOpenListPlaybackURL(raw)
if err != nil {
return nil, err
}
headers := normalizeOpenListPlaybackHeaders(decoded.Data.Header)
if len(headers) > 0 {
return nil, fmt.Errorf("%s: api get %s returned raw_url that requires headers (%s); refusing WebDAV/proxy fallback for pure 302 playback", p.name, fileRef, strings.Join(sortedHeaderNames(headers), ","))
}
resolved, err = p.resolveOpenListCDNRedirect(ctx, fileRef, resolved)
if err != nil {
return nil, err
}
return &DirectLink{URL: resolved, Headers: nil, Proxy: false}, nil
}
func (p *cloudDrive2Provider) resolveOpenListCDNRedirect(ctx context.Context, fileRef, rawURL string) (string, error) {
if p.apiBase == nil || !sameURLHost(rawURL, p.apiBase) {
return rawURL, nil
}
location, status, err := p.firstHTTPRedirectLocation(ctx, rawURL, nil)
if err != nil {
return "", fmt.Errorf("%s: probe raw_url %s failed: %w", p.name, fileRef, err)
}
if location != "" {
return location, nil
}
return "", fmt.Errorf("%s: api get %s returned an OpenList-hosted raw_url with http %d and no CDN Location; refusing OpenList/WebDAV proxy fallback for pure 302 playback", p.name, fileRef, status)
}
func (p *cloudDrive2Provider) resolveCloudDAVRedirectDirect(ctx context.Context, fileRef string) (*DirectLink, error) {
target := p.urlFor(fileRef)
headers := map[string]string{
"User-Agent": p.ua,
}
if p.token != "" {
headers["Authorization"] = p.token
} else if p.username != "" {
headers["Authorization"] = "Basic " + base64.StdEncoding.EncodeToString([]byte(p.username+":"+p.password))
}
location, status, err := p.firstHTTPRedirectLocation(ctx, target, headers)
if err != nil {
return nil, decorateDAVTransportError(p.name, target, err)
}
if location == "" {
return nil, fmt.Errorf("%s: WebDAV %s returned http %d without CDN Location; refusing WebDAV/proxy fallback for pure 302 playback", p.name, fileRef, status)
}
return &DirectLink{URL: location, Headers: nil, Proxy: false}, nil
}
func (p *cloudDrive2Provider) firstHTTPRedirectLocation(ctx context.Context, target string, headers map[string]string) (string, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return "", 0, err
}
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Encoding", "identity")
req.Header.Set("Range", "bytes=0-0")
if strings.TrimSpace(p.ua) != "" {
req.Header.Set("User-Agent", p.ua)
}
for key, value := range headers {
key = strings.TrimSpace(key)
if key != "" && strings.TrimSpace(value) != "" {
req.Header.Set(key, value)
}
}
client := p.client
if client == nil {
client = http.DefaultClient
}
noFollow := *client
noFollow.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
resp, err := noFollow.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
status := resp.StatusCode
if status >= 300 && status < 400 {
rawLocation := strings.TrimSpace(resp.Header.Get("Location"))
if rawLocation == "" {
return "", status, fmt.Errorf("%s: upstream returned redirect http %d without Location", p.name, status)
}
location, err := resolveHTTPRedirectLocation(target, rawLocation)
if err != nil {
return "", status, err
}
return location, status, nil
}
return "", status, nil
}
func sortedHeaderNames(headers map[string]string) []string {
if len(headers) == 0 {
return nil
}
out := make([]string, 0, len(headers))
for key := range headers {
key = strings.TrimSpace(key)
if key != "" {
out = append(out, key)
}
}
sort.Strings(out)
return out
}
func (p *cloudDrive2Provider) hasOpenListAPICredentials() bool {
return strings.TrimSpace(p.token) != "" || (strings.TrimSpace(p.username) != "" && p.password != "")
}
func (p *cloudDrive2Provider) openListAPIToken(ctx context.Context) (string, error) {
if token := strings.TrimSpace(p.token); token != "" {
return token, nil
}
if strings.TrimSpace(p.username) == "" || p.password == "" {
return "", nil
}
payload, _ := json.Marshal(map[string]string{
"username": p.username,
"password": p.password,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/auth/login"), bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", p.ua)
resp, err := p.client.Do(req)
if err != nil {
return "", decorateDAVTransportError(p.name, p.openListAPIURL("/api/auth/login"), err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("%s: api login returned http %d", p.name, resp.StatusCode)
}
var decoded openListLoginResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil {
return "", fmt.Errorf("%s: decode api login: %w", p.name, err)
}
if decoded.Code != 0 && decoded.Code != 200 {
msg := strings.TrimSpace(decoded.Message)
if msg == "" {
msg = fmt.Sprintf("code %d", decoded.Code)
}
return "", fmt.Errorf("%s: api login failed: %s", p.name, msg)
}
token := strings.TrimSpace(decoded.Data.Token)
if token == "" {
return "", fmt.Errorf("%s: api login returned empty token", p.name)
}
p.token = token
return token, nil
}
func (p *cloudDrive2Provider) resolveOpenListPlaybackURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", fmt.Errorf("%s: empty playback URL", p.name)
}
if strings.HasPrefix(raw, "//") {
if p.apiBase == nil || p.apiBase.Scheme == "" {
return "", fmt.Errorf("%s: protocol-relative playback URL without API base", p.name)
}
raw = p.apiBase.Scheme + ":" + raw
}
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("%s: invalid playback URL: %w", p.name, err)
}
if u.IsAbs() {
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("%s: unsupported playback URL scheme %q", p.name, u.Scheme)
}
return u.String(), nil
}
if p.apiBase == nil {
return "", fmt.Errorf("%s: relative playback URL without API base", p.name)
}
base := *p.apiBase
base.RawPath = ""
base.RawQuery = ""
base.Fragment = ""
return base.ResolveReference(u).String(), nil
}
func sameURLHost(raw string, base *url.URL) bool {
if base == nil {
return false
}
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return false
}
if !u.IsAbs() {
return true
}
return strings.EqualFold(u.Host, base.Host)
}
func resolveHTTPRedirectLocation(baseURL, rawLocation string) (string, error) {
rawLocation = strings.TrimSpace(rawLocation)
if rawLocation == "" {
return "", fmt.Errorf("empty redirect Location")
}
if strings.HasPrefix(rawLocation, "//") {
base, err := url.Parse(baseURL)
if err != nil || base.Scheme == "" {
return "", fmt.Errorf("protocol-relative redirect Location without base scheme")
}
rawLocation = base.Scheme + ":" + rawLocation
}
location, err := url.Parse(rawLocation)
if err != nil {
return "", fmt.Errorf("invalid redirect Location: %w", err)
}
if location.IsAbs() {
if location.Scheme != "http" && location.Scheme != "https" {
return "", fmt.Errorf("unsupported redirect Location scheme %q", location.Scheme)
}
return location.String(), nil
}
base, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("invalid redirect base URL: %w", err)
}
return base.ResolveReference(location).String(), nil
}
func normalizeOpenListPlaybackHeaders(raw json.RawMessage) map[string]string {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
return nil
}
out := make(map[string]string, len(obj))
for k, v := range obj {
key := strings.TrimSpace(k)
if key == "" {
continue
}
switch value := v.(type) {
case string:
if strings.TrimSpace(value) != "" {
out[key] = strings.TrimSpace(value)
}
case []any:
parts := make([]string, 0, len(value))
for _, item := range value {
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
parts = append(parts, strings.TrimSpace(s))
}
}
if len(parts) > 0 {
out[key] = strings.Join(parts, ", ")
}
}
}
if len(out) == 0 {
return nil
}
return out
}
func isCloudVideoPlaybackCandidate(fileRef string) bool {
switch strings.ToLower(path.Ext(strings.TrimSpace(fileRef))) {
case ".mkv", ".mp4", ".m4v", ".avi", ".mov", ".webm", ".ts", ".rmvb", ".rm", ".3gp", ".mpg", ".mpeg":
return true
default:
return false
}
}
func (p *cloudDrive2Provider) validate() error {
if p.base == nil || p.base.Scheme == "" || p.base.Host == "" {
return fmt.Errorf("%s: missing WebDAV URL", p.name)
@@ -563,17 +113,6 @@ func (p *cloudDrive2Provider) validate() error {
return nil
}
func (p *cloudDrive2Provider) auth(req *http.Request) {
req.Header.Set("User-Agent", p.ua)
if p.token != "" {
req.Header.Set("Authorization", p.token)
return
}
if p.username != "" {
req.SetBasicAuth(p.username, p.password)
}
}
func webDAVURLFromConfig(cfg map[string]any, defaultDAVPath string) string {
rawURL := str(cfg["url"])
if rawURL == "" {
@@ -669,153 +208,6 @@ func ensureDefaultDAVPath(rawURL, defaultDAVPath string) 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
u.RawPath = ""
basePath := strings.TrimRight(u.Path, "/")
remote := strings.Trim(normalizeCloudDAVPath(remotePath), "/")
switch {
case basePath == "" || basePath == "/":
if remote == "" {
u.Path = "/"
} else {
u.Path = "/" + remote
}
case remote == "":
u.Path = basePath
default:
u.Path = basePath + "/" + remote
}
return u.String()
}
func (p *cloudDrive2Provider) entryIDFromHref(href, basePath string) (string, error) {
if href == "" {
return "", nil
}
parsed, err := url.Parse(href)
if err != nil {
return "", err
}
hrefPath := parsed.EscapedPath()
if hrefPath == "" {
hrefPath = href
}
if basePath != "" && basePath != "/" {
hrefPath = strings.TrimPrefix(hrefPath, basePath)
}
if decoded, err := url.PathUnescape(hrefPath); err == nil {
hrefPath = decoded
}
return normalizeCloudDAVPath(hrefPath), nil
}
const cloudDAVPropfindBody = `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:displayname/>
<d:getcontentlength/>
<d:resourcetype/>
</d:prop>
</d:propfind>`
type cloudDAVMultiStatus struct {
Responses []cloudDAVResponse `xml:"response"`
}
type cloudDAVResponse struct {
Href string `xml:"href"`
PropStat cloudDAVPropStat `xml:"propstat"`
}
type cloudDAVPropStat struct {
Prop cloudDAVProp `xml:"prop"`
}
type cloudDAVProp struct {
DisplayName string `xml:"displayname"`
ContentLength string `xml:"getcontentlength"`
ResourceType cloudDAVResourceType `xml:"resourcetype"`
}
type cloudDAVResourceType struct {
Collection *struct{} `xml:"collection"`
}
type openListListResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Content []openListListItem `json:"content"`
Total int `json:"total"`
} `json:"data"`
}
type openListListItem struct {
Name string `json:"name"`
Size int64 `json:"size"`
IsDir bool `json:"is_dir"`
}
type openListGetResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
RawURL string `json:"raw_url"`
URL string `json:"url"`
Header json.RawMessage `json:"header"`
} `json:"data"`
}
type openListLoginResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Token string `json:"token"`
} `json:"data"`
}
func normalizeCloudDAVPath(p string) string {
p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
if p == "" || p == "." {
@@ -835,20 +227,6 @@ func sameCloudDAVPath(a, b string) bool {
return strings.TrimRight(normalizeCloudDAVPath(a), "/") == strings.TrimRight(normalizeCloudDAVPath(b), "/")
}
func joinOpenListAPIPath(dir, name string) string {
dir = strings.TrimRight(normalizeCloudDAVPath(dir), "/")
name = strings.Trim(strings.ReplaceAll(name, "\\", "/"), "/")
if dir == "" || dir == "/" {
return normalizeCloudDAVPath(name)
}
return normalizeCloudDAVPath(dir + "/" + name)
}
func parseDAVSize(raw string) int64 {
n, _ := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
return n
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
+292
View File
@@ -0,0 +1,292 @@
package cloud
import (
"context"
"encoding/base64"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strconv"
"strings"
)
func (p *cloudDrive2Provider) List(ctx context.Context, dir string) ([]FileEntry, error) {
if err := p.validate(); err != nil {
return nil, err
}
if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() {
return p.listOpenListAPI(ctx, dir)
}
target := normalizeCloudDAVPath(dir)
req, err := http.NewRequestWithContext(ctx, "PROPFIND", p.urlFor(target), strings.NewReader(cloudDAVPropfindBody))
if err != nil {
return nil, err
}
p.auth(req)
req.Header.Set("Depth", "1")
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
req.Header.Set("Accept", "application/xml,text/xml,*/*")
resp, err := p.client.Do(req)
if err != nil {
return nil, decorateDAVTransportError(p.name, p.urlFor(target), err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
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("%s: decode webdav: %w", p.name, err)
}
basePath := strings.TrimRight(p.base.EscapedPath(), "/")
currentID := normalizeCloudDAVPath(target)
out := make([]FileEntry, 0, len(multi.Responses))
for _, item := range multi.Responses {
entryPath, err := p.entryIDFromHref(item.Href, basePath)
if err != nil || entryPath == "" || sameCloudDAVPath(entryPath, currentID) {
continue
}
name := firstNonEmpty(item.PropStat.Prop.DisplayName, path.Base(strings.TrimRight(entryPath, "/")))
if decoded, err := url.PathUnescape(name); err == nil {
name = decoded
}
if name == "" || name == "." || name == "/" {
continue
}
out = append(out, FileEntry{
ID: entryPath,
Name: name,
IsDir: item.PropStat.Prop.ResourceType.Collection != nil || strings.HasSuffix(item.Href, "/"),
Size: parseDAVSize(item.PropStat.Prop.ContentLength),
})
}
return out, nil
}
func (p *cloudDrive2Provider) resolveCloudDAVRedirectDirect(ctx context.Context, fileRef string) (*DirectLink, error) {
target := p.urlFor(fileRef)
headers := map[string]string{
"User-Agent": p.ua,
}
if p.token != "" {
headers["Authorization"] = p.token
} else if p.username != "" {
headers["Authorization"] = "Basic " + base64.StdEncoding.EncodeToString([]byte(p.username+":"+p.password))
}
location, status, err := p.firstHTTPRedirectLocation(ctx, target, headers)
if err != nil {
return nil, decorateDAVTransportError(p.name, target, err)
}
if location == "" {
return nil, fmt.Errorf("%s: WebDAV %s returned http %d without CDN Location; refusing WebDAV/proxy fallback for pure 302 playback", p.name, fileRef, status)
}
return &DirectLink{URL: location, Headers: nil, Proxy: false}, nil
}
func (p *cloudDrive2Provider) firstHTTPRedirectLocation(ctx context.Context, target string, headers map[string]string) (string, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return "", 0, err
}
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Encoding", "identity")
req.Header.Set("Range", "bytes=0-0")
if strings.TrimSpace(p.ua) != "" {
req.Header.Set("User-Agent", p.ua)
}
for key, value := range headers {
key = strings.TrimSpace(key)
if key != "" && strings.TrimSpace(value) != "" {
req.Header.Set(key, value)
}
}
client := p.client
if client == nil {
client = http.DefaultClient
}
noFollow := *client
noFollow.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
resp, err := noFollow.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
status := resp.StatusCode
if status >= 300 && status < 400 {
rawLocation := strings.TrimSpace(resp.Header.Get("Location"))
if rawLocation == "" {
return "", status, fmt.Errorf("%s: upstream returned redirect http %d without Location", p.name, status)
}
location, err := resolveHTTPRedirectLocation(target, rawLocation)
if err != nil {
return "", status, err
}
return location, status, nil
}
return "", status, nil
}
func resolveHTTPRedirectLocation(baseURL, rawLocation string) (string, error) {
rawLocation = strings.TrimSpace(rawLocation)
if rawLocation == "" {
return "", fmt.Errorf("empty redirect Location")
}
if strings.HasPrefix(rawLocation, "//") {
base, err := url.Parse(baseURL)
if err != nil || base.Scheme == "" {
return "", fmt.Errorf("protocol-relative redirect Location without base scheme")
}
rawLocation = base.Scheme + ":" + rawLocation
}
location, err := url.Parse(rawLocation)
if err != nil {
return "", fmt.Errorf("invalid redirect Location: %w", err)
}
if location.IsAbs() {
if location.Scheme != "http" && location.Scheme != "https" {
return "", fmt.Errorf("unsupported redirect Location scheme %q", location.Scheme)
}
return location.String(), nil
}
base, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("invalid redirect base URL: %w", err)
}
return base.ResolveReference(location).String(), nil
}
func (p *cloudDrive2Provider) auth(req *http.Request) {
req.Header.Set("User-Agent", p.ua)
if p.token != "" {
req.Header.Set("Authorization", p.token)
return
}
if p.username != "" {
req.SetBasicAuth(p.username, p.password)
}
}
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
u.RawPath = ""
basePath := strings.TrimRight(u.Path, "/")
remote := strings.Trim(normalizeCloudDAVPath(remotePath), "/")
switch {
case basePath == "" || basePath == "/":
if remote == "" {
u.Path = "/"
} else {
u.Path = "/" + remote
}
case remote == "":
u.Path = basePath
default:
u.Path = basePath + "/" + remote
}
return u.String()
}
func (p *cloudDrive2Provider) entryIDFromHref(href, basePath string) (string, error) {
if href == "" {
return "", nil
}
parsed, err := url.Parse(href)
if err != nil {
return "", err
}
hrefPath := parsed.EscapedPath()
if hrefPath == "" {
hrefPath = href
}
if basePath != "" && basePath != "/" {
hrefPath = strings.TrimPrefix(hrefPath, basePath)
}
if decoded, err := url.PathUnescape(hrefPath); err == nil {
hrefPath = decoded
}
return normalizeCloudDAVPath(hrefPath), nil
}
const cloudDAVPropfindBody = `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:displayname/>
<d:getcontentlength/>
<d:resourcetype/>
</d:prop>
</d:propfind>`
type cloudDAVMultiStatus struct {
Responses []cloudDAVResponse `xml:"response"`
}
type cloudDAVResponse struct {
Href string `xml:"href"`
PropStat cloudDAVPropStat `xml:"propstat"`
}
type cloudDAVPropStat struct {
Prop cloudDAVProp `xml:"prop"`
}
type cloudDAVProp struct {
DisplayName string `xml:"displayname"`
ContentLength string `xml:"getcontentlength"`
ResourceType cloudDAVResourceType `xml:"resourcetype"`
}
type cloudDAVResourceType struct {
Collection *struct{} `xml:"collection"`
}
func parseDAVSize(raw string) int64 {
n, _ := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
return n
}
@@ -0,0 +1,233 @@
package cloud
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
)
func (p *cloudDrive2Provider) Mkdir(ctx context.Context, parentDir, name string) (*FileEntry, error) {
cleanName, err := cleanCloudEntryName(name)
if err != nil {
return nil, err
}
parent := normalizeCloudDAVPath(parentDir)
target := joinOpenListAPIPath(parent, cleanName)
if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() {
if err := p.openListAPIMkdir(ctx, target); err != nil {
return nil, err
}
return &FileEntry{ID: target, Name: cleanName, IsDir: true}, nil
}
if err := p.webDAVMkdir(ctx, target); err != nil {
return nil, err
}
return &FileEntry{ID: target, Name: cleanName, IsDir: true}, nil
}
func (p *cloudDrive2Provider) Rename(ctx context.Context, ref, name string) (*FileEntry, error) {
cleanName, err := cleanCloudEntryName(name)
if err != nil {
return nil, err
}
source := normalizeCloudDAVPath(ref)
if source == "/" {
return nil, fmt.Errorf("%s: cannot rename root directory", p.name)
}
target := joinOpenListAPIPath(path.Dir(source), cleanName)
if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() {
if err := p.openListAPIRename(ctx, source, cleanName); err != nil {
return nil, err
}
return &FileEntry{ID: target, Name: cleanName, IsDir: true}, nil
}
if err := p.webDAVRename(ctx, source, target); err != nil {
return nil, err
}
return &FileEntry{ID: target, Name: cleanName, IsDir: true}, nil
}
func (p *cloudDrive2Provider) Move(ctx context.Context, ref, targetDir, name string) (*FileEntry, error) {
source := normalizeCloudDAVPath(ref)
if source == "/" {
return nil, fmt.Errorf("%s: cannot move root directory", p.name)
}
cleanName := strings.TrimSpace(name)
if cleanName == "" {
cleanName = path.Base(source)
}
var err error
cleanName, err = cleanCloudEntryName(cleanName)
if err != nil {
return nil, err
}
targetDir = normalizeCloudDAVPath(targetDir)
target := joinOpenListAPIPath(targetDir, cleanName)
if sameCloudDAVPath(source, target) {
return &FileEntry{ID: target, Name: cleanName}, nil
}
if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() {
if err := p.openListAPIMove(ctx, source, targetDir, cleanName); err != nil {
return nil, err
}
return &FileEntry{ID: target, Name: cleanName}, nil
}
if err := p.webDAVRename(ctx, source, target); err != nil {
return nil, err
}
return &FileEntry{ID: target, Name: cleanName}, nil
}
func cleanCloudEntryName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" || name == "." || name == ".." {
return "", fmt.Errorf("entry name is required")
}
if strings.ContainsAny(name, `/\`) {
return "", fmt.Errorf("entry name cannot contain path separators")
}
return name, nil
}
func (p *cloudDrive2Provider) openListAPIMkdir(ctx context.Context, target string) error {
return p.openListAPIPost(ctx, "/api/fs/mkdir", map[string]string{"path": normalizeCloudDAVPath(target)}, "mkdir")
}
func (p *cloudDrive2Provider) openListAPIRename(ctx context.Context, source, name string) error {
return p.openListAPIPost(ctx, "/api/fs/rename", map[string]string{
"path": normalizeCloudDAVPath(source),
"name": name,
}, "rename")
}
func (p *cloudDrive2Provider) openListAPIMove(ctx context.Context, source, targetDir, targetName string) error {
targetDir = normalizeCloudDAVPath(targetDir)
sourceName := path.Base(normalizeCloudDAVPath(source))
if sameCloudDAVPath(path.Dir(source), targetDir) {
if sourceName == targetName {
return nil
}
return p.openListAPIRename(ctx, source, targetName)
}
if err := p.openListAPIPost(ctx, "/api/fs/move", map[string]any{
"src_dir": normalizeCloudDAVPath(path.Dir(source)),
"dst_dir": targetDir,
"names": []string{sourceName},
}, "move"); err != nil {
return err
}
if sourceName != targetName {
moved := joinOpenListAPIPath(targetDir, sourceName)
return p.openListAPIRename(ctx, moved, targetName)
}
return nil
}
func (p *cloudDrive2Provider) openListAPIPost(ctx context.Context, apiPath string, payload any, action string) error {
token, err := p.openListAPIToken(ctx)
if err != nil {
return err
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL(apiPath), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", p.ua)
if token != "" {
req.Header.Set("Authorization", token)
}
resp, err := p.client.Do(req)
if err != nil {
return decorateDAVTransportError(p.name, p.openListAPIURL(apiPath), err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%s: api %s returned http %d", p.name, action, resp.StatusCode)
}
var decoded struct {
Code int `json:"code"`
Message string `json:"message"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil {
return fmt.Errorf("%s: decode api %s: %w", p.name, action, err)
}
if decoded.Code != 0 && decoded.Code != 200 {
msg := strings.TrimSpace(decoded.Message)
if msg == "" {
msg = fmt.Sprintf("code %d", decoded.Code)
}
return fmt.Errorf("%s: api %s failed: %s", p.name, action, msg)
}
return nil
}
func (p *cloudDrive2Provider) webDAVMkdir(ctx context.Context, target string) error {
req, err := http.NewRequestWithContext(ctx, "MKCOL", p.urlFor(target), nil)
if err != nil {
return err
}
p.auth(req)
resp, err := p.client.Do(req)
if err != nil {
return decorateDAVTransportError(p.name, p.urlFor(target), err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusCreated, http.StatusOK, http.StatusNoContent:
return nil
case http.StatusMethodNotAllowed:
return fmt.Errorf("%s: mkdir %s returned http %d; the folder may already exist or this WebDAV backend is read-only", p.name, target, resp.StatusCode)
default:
return p.decorateDAVMutationStatusError(resp, "mkdir", target)
}
}
func (p *cloudDrive2Provider) webDAVRename(ctx context.Context, source, target string) error {
req, err := http.NewRequestWithContext(ctx, "MOVE", p.urlFor(source), nil)
if err != nil {
return err
}
p.auth(req)
req.Header.Set("Destination", p.webDAVDestination(target))
req.Header.Set("Overwrite", "F")
resp, err := p.client.Do(req)
if err != nil {
return decorateDAVTransportError(p.name, p.urlFor(source), err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusCreated, http.StatusOK, http.StatusNoContent:
return nil
default:
return p.decorateDAVMutationStatusError(resp, "rename", source)
}
}
func (p *cloudDrive2Provider) webDAVDestination(target string) string {
raw := p.urlFor(target)
u, err := url.Parse(raw)
if err != nil {
return raw
}
u.RawQuery = ""
u.Fragment = ""
return u.String()
}
func (p *cloudDrive2Provider) decorateDAVMutationStatusError(resp *http.Response, action, target string) error {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
detail := compactDAVErrorBody(string(body))
if detail == "" {
return fmt.Errorf("%s: %s %s returned http %d", p.name, action, target, resp.StatusCode)
}
return fmt.Errorf("%s: %s %s returned http %d:%s", p.name, action, target, resp.StatusCode, detail)
}
@@ -0,0 +1,352 @@
package cloud
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
"sort"
"strings"
)
func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) ([]FileEntry, error) {
token, err := p.openListAPIToken(ctx)
if err != nil {
return nil, err
}
const pageSize = 500
target := normalizeCloudDAVPath(dir)
out := make([]FileEntry, 0, pageSize)
for pageNum := 1; ; pageNum++ {
payload := map[string]any{
"path": target,
"password": "",
"page": pageNum,
"per_page": pageSize,
"refresh": false,
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/fs/list"), bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", p.ua)
if token != "" {
req.Header.Set("Authorization", token)
}
resp, err := p.client.Do(req)
if err != nil {
return nil, decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/list"), err)
}
var decoded openListListResponse
decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&decoded)
resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%s: api list %s returned http %d", p.name, target, resp.StatusCode)
}
if decodeErr != nil {
return nil, fmt.Errorf("%s: decode api list: %w", p.name, decodeErr)
}
if decoded.Code != 0 && decoded.Code != 200 {
msg := strings.TrimSpace(decoded.Message)
if msg == "" {
msg = fmt.Sprintf("code %d", decoded.Code)
}
return nil, fmt.Errorf("%s: api list %s failed: %s", p.name, target, msg)
}
for _, item := range decoded.Data.Content {
name := strings.TrimSpace(item.Name)
if name == "" || name == "." || name == "/" {
continue
}
out = append(out, FileEntry{
ID: joinOpenListAPIPath(target, name),
Name: name,
IsDir: item.IsDir,
Size: item.Size,
})
}
total := decoded.Data.Total
if total > 0 {
if len(out) >= total || len(decoded.Data.Content) == 0 {
break
}
continue
}
if len(decoded.Data.Content) == 0 || len(decoded.Data.Content) < pageSize {
break
}
}
return out, nil
}
func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, fileRef string) (*DirectLink, error) {
token, err := p.openListAPIToken(ctx)
if err != nil {
return nil, err
}
payload, _ := json.Marshal(map[string]string{"path": normalizeCloudDAVPath(fileRef), "password": ""})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/fs/get"), bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", p.ua)
if token != "" {
req.Header.Set("Authorization", token)
}
resp, err := p.client.Do(req)
if err != nil {
return nil, decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/get"), err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%s: api get %s returned http %d", p.name, fileRef, resp.StatusCode)
}
var decoded openListGetResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil {
return nil, fmt.Errorf("%s: decode api get: %w", p.name, err)
}
if decoded.Code != 0 && decoded.Code != 200 {
msg := strings.TrimSpace(decoded.Message)
if msg == "" {
msg = fmt.Sprintf("code %d", decoded.Code)
}
return nil, fmt.Errorf("%s: api get %s failed: %s", p.name, fileRef, msg)
}
raw := firstNonEmpty(decoded.Data.RawURL, decoded.Data.URL)
if raw == "" {
return nil, fmt.Errorf("%s: api get %s returned empty raw_url", p.name, fileRef)
}
resolved, err := p.resolveOpenListPlaybackURL(raw)
if err != nil {
return nil, err
}
headers := normalizeOpenListPlaybackHeaders(decoded.Data.Header)
if len(headers) > 0 {
return nil, fmt.Errorf("%s: api get %s returned raw_url that requires headers (%s); refusing WebDAV/proxy fallback for pure 302 playback", p.name, fileRef, strings.Join(sortedHeaderNames(headers), ","))
}
resolved, err = p.resolveOpenListCDNRedirect(ctx, fileRef, resolved)
if err != nil {
return nil, err
}
return &DirectLink{URL: resolved, Headers: nil, Proxy: false}, nil
}
func (p *cloudDrive2Provider) resolveOpenListCDNRedirect(ctx context.Context, fileRef, rawURL string) (string, error) {
if p.apiBase == nil || !sameURLHost(rawURL, p.apiBase) {
return rawURL, nil
}
location, status, err := p.firstHTTPRedirectLocation(ctx, rawURL, nil)
if err != nil {
return "", fmt.Errorf("%s: probe raw_url %s failed: %w", p.name, fileRef, err)
}
if location != "" {
return location, nil
}
return "", fmt.Errorf("%s: api get %s returned an OpenList-hosted raw_url with http %d and no CDN Location; refusing OpenList/WebDAV proxy fallback for pure 302 playback", p.name, fileRef, status)
}
func sortedHeaderNames(headers map[string]string) []string {
if len(headers) == 0 {
return nil
}
out := make([]string, 0, len(headers))
for key := range headers {
key = strings.TrimSpace(key)
if key != "" {
out = append(out, key)
}
}
sort.Strings(out)
return out
}
func (p *cloudDrive2Provider) hasOpenListAPICredentials() bool {
return strings.TrimSpace(p.token) != "" || (strings.TrimSpace(p.username) != "" && p.password != "")
}
func (p *cloudDrive2Provider) openListAPIToken(ctx context.Context) (string, error) {
if token := strings.TrimSpace(p.token); token != "" {
return token, nil
}
if strings.TrimSpace(p.username) == "" || p.password == "" {
return "", nil
}
payload, _ := json.Marshal(map[string]string{
"username": p.username,
"password": p.password,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/auth/login"), bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", p.ua)
resp, err := p.client.Do(req)
if err != nil {
return "", decorateDAVTransportError(p.name, p.openListAPIURL("/api/auth/login"), err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("%s: api login returned http %d", p.name, resp.StatusCode)
}
var decoded openListLoginResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil {
return "", fmt.Errorf("%s: decode api login: %w", p.name, err)
}
if decoded.Code != 0 && decoded.Code != 200 {
msg := strings.TrimSpace(decoded.Message)
if msg == "" {
msg = fmt.Sprintf("code %d", decoded.Code)
}
return "", fmt.Errorf("%s: api login failed: %s", p.name, msg)
}
token := strings.TrimSpace(decoded.Data.Token)
if token == "" {
return "", fmt.Errorf("%s: api login returned empty token", p.name)
}
p.token = token
return token, nil
}
func (p *cloudDrive2Provider) resolveOpenListPlaybackURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", fmt.Errorf("%s: empty playback URL", p.name)
}
if strings.HasPrefix(raw, "//") {
if p.apiBase == nil || p.apiBase.Scheme == "" {
return "", fmt.Errorf("%s: protocol-relative playback URL without API base", p.name)
}
raw = p.apiBase.Scheme + ":" + raw
}
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("%s: invalid playback URL: %w", p.name, err)
}
if u.IsAbs() {
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("%s: unsupported playback URL scheme %q", p.name, u.Scheme)
}
return u.String(), nil
}
if p.apiBase == nil {
return "", fmt.Errorf("%s: relative playback URL without API base", p.name)
}
base := *p.apiBase
base.RawPath = ""
base.RawQuery = ""
base.Fragment = ""
return base.ResolveReference(u).String(), nil
}
func sameURLHost(raw string, base *url.URL) bool {
if base == nil {
return false
}
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return false
}
if !u.IsAbs() {
return true
}
return strings.EqualFold(u.Host, base.Host)
}
func normalizeOpenListPlaybackHeaders(raw json.RawMessage) map[string]string {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
return nil
}
out := make(map[string]string, len(obj))
for k, v := range obj {
key := strings.TrimSpace(k)
if key == "" {
continue
}
switch value := v.(type) {
case string:
if strings.TrimSpace(value) != "" {
out[key] = strings.TrimSpace(value)
}
case []any:
parts := make([]string, 0, len(value))
for _, item := range value {
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
parts = append(parts, strings.TrimSpace(s))
}
}
if len(parts) > 0 {
out[key] = strings.Join(parts, ", ")
}
}
}
if len(out) == 0 {
return nil
}
return out
}
func isCloudVideoPlaybackCandidate(fileRef string) bool {
switch strings.ToLower(path.Ext(strings.TrimSpace(fileRef))) {
case ".mkv", ".mp4", ".m4v", ".avi", ".mov", ".webm", ".ts", ".rmvb", ".rm", ".3gp", ".mpg", ".mpeg":
return true
default:
return false
}
}
type openListListResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Content []openListListItem `json:"content"`
Total int `json:"total"`
} `json:"data"`
}
type openListListItem struct {
Name string `json:"name"`
Size int64 `json:"size"`
IsDir bool `json:"is_dir"`
}
type openListGetResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
RawURL string `json:"raw_url"`
URL string `json:"url"`
Header json.RawMessage `json:"header"`
} `json:"data"`
}
type openListLoginResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Token string `json:"token"`
} `json:"data"`
}
func joinOpenListAPIPath(dir, name string) string {
dir = strings.TrimRight(normalizeCloudDAVPath(dir), "/")
name = strings.Trim(strings.ReplaceAll(name, "\\", "/"), "/")
if dir == "" || dir == "/" {
return normalizeCloudDAVPath(name)
}
return normalizeCloudDAVPath(dir + "/" + name)
}
-176
View File
@@ -1,176 +0,0 @@
package cloud
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// quarkProvider implements the 夸克网盘 cloud disk using cookie auth.
//
// Quark's web API is plain JSON over HTTPS keyed by a session cookie; no
// request-body encryption is required (unlike 115). The resolved download_url
// is tied to the session, so playback runs in proxy mode by default.
type quarkProvider struct {
cookie string
ua string
base string // override for tests; defaults to quarkBase
client *http.Client
proxy bool
}
const quarkBase = "https://drive-pc.quark.cn/1/clouddrive"
func newQuark(cfg map[string]any, client *http.Client) *quarkProvider {
base := str(cfg["base"])
if base == "" {
base = quarkBase
}
ua := str(cfg["ua"])
if ua == "" {
ua = defaultUA
}
// Quark download links require the session cookie + UA, so the host must
// reverse-proxy. The global cloud playback setting decides whether clients
// receive a STRMURL entry or a /Videos stream entry; this provider only
// reports whether the resolved upstream URL itself is safe for raw 302.
proxy := true
return &quarkProvider{
cookie: str(cfg["cookie"]),
ua: ua,
base: strings.TrimRight(base, "/"),
client: client,
proxy: proxy,
}
}
func (q *quarkProvider) Type() string { return TypeQuark }
func (q *quarkProvider) do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, q.base+path, body)
if err != nil {
return nil, err
}
req.Header.Set("Cookie", q.cookie)
req.Header.Set("User-Agent", q.ua)
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Referer", "https://pan.quark.cn/")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return q.client.Do(req)
}
type quarkResp struct {
Status int `json:"status"`
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
func (q *quarkProvider) Ping(ctx context.Context) error {
if q.cookie == "" {
return fmt.Errorf("quark: missing cookie")
}
_, err := q.List(ctx, "0")
return err
}
func (q *quarkProvider) List(ctx context.Context, dirID string) ([]FileEntry, error) {
if dirID == "" {
dirID = "0"
}
const pageSize = 100
out := make([]FileEntry, 0, pageSize)
for page := 1; ; page++ {
query := url.Values{}
query.Set("pr", "ucpro")
query.Set("fr", "pc")
query.Set("uc_param_str", "")
query.Set("pdir_fid", dirID)
query.Set("_page", fmt.Sprint(page))
query.Set("_size", fmt.Sprint(pageSize))
query.Set("_fetch_total", "1")
query.Set("_sort", "file_type:asc,updated_at:desc")
path := "/file/sort?" + query.Encode()
resp, err := q.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
var r quarkResp
err = json.NewDecoder(resp.Body).Decode(&r)
_ = resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("quark: decode list: %w", err)
}
if r.Code != 0 && r.Status != 200 {
return nil, fmt.Errorf("quark: list failed: %s", r.Message)
}
var data struct {
List []struct {
Fid string `json:"fid"`
FileName string `json:"file_name"`
Dir bool `json:"dir"`
Size int64 `json:"size"`
} `json:"list"`
}
if err := json.Unmarshal(r.Data, &data); err != nil {
return nil, fmt.Errorf("quark: decode list data: %w", err)
}
for _, it := range data.List {
out = append(out, FileEntry{
ID: it.Fid,
Name: it.FileName,
IsDir: it.Dir,
Size: it.Size,
})
}
if len(data.List) < pageSize {
break
}
}
return out, nil
}
func (q *quarkProvider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) {
if fileRef == "" {
return nil, fmt.Errorf("quark: empty file id")
}
payload, _ := json.Marshal(map[string]any{"fids": []string{fileRef}})
resp, err := q.do(ctx, http.MethodPost, "/file/download?pr=ucpro&fr=pc&uc_param_str=", bytes.NewReader(payload))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var r quarkResp
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return nil, fmt.Errorf("quark: decode download: %w", err)
}
if r.Code != 0 && r.Status != 200 {
return nil, fmt.Errorf("quark: download failed: %s", r.Message)
}
var data []struct {
DownloadURL string `json:"download_url"`
Fid string `json:"fid"`
}
if err := json.Unmarshal(r.Data, &data); err != nil {
return nil, fmt.Errorf("quark: decode download data: %w", err)
}
if len(data) == 0 || data[0].DownloadURL == "" {
return nil, fmt.Errorf("quark: no download url returned")
}
return &DirectLink{
URL: data[0].DownloadURL,
Headers: map[string]string{
"Cookie": q.cookie,
"User-Agent": q.ua,
"Referer": "https://pan.quark.cn/",
},
Proxy: q.proxy,
}, nil
}