package cloud import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" ) // errOpenListAPITokenExpired 标记 OpenList 返回 401(登录 token 已失效): // 调用方收到后应清缓存重登一次再重试原请求。 var errOpenListAPITokenExpired = errors.New("openlist api token expired") // openListAPITokenCacheTTL 登录 token 缓存有效期(OpenList 默认签发 48h JWT, // 这里保守取 30 分钟,过期自动重新登录)。 const openListAPITokenCacheTTL = 30 * time.Minute // doWithOpenListAPIToken 获取 OpenList API token 后执行 fn;若请求命中 401 // (登录 token 失效)则清缓存重登一次并重试,避免一次 token 轮换导致整批请求失败。 func doWithOpenListAPIToken[T any](ctx context.Context, p *cloudDrive2Provider, fn func(token string) (T, error)) (T, error) { var zero T token, err := p.openListAPIToken(ctx) if err != nil { return zero, err } result, err := fn(token) if err == nil || !errors.Is(err, errOpenListAPITokenExpired) { return result, err } p.invalidateOpenListAPIToken() token, err = p.openListAPIToken(ctx) if err != nil { return zero, err } return fn(token) } func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) ([]FileEntry, error) { return doWithOpenListAPIToken(ctx, p, func(token string) ([]FileEntry, error) { return p.listOpenListAPIWithToken(ctx, dir, token) }) } func (p *cloudDrive2Provider) listOpenListAPIWithToken(ctx context.Context, dir, token string) ([]FileEntry, error) { 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 == http.StatusUnauthorized { return nil, errOpenListAPITokenExpired } 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) { return doWithOpenListAPIToken(ctx, p, func(token string) (*DirectLink, error) { return p.resolveOpenListAPIDirectWithToken(ctx, fileRef, token) }) } func (p *cloudDrive2Provider) resolveOpenListAPIDirectWithToken(ctx context.Context, fileRef, token string) (*DirectLink, error) { 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 == http.StatusUnauthorized { return nil, errOpenListAPITokenExpired } 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 != "") } // openListAPIToken 返回 OpenList API 访问令牌: // - 配置了静态 token 时直接使用(构造后只读,无并发问题); // - 否则用用户名密码登录,并在缓存有效期内单飞复用——8 个同步 worker 并发时 // 只会有一个 goroutine 真正执行登录,避免登录风暴;登录 token 的写入受 tokenMu 保护。 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 } p.tokenMu.Lock() defer p.tokenMu.Unlock() if p.loginToken != "" && time.Since(p.loginTokenSeen) < openListAPITokenCacheTTL { return p.loginToken, nil } token, err := p.openListAPILogin(ctx) if err != nil { return "", err } p.loginToken = token p.loginTokenSeen = time.Now() return token, nil } // invalidateOpenListAPIToken 清除登录 token 缓存(收到 401 时调用,下次请求重新登录)。 func (p *cloudDrive2Provider) invalidateOpenListAPIToken() { p.tokenMu.Lock() p.loginToken = "" p.loginTokenSeen = time.Time{} p.tokenMu.Unlock() } // openListAPILogin 调用 OpenList /api/auth/login 换取登录 token。 func (p *cloudDrive2Provider) openListAPILogin(ctx context.Context) (string, error) { 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) } 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 }