mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 04:16:38 +08:00
refactor: split modules and harden scraping workflows
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user