prefer openlist api credentials

This commit is contained in:
ShukeBta
2026-06-13 20:28:23 +08:00
parent b2b43e58da
commit 4b2839d753
5 changed files with 237 additions and 18 deletions
+79 -1
View File
@@ -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) {
+1 -3
View File
@@ -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))
+74 -8
View File
@@ -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)
+77
View File
@@ -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}`))
+6 -6
View File
@@ -113,11 +113,11 @@ const FIELD_DEFS: Record<StorageType, { key: string; label: string; secret?: boo
{ key: 'token', label: 'Token', secret: true },
],
openlist: [
{ key: 'server', label: 'OpenList Server URL(API / 转存)', placeholder: 'http://NAS-IP:5244' },
{ key: 'token', label: 'OpenList Token(API 转存可选)', secret: true },
{ key: 'url', label: 'WebDAV URL(浏览/挂载)', placeholder: 'http://NAS-IP:5244/dav/' },
{ key: 'username', label: 'WebDAV 用户名' },
{ key: 'password', label: 'WebDAV 密码', secret: true },
{ key: 'server', label: 'OpenList 服务地址(API / 浏览 / 挂载 / 转存)', placeholder: 'http://NAS-IP:5244' },
{ key: 'username', label: 'OpenList 用户名' },
{ key: 'password', label: 'OpenList 密码', secret: true },
{ key: 'token', label: 'OpenList Token(可选,优先于用户名密码)', secret: true },
{ key: 'url', label: 'WebDAV URL(可选兼容备用)', placeholder: '通常不用填;需要备用时填 http://NAS-IP:5244/dav/' },
{ key: 'timeout_seconds', label: '请求超时秒数', placeholder: '120' },
],
webdav: [
@@ -373,7 +373,7 @@ function StorageUploadPanel({ type }: { type: StorageType }) {
)}
{type === 'openlist' && (
<p className="mb-3 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">
OpenList 默认端口常见为 5244,WebDAV 地址通常是 http://host:5244/dav/。如果未配置 HTTPS 反代,请不要填写 https://,否则会出现 “server gave HTTP response to HTTPS client”。
OpenList 优先使用服务地址 + 用户名密码/Token 调用 API 进行浏览、挂载、转存和获取播放直链;WebDAV URL 只是兼容备用。默认端口常见为 5244,未配置 HTTPS 反代时请填写 http://。
</p>
)}
{type === 'clouddrive2' && (