diff --git a/internal/service/cloud/cloud_test.go b/internal/service/cloud/cloud_test.go index e04834d..8df5440 100644 --- a/internal/service/cloud/cloud_test.go +++ b/internal/service/cloud/cloud_test.go @@ -448,7 +448,7 @@ func TestOpenListWebDAVListAndResolve(t *testing.T) { })) defer srv.Close() - p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "username": "u", "password": "p"}, srv.Client()) + p, err := New(TypeOpenList, map[string]any{"url": srv.URL + "/dav"}, srv.Client()) if err != nil { t.Fatal(err) } @@ -474,6 +474,84 @@ func TestOpenListWebDAVListAndResolve(t *testing.T) { } } +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 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 TestOpenListResolveUsesAPIRawURLFor302Playback(t *testing.T) { var gotPath, gotAuth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/service/cloud/clouddrive2.go b/internal/service/cloud/clouddrive2.go index fe8209f..2c0cd3b 100644 --- a/internal/service/cloud/clouddrive2.go +++ b/internal/service/cloud/clouddrive2.go @@ -81,9 +81,7 @@ func (p *cloudDrive2Provider) List(ctx context.Context, dir string) ([]FileEntry return nil, err } if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() { - if entries, err := p.listOpenListAPI(ctx, dir); err == nil { - return entries, nil - } + return p.listOpenListAPI(ctx, dir) } target := normalizeCloudDAVPath(dir) req, err := http.NewRequestWithContext(ctx, "PROPFIND", p.urlFor(target), strings.NewReader(cloudDAVPropfindBody)) diff --git a/internal/service/storage_upload.go b/internal/service/storage_upload.go index 7d28584..038aa25 100644 --- a/internal/service/storage_upload.go +++ b/internal/service/storage_upload.go @@ -227,10 +227,12 @@ func joinRemotePath(base, rel string) string { } type alistUploader struct { - name string - server string - token string - client *http.Client + name string + server string + token string + username string + password string + client *http.Client } func newAlistUploader(cfg map[string]any) *alistUploader { @@ -239,10 +241,12 @@ func newAlistUploader(cfg map[string]any) *alistUploader { func newNamedAlistUploader(name string, cfg map[string]any) *alistUploader { return &alistUploader{ - name: name, - server: strings.TrimRight(strr(cfg["server"]), "/"), - token: strr(cfg["token"]), - client: &http.Client{}, + name: name, + server: strings.TrimRight(strr(cfg["server"]), "/"), + token: strr(cfg["token"]), + username: strr(cfg["username"]), + password: strr(cfg["password"]), + client: &http.Client{}, } } @@ -250,6 +254,9 @@ func (a *alistUploader) ensureDir(ctx context.Context, remoteDir string) error { if a.server == "" { return fmt.Errorf("%s missing server", a.name) } + if err := a.ensureToken(ctx); err != nil { + return err + } remoteDir = normalizeRemotePath(remoteDir) if remoteDir == "/" { return nil @@ -277,6 +284,9 @@ func (a *alistUploader) ensureDir(ctx context.Context, remoteDir string) error { } func (a *alistUploader) exists(ctx context.Context, remotePath string) (bool, error) { + if err := a.ensureToken(ctx); err != nil { + return false, err + } payload, _ := json.Marshal(map[string]string{"path": normalizeRemotePath(remotePath)}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.server+"/api/fs/get", bytes.NewReader(payload)) if err != nil { @@ -301,6 +311,9 @@ func (a *alistUploader) exists(ctx context.Context, remotePath string) (bool, er } func (a *alistUploader) upload(ctx context.Context, localPath, remotePath string, size int64) error { + if err := a.ensureToken(ctx); err != nil { + return err + } f, err := os.Open(localPath) // #nosec G304 -- localPath is selected from configured local media files before upload. if err != nil { return err @@ -321,6 +334,59 @@ func (a *alistUploader) upload(ctx context.Context, localPath, remotePath string return a.checkJSON(resp, "alist upload") } +func (a *alistUploader) ensureToken(ctx context.Context) error { + if strings.TrimSpace(a.token) != "" { + return nil + } + if strings.TrimSpace(a.username) == "" || a.password == "" { + return nil + } + if a.server == "" { + return fmt.Errorf("%s missing server", a.name) + } + payload, _ := json.Marshal(map[string]string{ + "username": a.username, + "password": a.password, + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.server+"/api/auth/login", bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := a.client.Do(req) + if err != nil { + return decorateStorageTransportError(a.name, a.server, err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("%s login: http %d: %s", a.name, resp.StatusCode, strings.TrimSpace(string(body))) + } + var out struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + Token string `json:"token"` + } `json:"data"` + } + if err := json.Unmarshal(body, &out); err != nil { + return fmt.Errorf("%s login: decode response: %w", a.name, err) + } + if out.Code != 0 && out.Code != 200 { + msg := strings.TrimSpace(out.Message) + if msg == "" { + msg = fmt.Sprintf("code %d", out.Code) + } + return fmt.Errorf("%s login: %s", a.name, msg) + } + a.token = strings.TrimSpace(out.Data.Token) + if a.token == "" { + return fmt.Errorf("%s login returned empty token", a.name) + } + return nil +} + func (a *alistUploader) auth(req *http.Request) { if a.token != "" { req.Header.Set("Authorization", a.token) diff --git a/internal/service/storage_upload_test.go b/internal/service/storage_upload_test.go index d211f45..2f16c94 100644 --- a/internal/service/storage_upload_test.go +++ b/internal/service/storage_upload_test.go @@ -146,6 +146,83 @@ func TestStorageConfigUploadLocalToOpenListAPI(t *testing.T) { } } +func TestStorageConfigUploadLocalToOpenListAPIWithUsernamePassword(t *testing.T) { + var loginSeen bool + var uploaded []string + openlist := 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":"openlist-session-token"}}`)) + case "/api/fs/mkdir": + if r.Header.Get("Authorization") != "openlist-session-token" { + t.Fatalf("mkdir authorization = %q", r.Header.Get("Authorization")) + } + _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) + case "/api/fs/get": + if r.Header.Get("Authorization") != "openlist-session-token" { + t.Fatalf("get authorization = %q", r.Header.Get("Authorization")) + } + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"code":404,"message":"not found"}`)) + case "/api/fs/put": + if r.Header.Get("Authorization") != "openlist-session-token" { + t.Fatalf("put authorization = %q", r.Header.Get("Authorization")) + } + decoded, err := url.PathUnescape(r.Header.Get("File-Path")) + if err != nil { + t.Fatalf("decode file path: %v", err) + } + uploaded = append(uploaded, decoded) + _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) + case "/dav": + t.Fatal("OpenList username/password upload should use API, not WebDAV") + default: + t.Fatalf("unexpected openlist path %s", r.URL.Path) + } + })) + defer openlist.Close() + + _, storage := newStorageUploadTestService(t) + if _, err := storage.Save(t.Context(), StorageInput{ + Type: "openlist", + Config: map[string]any{ + "server": openlist.URL, + "username": "alice", + "password": "secret", + }, + }); err != nil { + t.Fatal(err) + } + source := t.TempDir() + if err := os.WriteFile(filepath.Join(source, "Movie.2026.mkv"), []byte("movie"), 0o644); err != nil { + t.Fatal(err) + } + res, err := storage.UploadLocal(t.Context(), CloudUploadInput{ + Type: "openlist", + SourcePath: source, + DestPath: "/OpenList", + Recursive: true, + }) + if err != nil { + t.Fatalf("upload local: %v", err) + } + if !loginSeen { + t.Fatal("expected OpenList API login") + } + if res.Uploaded != 1 || len(uploaded) != 1 || uploaded[0] != "/OpenList/Movie.2026.mkv" { + t.Fatalf("result = %+v uploaded=%#v", res, uploaded) + } +} + func TestStorageConfigOpenListHTTPSAgainstHTTPHint(t *testing.T) { openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"code":200}`)) diff --git a/web/src/pages/StorageConfigPage.tsx b/web/src/pages/StorageConfigPage.tsx index 2f3595e..3c1f39f 100644 --- a/web/src/pages/StorageConfigPage.tsx +++ b/web/src/pages/StorageConfigPage.tsx @@ -113,11 +113,11 @@ const FIELD_DEFS: Record - OpenList 默认端口常见为 5244,WebDAV 地址通常是 http://host:5244/dav/。如果未配置 HTTPS 反代,请不要填写 https://,否则会出现 “server gave HTTP response to HTTPS client”。 + OpenList 优先使用服务地址 + 用户名密码/Token 调用 API 进行浏览、挂载、转存和获取播放直链;WebDAV URL 只是兼容备用。默认端口常见为 5244,未配置 HTTPS 反代时请填写 http://。

)} {type === 'clouddrive2' && (