diff --git a/internal/service/reader/audio_transcode.go b/internal/service/reader/audio_transcode.go index 91e6d99..2929c91 100644 --- a/internal/service/reader/audio_transcode.go +++ b/internal/service/reader/audio_transcode.go @@ -286,8 +286,12 @@ func (s *ReaderService) transcodeInputHeaders(ctx context.Context, bookID, sourc if ck := state.CookieForRequest(source); ck != "" { headers["Cookie"] = ck } + // 默认 Referer 同上:聚合类书源的 origin 是显示名,拼出来的 Referer 非法, + // 会被音源/图床判盗链。只在 origin 是真正的 http(s) 地址时才补。 if headers["Referer"] == "" { - headers["Referer"] = strings.TrimSuffix(book.Origin, "/") + "/" + if referer := sourceReferer(book.Origin); referer != "" { + headers["Referer"] = referer + } } return headers } diff --git a/internal/service/reader/proxy_test.go b/internal/service/reader/proxy_test.go index 8ce2abb..bc1e892 100644 --- a/internal/service/reader/proxy_test.go +++ b/internal/service/reader/proxy_test.go @@ -1,10 +1,16 @@ package reader import ( + "io" + "net/http" + "net/http/httptest" "net/url" + "sync/atomic" "testing" + "time" "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) // TestProxyURLSignRoundTrip 验证媒体代理签名:往返还原 + 防篡改。 @@ -41,3 +47,88 @@ func TestProxyURLSignRoundTrip(t *testing.T) { t.Fatalf("double wrap: %q", again) } } + +// TestSourceReferer 验证默认 Referer 只在 origin 是真正的 http(s) 书源地址时 +// 才生成。聚合类书源的 origin 是显示名(如「光遇聚合」),拿它当 Referer 会被 +// 图床判盗链,返回一张「请到本网站阅读」的占位图。 +func TestSourceReferer(t *testing.T) { + cases := []struct { + name string + origin string + want string + }{ + {"普通书源地址", "https://www.example.com", "https://www.example.com/"}, + {"带尾斜杠", "https://www.example.com/", "https://www.example.com/"}, + {"带子路径", "https://www.example.com/site", "https://www.example.com/site/"}, + {"聚合书源显示名", "光遇聚合", ""}, + {"空值", "", ""}, + {"只有空白", " ", ""}, + {"非 http 协议", "ftp://example.com", ""}, + {"没有主机", "https://", ""}, + } + for _, c := range cases { + if got := sourceReferer(c.origin); got != c.want { + t.Errorf("%s: sourceReferer(%q) = %q,期望 %q", c.name, c.origin, got, c.want) + } + } +} + +// TestFetchMediaRetriesTransientFailure 验证图床偶发 403(并发限流)会被重试掉, +// 否则漫画一屏并发取图时总有几页留成破图。 +func TestFetchMediaRetriesTransientFailure(t *testing.T) { + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if atomic.AddInt32(&hits, 1) < 3 { + w.WriteHeader(http.StatusForbidden) + return + } + w.Header().Set("Content-Type", "image/webp") + _, _ = w.Write([]byte("real-image-bytes")) + })) + defer srv.Close() + + s := &ReaderService{http: &http.Client{Timeout: 10 * time.Second}} + book := &model.ReaderBook{Origin: srv.URL} + + resp, err := s.FetchMedia(t.Context(), book, srv.URL+"/1.webp", "") + if err != nil { + t.Fatalf("FetchMedia 失败: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("状态码 = %d,期望 200(前两次 403 应被重试掉)", resp.StatusCode) + } + data, _ := io.ReadAll(resp.Body) + if string(data) != "real-image-bytes" { + t.Fatalf("body = %q", data) + } + if got := atomic.LoadInt32(&hits); got != 3 { + t.Fatalf("上游请求次数 = %d,期望 3", got) + } +} + +// TestFetchMediaDoesNotRetryNotFound 验证确定性的 4xx(如图片不存在)不做无谓重试, +// 避免首屏白白多等两轮退避。 +func TestFetchMediaDoesNotRetryNotFound(t *testing.T) { + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + s := &ReaderService{http: &http.Client{Timeout: 10 * time.Second}} + book := &model.ReaderBook{Origin: srv.URL} + + resp, err := s.FetchMedia(t.Context(), book, srv.URL+"/missing.webp", "") + if err != nil { + t.Fatalf("FetchMedia 失败: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("状态码 = %d,期望 404", resp.StatusCode) + } + if got := atomic.LoadInt32(&hits); got != 1 { + t.Fatalf("上游请求次数 = %d,期望 1(404 不该重试)", got) + } +} diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go index 6dd5a76..397ddb6 100644 --- a/internal/service/reader/reader.go +++ b/internal/service/reader/reader.go @@ -1222,49 +1222,127 @@ func (s *ReaderService) VerifyProxyURL(bookID, encoded, sig string) (string, err // FetchMedia 服务端拉取媒体资源(携带书源级请求头与 Referer,支持 Range 透传)。 // 调用方负责关闭 resp.Body。 func (s *ReaderService) FetchMedia(ctx context.Context, book *model.ReaderBook, rawURL, rangeHeader string) (*http.Response, error) { - httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) - if err != nil { - return nil, err - } - for k, v := range helper.HTTPHeaderPresets() { - httpReq.Header.Set(k, v) - } - // 媒体流同样交给 net/http 管压缩:否则压缩过的资源会以原始字节透传给 - // 播放器/图片标签,表现为「打不开」。Range 请求服务端通常不压缩, - // 解压后 resp 会去掉 Content-Length/Content-Encoding,透传逻辑不受影响。 - helper.StripAcceptEncoding(httpReq.Header) - // 书源级请求头 - if s.repo != nil { - if found, findErr := s.repo.GetSourceByURL(ctx, book.Origin); findErr == nil && found != nil && found.Header != "" { - var headers map[string]any - if json.Unmarshal([]byte(found.Header), &headers) == nil { - for k, v := range headers { - httpReq.Header.Set(k, fmt.Sprintf("%v", v)) + // 请求构造抽成闭包:http.Request 不可复用,重试时必须重建。 + buildRequest := func() (*http.Request, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + for k, v := range helper.HTTPHeaderPresets() { + httpReq.Header.Set(k, v) + } + // 媒体流同样交给 net/http 管压缩:否则压缩过的资源会以原始字节透传给 + // 播放器/图片标签,表现为「打不开」。Range 请求服务端通常不压缩, + // 解压后 resp 会去掉 Content-Length/Content-Encoding,透传逻辑不受影响。 + helper.StripAcceptEncoding(httpReq.Header) + // 书源级请求头 + if s.repo != nil { + if found, findErr := s.repo.GetSourceByURL(ctx, book.Origin); findErr == nil && found != nil && found.Header != "" { + var headers map[string]any + if json.Unmarshal([]byte(found.Header), &headers) == nil { + for k, v := range headers { + httpReq.Header.Set(k, fmt.Sprintf("%v", v)) + } } } } - } - // 登录态:登录类书源的漫画/音频资源同样需要 Cookie 与 loginHeader 才能取到。 - if s.repo != nil { - state := s.newSourceState(ctx, book.Origin) - for k, v := range state.LoginHeaderMap() { - if !strings.EqualFold(k, "cookie") && httpReq.Header.Get(k) == "" { - httpReq.Header.Set(k, v) + // 登录态:登录类书源的漫画/音频资源同样需要 Cookie 与 loginHeader 才能取到。 + if s.repo != nil { + state := s.newSourceState(ctx, book.Origin) + for k, v := range state.LoginHeaderMap() { + if !strings.EqualFold(k, "cookie") && httpReq.Header.Get(k) == "" { + httpReq.Header.Set(k, v) + } + } + if httpReq.Header.Get("Cookie") == "" { + if ck := state.CookieForRequest(rawURL); ck != "" { + httpReq.Header.Set("Cookie", ck) + } } } - if httpReq.Header.Get("Cookie") == "" { - if ck := state.CookieForRequest(rawURL); ck != "" { - httpReq.Header.Set("Cookie", ck) + // 默认 Referer 只能用「真正的 http(s) 书源地址」。 + // legado 的 origin 对普通书源是 bookSourceUrl,但聚合类书源(如「光遇聚合」) + // 的 origin 是个显示名,拼出来的 Referer 非法,会被图床判定为盗链并 + // 301 到一张「请到本网站阅读」的占位图 —— 表现为漫画每一页都是同一张提示图。 + // 这种情况下宁可不发 Referer(实测不带 Referer 能拿到原图);书源 header + // 里自己声明的 Referer 优先级更高,不受这里影响。 + if httpReq.Header.Get("Referer") == "" { + if referer := sourceReferer(book.Origin); referer != "" { + httpReq.Header.Set("Referer", referer) } } + if rangeHeader != "" { + httpReq.Header.Set("Range", rangeHeader) + } + return httpReq, nil } - if httpReq.Header.Get("Referer") == "" && book.Origin != "" { - httpReq.Header.Set("Referer", strings.TrimSuffix(book.Origin, "/")+"/") + + var lastErr error + for attempt := 1; attempt <= mediaFetchAttempts; attempt++ { + if attempt > 1 { + // 图床在高并发拉取时会偶发 403/429(实测同一张图稍后重试即可成功)。 + // 漫画一屏会并发取多张,零星失败就会在页面上留几个破图,因此做少量退避重试。 + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(mediaFetchRetryDelay * time.Duration(attempt-1)): + } + } + httpReq, err := buildRequest() + if err != nil { + return nil, err + } + resp, err := s.http.Do(httpReq) + if err != nil { + lastErr = err + continue + } + if attempt == mediaFetchAttempts || !mediaRetryableStatus(resp.StatusCode) { + return resp, nil + } + // 可重试的状态码:读完并关闭响应体再试,避免连接泄漏。 + lastErr = fmt.Errorf("upstream returned %s", resp.Status) + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) + _ = resp.Body.Close() } - if rangeHeader != "" { - httpReq.Header.Set("Range", rangeHeader) + return nil, lastErr +} + +const ( + // mediaFetchAttempts 媒体拉取最多尝试次数(含首次)。 + mediaFetchAttempts = 3 + // mediaFetchRetryDelay 重试退避基数,实际等待为 (attempt-1) 倍。 + mediaFetchRetryDelay = 400 * time.Millisecond +) + +// mediaRetryableStatus 判断上游状态码是否值得重试。 +// 403/429 是图床并发限流的表现,5xx 是上游抖动。 +func mediaRetryableStatus(code int) bool { + switch code { + case http.StatusForbidden, http.StatusRequestTimeout, http.StatusTooManyRequests, + http.StatusInternalServerError, http.StatusBadGateway, + http.StatusServiceUnavailable, http.StatusGatewayTimeout: + return true + default: + return false } - return s.http.Do(httpReq) +} + +// sourceReferer 把书源的 origin 转成可用的默认 Referer。 +// +// 只有 origin 本身是合法的 http(s) 地址时才使用;聚合类书源的 origin 是 +// 「显示名」,用它当 Referer 会被图床当成盗链(见 FetchMedia 中的注释), +// 此时返回空串表示不发 Referer。 +func sourceReferer(origin string) string { + origin = strings.TrimSpace(origin) + if origin == "" { + return "" + } + u, err := url.Parse(origin) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return "" + } + return strings.TrimSuffix(origin, "/") + "/" } // RewritePlaylist 重写 m3u8 播放列表:分片与密钥地址改写为签名代理地址。 diff --git a/web/src/pages/reader/ReaderComic.tsx b/web/src/pages/reader/ReaderComic.tsx index f91fd59..b686258 100644 --- a/web/src/pages/reader/ReaderComic.tsx +++ b/web/src/pages/reader/ReaderComic.tsx @@ -16,29 +16,57 @@ interface ReaderComicProps { onScrolled: () => void } -function ComicImage({ src, theme }: { src: string; theme: { bg: string; text: string; accent: string } }) { +function ComicImage({ + src, + theme, + fit = false, +}: { + src: string + theme: { bg: string; text: string; accent: string } + // fit:单页模式用。整页缩放至视口内,长图不再被 overflow-hidden 的容器裁掉。 + fit?: boolean +}) { const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading') + + // 换章后 src 变了要重新回到 loading:否则上一章加载失败的第 N 张会一直 + // 显示「图片加载失败」,而 key 不变(滚动模式按序号做 key)不会重挂载。 + useEffect(() => { + setState('loading') + }, [src]) + + // 必须始终留在渲染树里(不能 display:none):浏览器不会去拉取 + // display:none 的 loading="lazy" 图片,onLoad 就永远不会触发,于是更没机会 + // 脱离 loading —— 之前用 hidden 藏图就死在这里,漫画只剩一个转圈。 + // 改成图片正常参与布局,未加载时用 minHeight 占位,转圈/失败信息盖在上层。 return ( -
+
+ setState('ok')} + onError={() => setState('error')} + className={fit ? 'block max-h-full w-auto max-w-full object-contain' : 'block w-full'} + style={state === 'ok' ? undefined : { minHeight: '10rem' }} + /> {state === 'loading' && ( -
+
)} - {state === 'error' ? ( -
+ {state === 'error' && ( +
图片加载失败
- ) : ( - setState('ok')} - onError={() => setState('error')} - className={`block w-full ${state === 'loading' ? 'hidden' : ''}`} - style={{ backgroundColor: theme.bg }} - /> )}
) @@ -83,7 +111,7 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o const idx = Math.min(Math.max(page, 0), images.length - 1) return (
- +
) }