bug处理

This commit is contained in:
truewhile
2026-10-02 13:14:29 +08:00
parent b8648f0049
commit 83ec494a36
4 changed files with 251 additions and 50 deletions
+5 -1
View File
@@ -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
}
+91
View File
@@ -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)
}
}
+111 -33
View File
@@ -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 播放列表:分片与密钥地址改写为签名代理地址。
+44 -16
View File
@@ -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])
// <img> 必须始终留在渲染树里(不能 display:none):浏览器不会去拉取
// display:none 的 loading="lazy" 图片,onLoad 就永远不会触发,于是更没机会
// 脱离 loading —— 之前用 hidden 藏图就死在这里,漫画只剩一个转圈。
// 改成图片正常参与布局,未加载时用 minHeight 占位,转圈/失败信息盖在上层。
return (
<div className="relative w-full">
<div
className={`relative flex w-full items-center justify-center ${fit ? 'h-full' : ''}`}
style={{ backgroundColor: theme.bg }}
>
<img
src={src}
loading="lazy"
alt=""
onLoad={() => 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' && (
<div className="flex h-64 w-full items-center justify-center" style={{ color: theme.text }}>
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center"
style={{ color: theme.text }}
>
<Loader2 className="animate-spin opacity-50" size={22} />
</div>
)}
{state === 'error' ? (
<div className="flex h-40 w-full items-center justify-center text-xs opacity-50" style={{ color: theme.text }}>
{state === 'error' && (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center text-xs opacity-60"
style={{ color: theme.text }}
>
图片加载失败
</div>
) : (
<img
src={src}
loading="lazy"
alt=""
onLoad={() => setState('ok')}
onError={() => setState('error')}
className={`block w-full ${state === 'loading' ? 'hidden' : ''}`}
style={{ backgroundColor: theme.bg }}
/>
)}
</div>
)
@@ -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 (
<div className="flex h-full items-center justify-center">
<ComicImage key={images[idx]} src={images[idx]} theme={theme} />
<ComicImage key={images[idx]} src={images[idx]} theme={theme} fit />
</div>
)
}