mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 00:44:30 +08:00
7e5e67697e
Implement a new GuangYaPan cloud drive integration across the backend, admin UI, playback proxy, and Spider91 migration flow. Backend changes:\n- Add a GuangYaPan drive driver with token refresh, QR/device login support, directory listing, stream link resolution, directory creation, rename/delete operations, OSS multipart upload, and upload task polling.\n- Register GuangYaPan as a supported storage kind in configuration, catalog normalization, admin APIs, public drive labels, and 302 playback redirects.\n- Allow Spider91 crawler uploads to target GuangYaPan through a dedicated migration adapter.\n- Add scan, thumbnail, preview, and fingerprint cooldown handling for GuangYaPan based on explicit HTTP status codes, Retry-After values, and structured provider codes instead of natural-language message matching.\n- Tighten existing provider cooldown detectors so OneDrive, Google Drive, 115, PikPak, 123pan, Wopan, and media workers avoid treating arbitrary response text as a rate-limit signal.\n- Keep large videos eligible for preview generation unless the user disables preview generation. Admin and tooling changes:\n- Add GuangYaPan as a selectable drive type with QR login UI and token/root-path credential fields.\n- Add crawler upload target support for GuangYaPan in the admin UI.\n- Add drive branding, labels, metadata display, and docs/config examples for GuangYaPan.\n- Include a standalone GuangYaPan QR login helper script for manual credential acquisition. Tests:\n- Add GuangYaPan driver, QR login, proxy, admin API, crawler upload target, fingerprint, cooldown, and form coverage.\n- Update rate-limit tests to assert that message-only throttling text no longer starts cooldowns.\n- Cover explicit HTTP status parsing through shared drive helper tests.
171 lines
5.3 KiB
Go
171 lines
5.3 KiB
Go
package p115
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/sha1"
|
||
"encoding/hex"
|
||
"errors"
|
||
"io"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/video-site/backend/internal/drives"
|
||
)
|
||
|
||
func TestIsTransient115ListError(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
err error
|
||
want bool
|
||
}{
|
||
{name: "nil", err: nil, want: false},
|
||
{name: "blocked html without status context", err: errors.New(`<!doctype html><title>405</title>Sorry, your request has been blocked as it may cause potential threats to the server's security.`), want: false},
|
||
{name: "chinese waf", err: errors.New("很抱歉,由于您访问的URL有可能对网站造成安全威胁,您的访问被阻断。"), want: false},
|
||
{name: "status 405", err: errors.New("request failed with status: 405"), want: true},
|
||
{name: "rate limit", err: errors.New("429 too many requests"), want: true},
|
||
{name: "regular auth error", err: errors.New("invalid credential"), want: false},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
if got := isTransient115ListError(tc.err); got != tc.want {
|
||
t.Fatalf("isTransient115ListError(%v) = %v, want %v", tc.err, got, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestWrap115StreamTransientError(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
err error
|
||
wantRateLimit bool
|
||
}{
|
||
{name: "unexpected", err: errors.New("unexpected error"), wantRateLimit: false},
|
||
{name: "405 blocked", err: errors.New("405 request has been blocked"), wantRateLimit: true},
|
||
{name: "429", err: errors.New("429 too many requests"), wantRateLimit: true},
|
||
{name: "blocked", err: errors.New("blocked by waf"), wantRateLimit: false},
|
||
{name: "auth", err: errors.New("invalid credential"), wantRateLimit: false},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
got := wrap115StreamTransientError("115 get file", tc.err)
|
||
var rateLimit *drives.RateLimitError
|
||
isRateLimit := errors.As(got, &rateLimit)
|
||
if isRateLimit != tc.wantRateLimit {
|
||
t.Fatalf("rate limit = %v, want %v; err=%v", isRateLimit, tc.wantRateLimit, got)
|
||
}
|
||
if !strings.Contains(got.Error(), "115 get file") {
|
||
t.Fatalf("err = %v, want operation prefix", got)
|
||
}
|
||
if tc.wantRateLimit {
|
||
if rateLimit.Provider != "p115" {
|
||
t.Fatalf("provider = %q, want p115", rateLimit.Provider)
|
||
}
|
||
if rateLimit.RetryAfter != 10*time.Minute {
|
||
t.Fatalf("retry after = %s, want 10m", rateLimit.RetryAfter)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestBufferAndHashSha1 验证 bufferAndHashSha1:
|
||
//
|
||
// - 把 reader 的全部字节落到 tmp 文件
|
||
// - SHA1 与标准库一致(HEX 大写)
|
||
// - declaredSize=0 时不校验,>0 时严格校验
|
||
// - 调用方拿到的 *os.File 可以 Seek 回 0 重新读出原文(OSS SDK 上传需要)
|
||
func TestBufferAndHashSha1(t *testing.T) {
|
||
body := []byte("hello-115-upload-test")
|
||
want := sha1.Sum(body)
|
||
wantHex := strings.ToUpper(hex.EncodeToString(want[:]))
|
||
|
||
t.Run("declared size matches", func(t *testing.T) {
|
||
tmp, gotHex, n, err := bufferAndHashSha1(bytes.NewReader(body), int64(len(body)))
|
||
if err != nil {
|
||
t.Fatalf("bufferAndHashSha1 returned error: %v", err)
|
||
}
|
||
defer cleanup(tmp)
|
||
if gotHex != wantHex {
|
||
t.Errorf("sha1 = %s, want %s", gotHex, wantHex)
|
||
}
|
||
if n != int64(len(body)) {
|
||
t.Errorf("written = %d, want %d", n, len(body))
|
||
}
|
||
// Seek 回 0,应能读出原文
|
||
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||
t.Fatalf("seek: %v", err)
|
||
}
|
||
got, err := io.ReadAll(tmp)
|
||
if err != nil {
|
||
t.Fatalf("read tmp: %v", err)
|
||
}
|
||
if !bytes.Equal(got, body) {
|
||
t.Errorf("tmp content mismatch: got %q want %q", string(got), string(body))
|
||
}
|
||
})
|
||
|
||
t.Run("declared size mismatch returns error", func(t *testing.T) {
|
||
_, _, _, err := bufferAndHashSha1(bytes.NewReader(body), int64(len(body))+1)
|
||
if err == nil {
|
||
t.Fatal("expected size mismatch error, got nil")
|
||
}
|
||
})
|
||
|
||
t.Run("declared size zero is unchecked", func(t *testing.T) {
|
||
tmp, gotHex, n, err := bufferAndHashSha1(bytes.NewReader(body), 0)
|
||
if err != nil {
|
||
t.Fatalf("bufferAndHashSha1 returned error: %v", err)
|
||
}
|
||
defer cleanup(tmp)
|
||
if gotHex != wantHex {
|
||
t.Errorf("sha1 = %s, want %s", gotHex, wantHex)
|
||
}
|
||
if n != int64(len(body)) {
|
||
t.Errorf("written = %d, want %d", n, len(body))
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestUploadAndReportSha1RejectsInvalidArgs 检查空 reader / 空 name / 负 size 在
|
||
// 客户端未初始化前就被拒绝,避免下游 SDK 在错误参数下做异步初始化和真实网络调用。
|
||
func TestUploadAndReportSha1RejectsInvalidArgs(t *testing.T) {
|
||
d := New(Config{ID: "p115-test"})
|
||
// 注意:未调 Init,因此 d.client == nil,第一道防线就会拒绝。
|
||
|
||
cases := []struct {
|
||
name string
|
||
parentID string
|
||
fname string
|
||
body io.Reader
|
||
size int64
|
||
wantSubst string
|
||
}{
|
||
{name: "nil client", parentID: "0", fname: "x.mp4", body: bytes.NewReader([]byte("ok")), size: 2, wantSubst: "not initialized"},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
_, err := d.UploadAndReportSha1(context.Background(), c.parentID, c.fname, c.body, c.size)
|
||
if err == nil {
|
||
t.Fatalf("expected error, got nil")
|
||
}
|
||
if !strings.Contains(err.Error(), c.wantSubst) {
|
||
t.Fatalf("err = %v, want containing %q", err, c.wantSubst)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func cleanup(f *os.File) {
|
||
if f == nil {
|
||
return
|
||
}
|
||
_ = f.Close()
|
||
_ = os.Remove(f.Name())
|
||
}
|