mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 20:26:36 +08:00
添加strm生成
添加strm
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
// Package cloud implements pluggable cloud-disk (网盘) providers used by the
|
||||
// external-storage subsystem to expose remote files as playable media via
|
||||
// HTTP 302 redirects.
|
||||
//
|
||||
// The design offloads playback to the cloud provider: instead of the
|
||||
// host downloading and re-streaming bytes, a provider resolves a file to a
|
||||
// short-lived direct download URL and the player is 302-redirected straight to
|
||||
// the cloud CDN. The host only performs a tiny redirect, freeing its CPU and
|
||||
// bandwidth.
|
||||
//
|
||||
// Each provider authenticates with a cookie (obtained via the web UI, an API
|
||||
// cookie, or a QR-code login flow). Providers are intentionally side-effect
|
||||
// free and take an *http.Client so they can be exercised against httptest
|
||||
// mock servers in unit tests.
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// timeNow is a seam so tests can pin timestamps.
|
||||
var timeNow = time.Now
|
||||
|
||||
// Provider types recognised by the registry.
|
||||
const (
|
||||
Type115 = "cloud115" // 115 网盘
|
||||
TypeCloudDrive2 = "clouddrive2" // CloudDrive2 桥接网盘
|
||||
TypeOpenList = "openlist" // OpenList / AList-compatible bridge
|
||||
)
|
||||
|
||||
// ErrUnsupported is returned for an unknown provider type.
|
||||
var ErrUnsupported = errors.New("unsupported cloud provider")
|
||||
|
||||
// FileEntry is one item in a cloud directory listing.
|
||||
type FileEntry struct {
|
||||
ID string `json:"id"` // provider-native file id
|
||||
Name string `json:"name"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Size int64 `json:"size"`
|
||||
// PickCode is 115-specific; other providers use ID directly.
|
||||
PickCode string `json:"pick_code,omitempty"`
|
||||
}
|
||||
|
||||
// DirectLink is a resolved playback target.
|
||||
type DirectLink struct {
|
||||
URL string `json:"url"`
|
||||
// Headers that must accompany a request to URL (e.g. User-Agent, Cookie).
|
||||
Headers map[string]string `json:"-"`
|
||||
// Proxy reports whether URL requires the host to reverse-proxy the bytes
|
||||
// (because the headers cannot be carried by a plain browser 302). When
|
||||
// false the play handler issues a pure 302 redirect (true offload).
|
||||
Proxy bool `json:"-"`
|
||||
}
|
||||
|
||||
// Provider is the common cloud-disk interface.
|
||||
type Provider interface {
|
||||
// Type returns the provider key.
|
||||
Type() string
|
||||
// Ping validates the stored credentials (cookie). Cheap, used by the
|
||||
// storage-config Test() probe.
|
||||
Ping(ctx context.Context) error
|
||||
// List returns the entries under dirID. An empty dirID means the root.
|
||||
List(ctx context.Context, dirID string) ([]FileEntry, error)
|
||||
// Resolve turns a provider-native file reference (id or pickcode) into a
|
||||
// short-lived direct download link suitable for 302 playback.
|
||||
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.
|
||||
func New(typ string, cfg map[string]any, client *http.Client) (Provider, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
switch typ {
|
||||
case Type115:
|
||||
// 115 使用开放平台(openapi)驱动(QMediaSync 同款:授权/列目录/直链)。
|
||||
return NewOpenAPI115(str(cfg["app_id"]), str(cfg["access_token"]), str(cfg["refresh_token"])), nil
|
||||
case TypeCloudDrive2:
|
||||
return newCloudDrive2(cfg, client), nil
|
||||
case TypeOpenList:
|
||||
return newOpenList(cfg, client), nil
|
||||
default:
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
// IsCloudType reports whether typ is a cloud-disk provider.
|
||||
func IsCloudType(typ string) bool {
|
||||
return typ == Type115 || typ == TypeCloudDrive2 || typ == TypeOpenList
|
||||
}
|
||||
|
||||
// str coerces a config value to a trimmed string.
|
||||
func str(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// boolish coerces a config value to bool ("true"/"1"/true → true).
|
||||
func boolish(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case string:
|
||||
s := strings.ToLower(strings.TrimSpace(t))
|
||||
return s == "1" || s == "true" || s == "yes" || s == "on"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 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,134 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service/cloud115"
|
||||
)
|
||||
|
||||
// 115 开放平台(openapi)驱动测试:mock proapi 的列目录/直链接口。
|
||||
|
||||
func newOpenAPI115TestProvider(t *testing.T, handler http.HandlerFunc) (*openAPI115Provider, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
old := cloud115.ProAPIBase
|
||||
cloud115.ProAPIBase = srv.URL
|
||||
t.Cleanup(func() { cloud115.ProAPIBase = old; srv.Close() })
|
||||
p := NewOpenAPI115("100195125", "token-abc", "refresh-xyz")
|
||||
return p, srv
|
||||
}
|
||||
|
||||
func Test115OpenAPIListAndResolve(t *testing.T) {
|
||||
p, _ := newOpenAPI115TestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/open/ufile/files":
|
||||
if r.Header.Get("Authorization") != "Bearer token-abc" {
|
||||
t.Errorf("missing authorization header")
|
||||
}
|
||||
if r.URL.Query().Get("cid") != "0" {
|
||||
t.Errorf("bad cid %q", r.URL.Query().Get("cid"))
|
||||
}
|
||||
w.Write([]byte(`{"state":true,"data":[
|
||||
{"fid":"100","cid":"100","fc":"0","fn":"Movies","fs":0,"pc":""},
|
||||
{"fid":"200","fc":"1","fn":"Inception.mkv","fs":456,"pc":"pick200"}]}`))
|
||||
case "/open/ufile/downurl":
|
||||
if r.Header.Get("Authorization") != "Bearer token-abc" {
|
||||
t.Errorf("missing authorization header")
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
if r.PostFormValue("pick_code") != "pick200" {
|
||||
t.Errorf("bad pick_code %q", r.PostFormValue("pick_code"))
|
||||
}
|
||||
w.Write([]byte(`{"state":true,"data":{"200":{"file_name":"Inception.mkv","url":{"url":"https://cdn.115/x.mkv?t=1"}}}}`))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
})
|
||||
|
||||
entries, err := p.List(context.Background(), "0")
|
||||
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" || entries[0].Name != "Movies" {
|
||||
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 openapi should default to 302 (no proxy)")
|
||||
}
|
||||
}
|
||||
|
||||
func Test115OpenAPIListPaginates(t *testing.T) {
|
||||
p, _ := newOpenAPI115TestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/open/ufile/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, `{"fid":"`+strconv.Itoa(n)+`","fc":"1","fn":"Movie.`+padZero(n)+`.mkv","fs":1,"pc":"pick`+strconv.Itoa(n)+`"}`)
|
||||
}
|
||||
w.Write([]byte(`{"state":true,"data":[` + strings.Join(items, ",") + `]}`))
|
||||
})
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
func Test115OpenAPIErrorSurfaced(t *testing.T) {
|
||||
p, _ := newOpenAPI115TestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/open/ufile/downurl" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
w.Write([]byte(`{"state":false,"message":"文件不存在"}`))
|
||||
})
|
||||
_, err := p.Resolve(context.Background(), "pickX")
|
||||
if err == nil || !strings.Contains(err.Error(), "文件不存在") {
|
||||
t.Fatalf("want upstream error surfaced, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func Test115OpenAPIPingRequiresToken(t *testing.T) {
|
||||
p := NewOpenAPI115("100195125", "", "")
|
||||
if err := p.Ping(context.Background()); err == nil {
|
||||
t.Fatalf("ping without token should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func padZero(n int) string {
|
||||
s := strconv.Itoa(n)
|
||||
for len(s) < 3 {
|
||||
s = "0" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeprecatedProviderPlaybackOverrideKeysAreIgnored(t *testing.T) {
|
||||
cd2 := newCloudDrive2(map[string]any{"url": "http://example.test/dav", "force_302": "true"}, http.DefaultClient)
|
||||
if !cd2.proxy {
|
||||
t.Fatalf("clouddrive2 should keep safe proxy mode; force_302 is deprecated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedProvider(t *testing.T) {
|
||||
if _, err := New("dropbox", nil, nil); err != ErrUnsupported {
|
||||
t.Fatalf("want ErrUnsupported, got %v", err)
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cloudDrive2Provider bridges CloudDrive2 through its WebDAV endpoint.
|
||||
//
|
||||
// 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 {
|
||||
typ string
|
||||
name string
|
||||
base *url.URL
|
||||
username string
|
||||
password string
|
||||
token string
|
||||
ua string
|
||||
apiBase *url.URL
|
||||
client *http.Client
|
||||
proxy bool
|
||||
}
|
||||
|
||||
func newCloudDrive2(cfg map[string]any, client *http.Client) *cloudDrive2Provider {
|
||||
return newCloudDAVProvider(TypeCloudDrive2, "clouddrive2", cfg, client, "/dav")
|
||||
}
|
||||
|
||||
func newOpenList(cfg map[string]any, client *http.Client) *cloudDrive2Provider {
|
||||
return newCloudDAVProvider(TypeOpenList, "openlist", cfg, client, "/dav")
|
||||
}
|
||||
|
||||
func newCloudDAVProvider(typ, name string, cfg map[string]any, client *http.Client, defaultDAVPath string) *cloudDrive2Provider {
|
||||
rawURL := webDAVURLFromConfig(cfg, defaultDAVPath)
|
||||
u, _ := url.Parse(strings.TrimRight(rawURL, "/"))
|
||||
var apiBase *url.URL
|
||||
if typ == TypeOpenList {
|
||||
apiBase = openListAPIBaseFromConfig(cfg, rawURL, defaultDAVPath)
|
||||
}
|
||||
ua := str(cfg["ua"])
|
||||
if ua == "" {
|
||||
ua = defaultUA
|
||||
}
|
||||
proxy := true
|
||||
return &cloudDrive2Provider{
|
||||
typ: typ,
|
||||
name: name,
|
||||
base: u,
|
||||
username: str(cfg["username"]),
|
||||
password: str(cfg["password"]),
|
||||
token: str(cfg["token"]),
|
||||
ua: ua,
|
||||
apiBase: apiBase,
|
||||
client: client,
|
||||
proxy: proxy,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *cloudDrive2Provider) Type() string { return p.typ }
|
||||
|
||||
func (p *cloudDrive2Provider) Ping(ctx context.Context) error {
|
||||
_, err := p.List(ctx, "")
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) {
|
||||
if err := p.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ref := normalizeCloudDAVPath(fileRef)
|
||||
if ref == "/" {
|
||||
return nil, fmt.Errorf("%s: file reference required", p.name)
|
||||
}
|
||||
if p.typ == TypeOpenList && isCloudVideoPlaybackCandidate(ref) {
|
||||
if p.apiBase == nil {
|
||||
return nil, fmt.Errorf("%s: pure 302 playback requires an OpenList API server address; configure server/api_url so /api/fs/get can return raw_url", p.name)
|
||||
}
|
||||
link, err := p.resolveOpenListAPIDirect(ctx, ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: pure 302 playback requires OpenList raw_url for %s: %w", p.name, ref, err)
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
if p.typ == TypeCloudDrive2 && isCloudVideoPlaybackCandidate(ref) {
|
||||
link, err := p.resolveCloudDAVRedirectDirect(ctx, ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: pure 302 playback requires CloudDrive2/WebDAV to return a CDN Location for %s: %w", p.name, ref, err)
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
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))
|
||||
}
|
||||
return &DirectLink{URL: p.urlFor(ref), Headers: headers, Proxy: p.proxy}, nil
|
||||
}
|
||||
|
||||
func (p *cloudDrive2Provider) validate() error {
|
||||
if p.base == nil || p.base.Scheme == "" || p.base.Host == "" {
|
||||
return fmt.Errorf("%s: missing WebDAV URL", p.name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func webDAVURLFromConfig(cfg map[string]any, defaultDAVPath string) string {
|
||||
rawURL := str(cfg["url"])
|
||||
if rawURL == "" {
|
||||
rawURL = str(cfg["webdav_url"])
|
||||
}
|
||||
if rawURL != "" {
|
||||
return ensureDefaultDAVPath(rawURL, defaultDAVPath)
|
||||
}
|
||||
return defaultWebDAVURL(str(cfg["server"]), defaultDAVPath)
|
||||
}
|
||||
|
||||
func defaultWebDAVURL(server, defaultDAVPath string) string {
|
||||
server = strings.TrimRight(strings.TrimSpace(server), "/")
|
||||
if server == "" {
|
||||
return ""
|
||||
}
|
||||
davPath := strings.TrimSpace(defaultDAVPath)
|
||||
if davPath == "" {
|
||||
return server
|
||||
}
|
||||
if !strings.HasPrefix(davPath, "/") {
|
||||
davPath = "/" + davPath
|
||||
}
|
||||
return server + davPath
|
||||
}
|
||||
|
||||
func openListAPIBaseFromConfig(cfg map[string]any, webDAVURL, defaultDAVPath string) *url.URL {
|
||||
raw := str(cfg["server"])
|
||||
if raw == "" {
|
||||
raw = firstNonEmpty(str(cfg["api_url"]), webDAVURL)
|
||||
}
|
||||
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return nil
|
||||
}
|
||||
davPath := strings.Trim(strings.TrimSpace(defaultDAVPath), "/")
|
||||
if davPath != "" {
|
||||
pathParts := strings.Split(strings.TrimRight(u.Path, "/"), "/")
|
||||
if len(pathParts) > 0 && strings.EqualFold(pathParts[len(pathParts)-1], davPath) {
|
||||
u.Path = strings.Join(pathParts[:len(pathParts)-1], "/")
|
||||
if u.Path == "" {
|
||||
u.Path = "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
u.RawPath = ""
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
return u
|
||||
}
|
||||
|
||||
func (p *cloudDrive2Provider) openListAPIURL(apiPath string) string {
|
||||
if p.apiBase == nil {
|
||||
return ""
|
||||
}
|
||||
u := *p.apiBase
|
||||
u.RawPath = ""
|
||||
basePath := strings.TrimRight(u.Path, "/")
|
||||
apiPath = "/" + strings.TrimLeft(apiPath, "/")
|
||||
if basePath == "" || basePath == "/" {
|
||||
u.Path = apiPath
|
||||
} else {
|
||||
u.Path = basePath + apiPath
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func ensureDefaultDAVPath(rawURL, defaultDAVPath string) string {
|
||||
rawURL = strings.TrimRight(strings.TrimSpace(rawURL), "/")
|
||||
if rawURL == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return rawURL
|
||||
}
|
||||
if strings.TrimSpace(defaultDAVPath) == "" {
|
||||
return rawURL
|
||||
}
|
||||
if u.Path == "" || u.Path == "/" {
|
||||
davPath := strings.TrimSpace(defaultDAVPath)
|
||||
if !strings.HasPrefix(davPath, "/") {
|
||||
davPath = "/" + davPath
|
||||
}
|
||||
u.Path = davPath
|
||||
u.RawPath = ""
|
||||
return strings.TrimRight(u.String(), "/")
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
|
||||
func normalizeCloudDAVPath(p string) string {
|
||||
p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
|
||||
if p == "" || p == "." {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
cleaned := path.Clean(p)
|
||||
if cleaned == "." {
|
||||
return "/"
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func sameCloudDAVPath(a, b string) bool {
|
||||
return strings.TrimRight(normalizeCloudDAVPath(a), "/") == strings.TrimRight(normalizeCloudDAVPath(b), "/")
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (p *cloudDrive2Provider) decorateDAVStatusError(resp *http.Response, target string) error {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
detail := compactDAVErrorBody(string(body))
|
||||
if detail == "" {
|
||||
if resp.StatusCode == http.StatusMethodNotAllowed {
|
||||
return fmt.Errorf("%s: list %s returned http %d;请确认填写的是 WebDAV 地址(通常以 /dav 结尾),并且桥接网盘已在 OpenList/CloudDrive2 内完成登录或 Cookie 保存", p.name, target, resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return fmt.Errorf("%s: list %s returned http %d;请填写 OpenList/CloudDrive2 的 Token 或用户名密码,或确认 WebDAV 凭据可用", p.name, target, resp.StatusCode)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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,312 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"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
|
||||
}
|
||||
|
||||
// PutFile 把本地文件内容上传(覆盖)到远端 remotePath。
|
||||
// OpenList 账号优先走 OpenList /api/fs/form 分片表单接口;其余走 WebDAV PUT。
|
||||
func (p *cloudDrive2Provider) PutFile(ctx context.Context, remotePath string, r io.Reader) error {
|
||||
target := normalizeCloudDAVPath(remotePath)
|
||||
if target == "/" {
|
||||
return fmt.Errorf("%s: cannot upload to root directory", p.name)
|
||||
}
|
||||
if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() {
|
||||
return p.openListAPIPutFile(ctx, target, r)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, p.urlFor(target), r)
|
||||
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
|
||||
default:
|
||||
return p.decorateDAVMutationStatusError(resp, "upload", target)
|
||||
}
|
||||
}
|
||||
|
||||
// openListAPIPutFile 通过 OpenList /api/fs/form 上传(QMediaSync 同款契约:
|
||||
// PUT + multipart + File-Path 头)。
|
||||
func (p *cloudDrive2Provider) openListAPIPutFile(ctx context.Context, remotePath string, r io.Reader) error {
|
||||
token, err := p.openListAPIToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encodedPath := openListPathEscape(remotePath)
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
formFile, err := writer.CreateFormFile("file", path.Base(remotePath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(formFile, r); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, p.openListAPIURL("/api/fs/form"), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("File-Path", encodedPath)
|
||||
req.Header.Set("As-Task", "true")
|
||||
req.Header.Set("Overwrite", "true")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/form"), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return p.openListAPIStatusError("upload", remotePath, resp.StatusCode)
|
||||
}
|
||||
// OpenList 返回 code=200 即任务受理成功(小文件同步完成,大文件异步排队)。
|
||||
return nil
|
||||
}
|
||||
|
||||
// openListPathEscape 保留斜杠地 URL 编码远端路径(OpenList File-Path 需要)。
|
||||
func openListPathEscape(p string) string {
|
||||
parts := strings.Split(strings.TrimPrefix(normalizeCloudDAVPath(p), "/"), "/")
|
||||
for i, part := range parts {
|
||||
parts[i] = url.PathEscape(part)
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
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,240 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"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, p.openListAPIStatusError("list", 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, p.openListAPIStatusError("get", 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) openListAPIStatusError(action, target string, status int) error {
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
return fmt.Errorf("%s: api %s %s returned http %d;请检查 OpenList Token 或用户名密码,并确认填写的是 OpenList 服务地址而不是 /dav 地址", p.name, action, target, status)
|
||||
}
|
||||
return fmt.Errorf("%s: api %s %s returned http %d", p.name, action, target, status)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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 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)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCloudDrive2WebDAVListAndResolve(t *testing.T) {
|
||||
var gotAuth, gotDepth, gotRange string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "PROPFIND" && r.URL.Path == "/dav":
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotDepth = r.Header.Get("Depth")
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>/dav/</d:href>
|
||||
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav/115/</d:href>
|
||||
<d:propstat><d:prop><d:displayname>115</d:displayname><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav/123/Movie.mkv</d:href>
|
||||
<d:propstat><d:prop><d:displayname>Movie.mkv</d:displayname><d:getcontentlength>789</d:getcontentlength><d:resourcetype/></d:prop></d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/dav/123/Movie.mkv":
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotRange = r.Header.Get("Range")
|
||||
http.Redirect(w, r, "https://cdn.example.test/123/Movie.mkv?sign=1", http.StatusFound)
|
||||
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)
|
||||
}
|
||||
entries, err := p.List(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if gotDepth != "1" {
|
||||
t.Fatalf("Depth = %q, want 1", gotDepth)
|
||||
}
|
||||
if !strings.HasPrefix(gotAuth, "Basic ") {
|
||||
t.Fatalf("missing basic auth: %q", gotAuth)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("entries = %#v", entries)
|
||||
}
|
||||
if !entries[0].IsDir || entries[0].ID != "/115" {
|
||||
t.Fatalf("dir entry wrong: %#v", entries[0])
|
||||
}
|
||||
if entries[1].IsDir || entries[1].ID != "/123/Movie.mkv" || entries[1].Size != 789 {
|
||||
t.Fatalf("file entry wrong: %#v", entries[1])
|
||||
}
|
||||
link, err := p.Resolve(context.Background(), entries[1].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if link.URL != "https://cdn.example.test/123/Movie.mkv?sign=1" {
|
||||
t.Fatalf("bad url: %s", link.URL)
|
||||
}
|
||||
if link.Proxy || len(link.Headers) != 0 {
|
||||
t.Fatalf("clouddrive2 video should resolve to pure 302 link: %#v", link)
|
||||
}
|
||||
if gotRange != "bytes=0-0" {
|
||||
t.Fatalf("resolve should probe with a tiny range, got %q", gotRange)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudDrive2ResolveRejectsWebDAVProxyFallbackWithoutRedirect(t *testing.T) {
|
||||
var getSeen bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "PROPFIND" && r.URL.Path == "/dav":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/dav/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response></d:multistatus>`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/dav/123/Movie.mkv":
|
||||
getSeen = true
|
||||
w.Header().Set("Content-Range", "bytes 0-0/10")
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = w.Write([]byte("x"))
|
||||
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)
|
||||
}
|
||||
_, err = p.Resolve(context.Background(), "/123/Movie.mkv")
|
||||
if err == nil || !strings.Contains(err.Error(), "without CDN Location") || !strings.Contains(err.Error(), "refusing WebDAV/proxy fallback") {
|
||||
t.Fatalf("resolve error = %v, want pure 302 refusal", err)
|
||||
}
|
||||
if !getSeen {
|
||||
t.Fatal("expected CloudDrive2 WebDAV direct-link probe")
|
||||
}
|
||||
}
|
||||
|
||||
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/%E6%AC%A7%E7%BE%8E%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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenListWebDAVListAndResolve(t *testing.T) {
|
||||
var gotPath, gotDepth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/auth/login" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/fs/get" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != "PROPFIND" || r.URL.Path != "/dav" {
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
gotPath = r.URL.Path
|
||||
gotDepth = r.Header.Get("Depth")
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>/dav/</d:href>
|
||||
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/dav/Cloud/Movie.mkv</d:href>
|
||||
<d:propstat><d:prop><d:displayname>Movie.mkv</d:displayname><d:getcontentlength>1024</d:getcontentlength><d:resourcetype/></d:prop></d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeOpenList, map[string]any{"url": srv.URL + "/dav"}, srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Type() != TypeOpenList {
|
||||
t.Fatalf("type = %q, want %q", p.Type(), TypeOpenList)
|
||||
}
|
||||
entries, err := p.List(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if gotPath != "/dav" {
|
||||
t.Fatalf("path = %q, want /dav", gotPath)
|
||||
}
|
||||
if gotDepth != "1" {
|
||||
t.Fatalf("Depth = %q, want 1", gotDepth)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].ID != "/Cloud/Movie.mkv" || entries[0].Size != 1024 {
|
||||
t.Fatalf("entries = %#v", entries)
|
||||
}
|
||||
_, err = p.Resolve(context.Background(), entries[0].ID)
|
||||
if err == nil || !strings.Contains(err.Error(), "pure 302 playback requires OpenList raw_url") {
|
||||
t.Fatalf("openlist video resolve should require raw_url instead of WebDAV proxy fallback, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenListListUsesAPIUsernamePasswordWithoutWebDAVFallback(t *testing.T) {
|
||||
var loginSeen, listSeen, davSeen bool
|
||||
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/auth/login":
|
||||
loginSeen = true
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode login body: %v", err)
|
||||
}
|
||||
if body["username"] != "alice" || body["password"] != "secret" {
|
||||
t.Fatalf("login body = %#v", body)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":200,"data":{"token":"api-token"}}`))
|
||||
case "/api/fs/list":
|
||||
listSeen = true
|
||||
if r.Header.Get("Authorization") != "api-token" {
|
||||
t.Fatalf("Authorization = %q, want api-token", r.Header.Get("Authorization"))
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":200,"data":{"content":[{"name":"Movies","is_dir":true,"size":0},{"name":"Movie.mkv","is_dir":false,"size":1024}],"total":2}}`))
|
||||
case "/dav":
|
||||
davSeen = true
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "username": "alice", "password": "secret"}, srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := p.List(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if !loginSeen || !listSeen {
|
||||
t.Fatalf("expected api login/list, login=%v list=%v", loginSeen, listSeen)
|
||||
}
|
||||
if davSeen {
|
||||
t.Fatal("openlist API credentials should not fall back to WebDAV")
|
||||
}
|
||||
if len(entries) != 2 || entries[0].ID != "/Movies" || !entries[0].IsDir || entries[1].ID != "/Movie.mkv" || entries[1].Size != 1024 {
|
||||
t.Fatalf("entries = %#v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestOpenListListAPIFailureDoesNotFallbackToWebDAV(t *testing.T) {
|
||||
var davSeen bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/auth/login":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":500,"message":"bad password"}`))
|
||||
case "/dav":
|
||||
davSeen = true
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "username": "alice", "password": "bad"}, srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = p.List(context.Background(), "")
|
||||
if err == nil || !strings.Contains(err.Error(), "api login failed") || !strings.Contains(err.Error(), "bad password") {
|
||||
t.Fatalf("list error = %v, want api login failure", err)
|
||||
}
|
||||
if davSeen {
|
||||
t.Fatal("openlist API failure fell back to WebDAV")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenListRootURLDefaultsToDAV(t *testing.T) {
|
||||
var gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/dav/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response></d:multistatus>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeOpenList, map[string]any{"url": srv.URL + "/"}, srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := p.List(context.Background(), ""); err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if gotPath != "/dav" {
|
||||
t.Fatalf("path = %q, want /dav", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenListURLForKeepsNonASCIIPathSingleEncoded(t *testing.T) {
|
||||
p := newOpenList(map[string]any{"url": "http://example.test:5244/dav/"}, nil)
|
||||
got := p.urlFor("/动画电影/爱宠大机密2 (2019) {tmdb-412117}")
|
||||
if strings.Contains(got, "%25E") {
|
||||
t.Fatalf("url is double-escaped: %s", got)
|
||||
}
|
||||
want := "http://example.test:5244/dav/%E5%8A%A8%E7%94%BB%E7%94%B5%E5%BD%B1/%E7%88%B1%E5%AE%A0%E5%A4%A7%E6%9C%BA%E5%AF%862%20%282019%29%20%7Btmdb-412117%7D"
|
||||
if got != want {
|
||||
t.Fatalf("url = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenListDAVStatusErrorIncludesBodyHint(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
_, _ = w.Write([]byte("请先填写有效Cookie并保存"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeOpenList, map[string]any{"url": srv.URL + "/dav"}, srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = p.List(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Fatal("want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "请先填写有效Cookie并保存") || !strings.Contains(err.Error(), "WebDAV 地址") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenListResolveUsesAPIRawURLFor302Playback(t *testing.T) {
|
||||
var gotPath, gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/fs/get" {
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"https://cdn.example.test/movie.mkv?sign=1"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link, err := p.Resolve(context.Background(), "/Cloud/Movie.mkv")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if gotPath != "/api/fs/get" {
|
||||
t.Fatalf("api path = %q, want /api/fs/get", gotPath)
|
||||
}
|
||||
if gotAuth != "alist-token" {
|
||||
t.Fatalf("Authorization = %q, want token", gotAuth)
|
||||
}
|
||||
if link.URL != "https://cdn.example.test/movie.mkv?sign=1" {
|
||||
t.Fatalf("url = %q", link.URL)
|
||||
}
|
||||
if link.Proxy {
|
||||
t.Fatalf("openlist raw_url without required headers should be 302 playback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenListResolveCollapsesHostedRawURLRedirectToCDN(t *testing.T) {
|
||||
var probeSeen bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/fs/get":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"/d/Cloud/Movie.mkv?sign=1"}}`))
|
||||
case "/d/Cloud/Movie.mkv":
|
||||
probeSeen = true
|
||||
if r.Header.Get("Range") != "bytes=0-0" {
|
||||
t.Fatalf("probe Range = %q", r.Header.Get("Range"))
|
||||
}
|
||||
http.Redirect(w, r, "https://cdn.example.test/movie.mkv?sign=cdn", http.StatusFound)
|
||||
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)
|
||||
}
|
||||
link, err := p.Resolve(context.Background(), "/Cloud/Movie.mkv")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if !probeSeen {
|
||||
t.Fatal("expected OpenList-hosted raw_url probe")
|
||||
}
|
||||
if link.URL != "https://cdn.example.test/movie.mkv?sign=cdn" || link.Proxy || len(link.Headers) != 0 {
|
||||
t.Fatalf("link = %#v, want collapsed CDN 302 playback", link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenListResolveLogsInWithUsernamePasswordForAPIRawURL(t *testing.T) {
|
||||
var loginSeen bool
|
||||
var gotAuth 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/auth/login":
|
||||
loginSeen = true
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode login body: %v", err)
|
||||
}
|
||||
if body["username"] != "alice" || body["password"] != "secret" {
|
||||
t.Fatalf("login body = %#v", body)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":200,"data":{"token":"api-token"}}`))
|
||||
case "/api/fs/get":
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"https://cdn.example.test/movie.mkv?sign=1"}}`))
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "username": "alice", "password": "secret"}, srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link, err := p.Resolve(context.Background(), "/Cloud/Movie.mkv")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if !loginSeen {
|
||||
t.Fatalf("expected api login before fs/get")
|
||||
}
|
||||
if gotAuth != "api-token" {
|
||||
t.Fatalf("Authorization = %q, want api-token", gotAuth)
|
||||
}
|
||||
if link.URL != "https://cdn.example.test/movie.mkv?sign=1" || link.Proxy {
|
||||
t.Fatalf("link = %#v, want raw_url 302 playback", link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenListResolveRejectsProxyWhenAPIRawURLNeedsHeaders(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/fs/get" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"/dav/Cloud/Movie.mkv","header":{"Cookie":"sid=abc"}}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = p.Resolve(context.Background(), "/Cloud/Movie.mkv")
|
||||
if err == nil || !strings.Contains(err.Error(), "refusing WebDAV/proxy fallback") || !strings.Contains(err.Error(), "Cookie") {
|
||||
t.Fatalf("resolve error = %v, want pure 302 refusal with header names", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenListResolveRejectsHostedRawURLWithoutCDNRedirect(t *testing.T) {
|
||||
var probeSeen bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/fs/get":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"/d/Cloud/Movie.mkv?sign=1"}}`))
|
||||
case "/d/Cloud/Movie.mkv":
|
||||
probeSeen = true
|
||||
w.Header().Set("Content-Range", "bytes 0-0/10")
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = w.Write([]byte("x"))
|
||||
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)
|
||||
}
|
||||
_, err = p.Resolve(context.Background(), "/Cloud/Movie.mkv")
|
||||
if err == nil || !strings.Contains(err.Error(), "OpenList-hosted raw_url") || !strings.Contains(err.Error(), "no CDN Location") {
|
||||
t.Fatalf("resolve error = %v, want hosted raw_url refusal", err)
|
||||
}
|
||||
if !probeSeen {
|
||||
t.Fatal("expected OpenList-hosted raw_url probe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenListResolveDoesNotFallbackToWebDAVWhenAPIRawURLFails(t *testing.T) {
|
||||
var davSeen bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/fs/get":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":500,"message":"driver cannot provide raw_url"}`))
|
||||
case "/dav/Cloud/Movie.mkv":
|
||||
davSeen = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
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)
|
||||
}
|
||||
_, err = p.Resolve(context.Background(), "/Cloud/Movie.mkv")
|
||||
if err == nil || !strings.Contains(err.Error(), "pure 302 playback requires OpenList raw_url") {
|
||||
t.Fatalf("resolve error = %v, want raw_url requirement", err)
|
||||
}
|
||||
if davSeen {
|
||||
t.Fatal("openlist video resolve fell back to WebDAV after raw_url failure")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// 115 开放平台(openapi)驱动:替代原 cookie 逆向方案。
|
||||
//
|
||||
// 账号配置(StrmAccount.Config JSON):
|
||||
//
|
||||
// {
|
||||
// "app_id": "100195125", // 开放平台应用 ID
|
||||
// "access_token": "...", // 加密存储
|
||||
// "refresh_token": "...", // 加密存储
|
||||
// "user_id": "12345", // 可选
|
||||
// "user_name": "user" // 可选
|
||||
// }
|
||||
//
|
||||
// 授权流程:设备码扫码(官方 PKCE 应用目录)、QMediaSync/MQFamily 中继、
|
||||
// MoviePilot 轮询、CloudDrive 回跳,见 internal/service/cloud115。
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service/cloud115"
|
||||
)
|
||||
|
||||
// openAPI115Provider 实现 Provider 接口:List 列目录、Resolve 用 pickcode
|
||||
// 换下载直链(302 offload,无需代理)、Ping 探测根目录。
|
||||
type openAPI115Provider struct {
|
||||
c *cloud115.OpenClient
|
||||
}
|
||||
|
||||
// NewOpenAPI115 构造 115 开放平台驱动。
|
||||
func NewOpenAPI115(appID, accessToken, refreshToken string) *openAPI115Provider {
|
||||
return &openAPI115Provider{c: cloud115.NewOpenClient(strings.TrimSpace(appID), accessToken, refreshToken)}
|
||||
}
|
||||
|
||||
func (p *openAPI115Provider) Type() string { return Type115 }
|
||||
|
||||
func (p *openAPI115Provider) Ping(ctx context.Context) error {
|
||||
if strings.TrimSpace(p.c.AppID) == "" {
|
||||
return fmt.Errorf("115: 缺少开放平台应用 ID,请重新授权")
|
||||
}
|
||||
if strings.TrimSpace(p.c.AccessToken) == "" {
|
||||
return fmt.Errorf("115: 缺少访问令牌,请重新授权")
|
||||
}
|
||||
_, _, err := p.c.GetFsList(ctx, "0", 0, 1)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *openAPI115Provider) List(ctx context.Context, dirID string) ([]FileEntry, error) {
|
||||
// 115 开放平台列表接口按 offset/limit 分页,这里循环取完整个目录
|
||||
const pageSize = 100
|
||||
var out []FileEntry
|
||||
for offset := 0; ; offset += pageSize {
|
||||
files, _, err := p.c.GetFsList(ctx, dirID, offset, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, f := range files {
|
||||
out = append(out, FileEntry{
|
||||
ID: f.FileId,
|
||||
Name: f.FileName,
|
||||
IsDir: f.Category == cloud115.TypeDir,
|
||||
Size: f.FileSize,
|
||||
PickCode: f.PickCode,
|
||||
})
|
||||
}
|
||||
if len(files) < pageSize {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *openAPI115Provider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) {
|
||||
url, err := p.c.GetDownloadURL(ctx, fileRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DirectLink{URL: url, Proxy: false}, nil
|
||||
}
|
||||
|
||||
// OpenClient 暴露底层客户端(token 刷新用)。
|
||||
func (p *openAPI115Provider) OpenClient() *cloud115.OpenClient { return p.c }
|
||||
|
||||
// RefreshToken 刷新访问令牌并返回新令牌;refresh_token 失效时返回
|
||||
// cloud115.IsRefreshTokenDead(err) 为 true 的错误。
|
||||
func (p *openAPI115Provider) RefreshToken(refreshToken string) (*cloud115.TokenData, error) {
|
||||
return p.c.RefreshToken(refreshToken)
|
||||
}
|
||||
Reference in New Issue
Block a user