From b2b43e58dad246826c7e6f47d0b57b7a82090006 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:45:10 +0800 Subject: [PATCH] enforce pure cloud 302 playback --- internal/service/cloud/cloud_test.go | 184 +++++++++++++++++++++----- internal/service/cloud/clouddrive2.go | 169 +++++++++++++++++++++-- 2 files changed, 312 insertions(+), 41 deletions(-) diff --git a/internal/service/cloud/cloud_test.go b/internal/service/cloud/cloud_test.go index ed9d655..e04834d 100644 --- a/internal/service/cloud/cloud_test.go +++ b/internal/service/cloud/cloud_test.go @@ -313,19 +313,15 @@ func Test115QRFlow(t *testing.T) { } func TestCloudDrive2WebDAVListAndResolve(t *testing.T) { - var gotAuth, gotDepth string + var gotAuth, gotDepth, gotRange string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotAuth = r.Header.Get("Authorization") - gotDepth = r.Header.Get("Depth") - if r.Method != "PROPFIND" { - t.Fatalf("unexpected method %s", r.Method) - } - if r.URL.Path != "/dav" { - t.Fatalf("unexpected path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - _, _ = w.Write([]byte(` + 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(` /dav/ @@ -340,6 +336,13 @@ func TestCloudDrive2WebDAVListAndResolve(t *testing.T) { Movie.mkv789 `)) + 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() @@ -370,14 +373,46 @@ func TestCloudDrive2WebDAVListAndResolve(t *testing.T) { if err != nil { t.Fatalf("resolve: %v", err) } - if link.URL != srv.URL+"/dav/123/Movie.mkv" { + if link.URL != "https://cdn.example.test/123/Movie.mkv?sign=1" { t.Fatalf("bad url: %s", link.URL) } - if !link.Proxy { - t.Fatalf("clouddrive2 should default to proxy mode") + if link.Proxy || len(link.Headers) != 0 { + t.Fatalf("clouddrive2 video should resolve to pure 302 link: %#v", link) } - if !strings.HasPrefix(link.Headers["Authorization"], "Basic ") { - t.Fatalf("resolve must carry basic auth: %#v", link.Headers) + 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(`/dav/`)) + 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") } } @@ -433,15 +468,9 @@ func TestOpenListWebDAVListAndResolve(t *testing.T) { if len(entries) != 1 || entries[0].ID != "/Cloud/Movie.mkv" || entries[0].Size != 1024 { t.Fatalf("entries = %#v", entries) } - link, err := p.Resolve(context.Background(), entries[0].ID) - if err != nil { - t.Fatalf("resolve: %v", err) - } - if link.URL != srv.URL+"/dav/Cloud/Movie.mkv" { - t.Fatalf("bad url: %s", link.URL) - } - if !link.Proxy { - t.Fatalf("openlist should default to proxy mode") + _, 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) } } @@ -480,6 +509,41 @@ func TestOpenListResolveUsesAPIRawURLFor302Playback(t *testing.T) { } } +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 @@ -524,7 +588,7 @@ func TestOpenListResolveLogsInWithUsernamePasswordForAPIRawURL(t *testing.T) { } } -func TestOpenListResolveFallsBackToProxyWhenAPIRawURLNeedsHeaders(t *testing.T) { +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) @@ -538,15 +602,69 @@ func TestOpenListResolveFallsBackToProxyWhenAPIRawURLNeedsHeaders(t *testing.T) if err != nil { t.Fatal(err) } - link, err := p.Resolve(context.Background(), "/Cloud/Movie.mkv") + _, 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.Fatalf("resolve: %v", err) + t.Fatal(err) } - if link.URL != srv.URL+"/dav/Cloud/Movie.mkv" { - t.Fatalf("url = %q", link.URL) + _, 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 !link.Proxy || link.Headers["Cookie"] != "sid=abc" { - t.Fatalf("link should keep proxy mode with required headers: %#v", link) + 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") } } diff --git a/internal/service/cloud/clouddrive2.go b/internal/service/cloud/clouddrive2.go index 838229c..fe8209f 100644 --- a/internal/service/cloud/clouddrive2.go +++ b/internal/service/cloud/clouddrive2.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" "path" + "sort" "strconv" "strings" ) @@ -212,10 +213,22 @@ func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*Dir if ref == "/" { return nil, fmt.Errorf("%s: file reference required", p.name) } - if p.typ == TypeOpenList && p.apiBase != nil && isOpenListDirectPlaybackCandidate(ref) { - if link, err := p.resolveOpenListAPIDirect(ctx, ref); err == nil && link != nil { - return link, nil + 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, @@ -272,11 +285,108 @@ func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, file return nil, err } headers := normalizeOpenListPlaybackHeaders(decoded.Data.Header) - proxy := p.proxy && len(headers) > 0 - if !proxy { - headers = nil + 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), ",")) } - return &DirectLink{URL: resolved, Headers: headers, Proxy: proxy}, nil + 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 { @@ -359,6 +469,49 @@ func (p *cloudDrive2Provider) resolveOpenListPlaybackURL(raw string) (string, er 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 @@ -396,7 +549,7 @@ func normalizeOpenListPlaybackHeaders(raw json.RawMessage) map[string]string { return out } -func isOpenListDirectPlaybackCandidate(fileRef string) bool { +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