From 83ec494a364be7013edfc853fdbed3fb34745a37 Mon Sep 17 00:00:00 2001
From: truewhile <779943132@qq.com>
Date: Fri, 2 Oct 2026 13:14:29 +0800
Subject: [PATCH] =?UTF-8?q?bug=E5=A4=84=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
internal/service/reader/audio_transcode.go | 6 +-
internal/service/reader/proxy_test.go | 91 +++++++++++++
internal/service/reader/reader.go | 144 ++++++++++++++++-----
web/src/pages/reader/ReaderComic.tsx | 60 ++++++---
4 files changed, 251 insertions(+), 50 deletions(-)
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 (
-