Compare commits

...

3 Commits

Author SHA1 Message Date
truewhile 43cfeabea1 bug处理 2026-10-16 14:54:06 +08:00
truewhile d112e8e4db 优化 2026-10-13 23:35:06 +08:00
truewhile 6604691c19 优化 2026-10-13 22:16:14 +08:00
18 changed files with 1336 additions and 82 deletions
@@ -0,0 +1,157 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// newDanmakuRemoteResolverTest 起一个假的远程 Emby(账号 + 挂载齐备),返回接好
// 弹幕解析器的 builder。
func newDanmakuRemoteResolverTest(t *testing.T, handler http.Handler) *serviceContainerBuilder {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
cfg := &config.Config{}
remote := NewEmbyRemoteService(cfg, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
remote.SetRuntimeCache(NewRuntimeCacheService(cfg, zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
if err := repos.StrmAccount.Create(t.Context(), &model.StrmAccount{
Base: model.Base{ID: "acct-1"},
Name: "远程 Emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}); err != nil {
t.Fatalf("create account: %v", err)
}
if err := repos.EmbyMount.Create(t.Context(), &model.EmbyMount{
Base: model.Base{ID: "mount-1"},
AccountID: "acct-1",
RemoteViewID: "view-1",
RemoteViewName: "新番连载",
CollectionType: "tvshows",
Enabled: true,
}); err != nil {
t.Fatalf("create mount: %v", err)
}
return &serviceContainerBuilder{
cfg: cfg,
log: zap.NewNop(),
repos: repos,
c: &Container{Cfg: cfg, Log: zap.NewNop(), Repo: repos, EmbyRemote: remote},
}
}
func danmakuResolverTestEpisode(id string) map[string]any {
return map[string]any{
"Id": id,
"Name": "第 1 集",
"Type": "Episode",
"Path": "/media/anime/PSYREN/" + id + ".mkv",
"MediaSources": []any{
map[string]any{"Id": id + "-src", "Container": "mkv", "Size": int64(1 << 20)},
},
}
}
// 目录类条目(整部剧/季)自身没有媒体源,拿它的 ID 去请求远程 /Videos/{id}/stream 只会
// 回 500,哈希层于是每次都白白跳过。这条用例保证弹幕解析器跟随 /api/stream 的规则先
// 落到第一集:哈希算的是真正要播的那条流。
func TestResolveDanmakuRemoteMediaUsesPlayableChildForContainer(t *testing.T) {
var requested []string
b := newDanmakuRemoteResolverTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requested = append(requested, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/emby/Users/uid-1/Items/series-100":
_ = json.NewEncoder(w).Encode(map[string]any{
"Id": "series-100", "Name": "PSYREN", "Type": "Series", "IsFolder": true,
})
case "/emby/Users/uid-1/Items":
if got := r.URL.Query().Get("ParentId"); got != "series-100" {
t.Errorf("ParentId = %q, want series-100", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{
{"Id": "season-1", "Type": "Season", "IndexNumber": 1},
danmakuResolverTestEpisode("ep-1"),
},
})
case "/emby/Users/uid-1/Items/ep-1":
_ = json.NewEncoder(w).Encode(danmakuResolverTestEpisode("ep-1"))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
media, streamURL, err := b.resolveDanmakuRemoteMedia(
context.Background(), EncodeEmbyRemoteID("mount-1", "series-100"))
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if media == nil {
t.Fatal("resolve returned no media metadata")
}
if !strings.Contains(streamURL, "/Videos/ep-1/stream") {
t.Fatalf("stream url = %q, want the playable child ep-1", streamURL)
}
if strings.Contains(streamURL, "series-100") {
t.Fatalf("stream url = %q, must not point at the container id (remote Emby answers 500)", streamURL)
}
if !strings.Contains(strings.Join(requested, " "), "/Items/ep-1") {
t.Fatalf("metadata must come from the playable child, requests = %v", requested)
}
}
// 本身可播的条目(单集/电影)不能被改写:直接取它自己的流地址。
func TestResolveDanmakuRemoteMediaKeepsPlayableItem(t *testing.T) {
b := newDanmakuRemoteResolverTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/emby/Users/uid-1/Items/ep-2" {
t.Errorf("unexpected upstream path %q", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(danmakuResolverTestEpisode("ep-2"))
}))
media, streamURL, err := b.resolveDanmakuRemoteMedia(
context.Background(), EncodeEmbyRemoteID("mount-1", "ep-2"))
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if media == nil {
t.Fatal("resolve returned no media metadata")
}
if !strings.Contains(streamURL, "/Videos/ep-2/stream") {
t.Fatalf("stream url = %q, want /Videos/ep-2/stream", streamURL)
}
}
// 不是远程伪装 ID 时直接报错,不去猜一个可以拉取的地址。
func TestResolveDanmakuRemoteMediaRejectsPlainID(t *testing.T) {
b := newDanmakuRemoteResolverTest(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("plain local ids must not reach the remote Emby")
w.WriteHeader(http.StatusNotFound)
}))
if _, _, err := b.resolveDanmakuRemoteMedia(context.Background(), "local-media-1"); err == nil {
t.Fatal("expected an error for a non remote-emby id")
}
}
+21 -6
View File
@@ -111,17 +111,20 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
// 仅 URL 解析层的 isPrivateHost 可被十进制/十六进制 IP、解析到
// 私网的域名与 DNS rebinding 绕过;在拨号层对最终连接 IP 做二次
// 校验(含重定向后的每条连接)堵住该旁路。
// 用户明确配置的远程挂载源(如内网 Emby)豁免该私网限制。
dialer := &net.Dialer{
//
// 用户明确配置的远程挂载源(如内网 Emby)与显式配置的服务(如
// MetaTube)豁免该私网限制。豁免必须在 DialContext 这一层判断:
// Control 回调收到的 address 已经是**解析后的 IP**,而放行名单里
// 存的是主机名(mebox-metatube-1、NAS 名),在 Control 里比较永远
// 不相等——那段分支对主机名条目是死代码,现场表现就是「名单里有
// 这个主机,回源依然被拒」。
guardDialer := &net.Dialer{
Timeout: 15 * time.Second,
Control: func(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
if proxy.isAllowedRemoteHost(host) {
return nil
}
ip := net.ParseIP(host)
if ip == nil {
return errors.New("image proxy: refusing non-IP dial target")
@@ -132,7 +135,19 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
return nil
},
}
transport.DialContext = dialer.DialContext
// 放行目标的专用拨号器:不再做私网校验,但仍然只连到请求里的那个
// 主机(豁免的是私网限制,不是「任意目标」)。
allowlistedDialer := &net.Dialer{Timeout: 15 * time.Second}
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, _, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
if proxy.isAllowedRemoteHost(host) {
return allowlistedDialer.DialContext(ctx, network, address)
}
return guardDialer.DialContext(ctx, network, address)
}
}
proxy.client = &http.Client{Timeout: 30 * time.Second, Transport: transport}
@@ -0,0 +1,193 @@
package service
import (
"context"
"net"
"net/url"
"sort"
"strings"
"github.com/truewhile/MeBox/internal/repository"
)
// 显式配置的服务地址也要进入图片代理的私网放行名单。
//
// 背景(现场 2026-10-16):MetaTube 刮削后端被配置成 docker 服务名
// `http://mebox-metatube-1:8081`,刮削结果里的 poster_url 就是这个地址。
// 图片代理的连接层 SSRF 校验只放行远程 Emby 挂载主机,于是每一次封面回源都
// 被拒(日志 warn: requests to private/internal hosts are not allowed),
// 前端长期只拿到占位图。
//
// 这里的取舍与 isPrivateHost 的注释一致:私网校验防的是「用户可控 URL 打到
// 内网」,而管理员显式配置的服务地址属于受信输入,和远程 Emby 挂载同级。
func configuredServiceImageHosts(values ...string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, raw := range values {
host := imageHostFromBaseURL(raw)
if host == "" {
continue
}
if _, ok := seen[host]; ok {
continue
}
seen[host] = struct{}{}
out = append(out, host)
}
sort.Strings(out)
return out
}
// internalServiceImageHosts 只保留指向内网/局域网的服务地址。
//
// 公网域名(image.tmdb.org、api.themoviedb.org 等)必须留在名单之外:放行后
// 图片代理会优先走直连客户端,而 GFW 环境恰恰需要先用 HTTP(S)_PROXY。把它们
// 加进来会让每张海报都白付一次直连失败。
func internalServiceImageHosts(values ...string) []string {
hosts := configuredServiceImageHosts(values...)
out := make([]string, 0, len(hosts))
for _, host := range hosts {
if isInternalServiceHost(host) {
out = append(out, host)
}
}
return out
}
// isInternalServiceHost 判断主机名是否只在内网可达:私网/回环/链路本地 IP、
// 单标签服务名(docker compose 服务名如 mebox-metatube-1),或常见的内部域名后缀。
func isInternalServiceHost(host string) bool {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" {
return false
}
if ip := net.ParseIP(host); ip != nil {
return isPrivateIP(ip)
}
// 单标签名(不含点)不是公网可解析的 FQDN,只可能是容器名/主机名。
if !strings.Contains(host, ".") {
return true
}
for _, suffix := range []string{".local", ".internal", ".lan", ".home.arpa", ".localhost"} {
if strings.HasSuffix(host, suffix) {
return true
}
}
return false
}
// imageHostFromBaseURL 从配置里的 base URL 取出小写主机名(不含端口)。
// 容忍缺少 scheme 的写法(`mebox-metatube-1:8081`),并拒绝非 http/https。
func imageHostFromBaseURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if !strings.Contains(raw, "://") {
raw = "http://" + raw
}
u, err := url.Parse(raw)
if err != nil {
return ""
}
switch strings.ToLower(u.Scheme) {
case "http", "https":
default:
return ""
}
return strings.ToLower(strings.TrimSpace(u.Hostname()))
}
// mergeImageProxyAllowedHosts 合并多组放行主机(远程 Emby 挂载 + 显式配置的
// 服务地址),去重时忽略大小写与端口。
func mergeImageProxyAllowedHosts(groups ...[]string) []string {
seen := make(map[string]struct{})
out := make([]string, 0)
for _, group := range groups {
for _, raw := range group {
host := strings.ToLower(strings.TrimSpace(raw))
if host == "" {
continue
}
if h, _, err := net.SplitHostPort(host); err == nil {
host = strings.ToLower(strings.TrimSpace(h))
}
if host == "" {
continue
}
if _, ok := seen[host]; ok {
continue
}
seen[host] = struct{}{}
out = append(out, host)
}
}
sort.Strings(out)
return out
}
// imageMintingSettingKeys 是「其取值会被拼进图片 URL」的设置项。
//
// 只收录这类设置:放行名单每多一条就是多一处 SSRF 例外,因此 app.server_url、
// strm.base_url 这些只出现在播放地址里的设置不在此列(它们由 streamURL 使用,
// 与 /api/img 无关)。
var imageMintingSettingKeys = []string{
"adult.scraper.metatube_server",
"adult.scraper.builtin_javdb_url",
"adult.scraper.builtin_javbus_url",
}
// imageMintingAPIProviders 是「其 base_url 会被拼进图片 URL」的 api_configs 行。
//
// 同样刻意收窄:api_configs 里每个 provider 都有一行出厂默认值,其中 metatube
// 默认指向 http://127.0.0.1:7700。把整张表都放行会让每次安装都无条件放开回环
// 地址,而只有真正用 MetaTube 刮削的部署才需要这个例外。
var imageMintingAPIProviders = []string{"metatube"}
// configuredServiceImageHostsFromRepo 从持久化配置里收集「会被写进图片 URL」的
// 内网服务地址。只在构造阶段调用一次,所以直接读库、忽略错误——读不到只是少
// 一条放行,不影响启动。
func configuredServiceImageHostsFromRepo(ctx context.Context, repos *repository.Container) []string {
if repos == nil {
return nil
}
values := make([]string, 0, 4)
if repos.Setting != nil {
for _, key := range imageMintingSettingKeys {
if val, err := repos.Setting.Get(ctx, key); err == nil && strings.TrimSpace(val) != "" {
values = append(values, val)
}
}
}
if repos.ApiConfig != nil {
// 用 List 而不是 FindByProvider:后者走 First(),缺行时 gorm 会打一条
// "record not found" 日志,而「没配置 MetaTube」是完全正常的状态。
if rows, err := repos.ApiConfig.List(ctx); err == nil {
for _, row := range rows {
if !imageMintingAPIProvider(row.Provider) {
continue
}
if strings.TrimSpace(row.BaseURL) != "" {
values = append(values, row.BaseURL)
}
// Extra 是逗号/分号分隔的备用源列表(见 api_config.go 的 adult 默认值)。
values = append(values, adultConfiguredBases(row.Extra)...)
}
}
}
return internalServiceImageHosts(values...)
}
func imageMintingAPIProvider(provider string) bool {
provider = strings.ToLower(strings.TrimSpace(provider))
for _, allowed := range imageMintingAPIProviders {
if provider == allowed {
return true
}
}
return false
}
@@ -0,0 +1,249 @@
package service
import (
"context"
"sort"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// 现场(2026-10-16):13 条媒体记录的 poster_url 指向
// http://mebox-metatube-1:8081/v1/images/primary/... ,而图片代理只放行
// 远程 Emby 挂载主机。MetaTube 是用户显式配置的刮削后端(settings 里的
// adult.scraper.metatube_server),它跑在 docker 私网里,于是每一次封面
// 回源都被连接层 SSRF 校验拒掉,日志刷 warn,前端长期只拿到占位图。
//
// 结论:显式配置的服务地址必须和远程 Emby 一样被加入放行名单。
func TestConfiguredServiceImageHostsExtractsHostnames(t *testing.T) {
got := configuredServiceImageHosts(
"http://mebox-metatube-1:8081",
"https://api.themoviedb.org/3",
"http://192.168.1.50:8096/emby",
)
want := []string{"192.168.1.50", "api.themoviedb.org", "mebox-metatube-1"}
if len(got) != len(want) {
t.Fatalf("hosts = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("hosts = %v, want %v", got, want)
}
}
}
func TestConfiguredServiceImageHostsNormalizesAndDedupes(t *testing.T) {
got := configuredServiceImageHosts(
" HTTP://MeBox-MetaTube-1:8081 ",
"http://mebox-metatube-1:8081/other/path",
"mebox-metatube-1:8081",
)
if len(got) != 1 || got[0] != "mebox-metatube-1" {
t.Fatalf("hosts = %v, want exactly [mebox-metatube-1]", got)
}
}
func TestConfiguredServiceImageHostsSkipsUnusableValues(t *testing.T) {
got := configuredServiceImageHosts(
"",
" ",
"not a url at all",
"http://",
"://missing-scheme",
"/relative/path",
"ftp://files.example.com",
)
if len(got) != 0 {
t.Fatalf("hosts = %v, want none", got)
}
}
func TestConfiguredServiceImageHostsPreservesIPv6(t *testing.T) {
got := configuredServiceImageHosts("http://[fd00::5]:8081")
if len(got) != 1 || got[0] != "fd00::5" {
t.Fatalf("hosts = %v, want [fd00::5]", got)
}
}
func TestImageProxyAllowlistCombinesMountsAndConfiguredServices(t *testing.T) {
// 回归:合并后的名单必须同时覆盖远程 Emby 挂载与显式配置的服务,
// 否则两组图片里必有一组被 SSRF 校验挡住。
proxy := newUpstreamImageProxy(t)
proxy.SetAllowedRemoteHostsProvider(func() []string {
return mergeImageProxyAllowedHosts(
[]string{"192.168.1.50"},
configuredServiceImageHosts("http://mebox-metatube-1:8081"),
)
})
for _, host := range []string{"192.168.1.50", "mebox-metatube-1"} {
if !proxy.isAllowedRemoteHost(host) {
t.Fatalf("host %q must bypass the private-IP guard", host)
}
}
if proxy.isAllowedRemoteHost("evil.example.com") {
t.Fatal("unrelated hosts must stay blocked")
}
}
func TestMergeImageProxyAllowedHostsDedupesCaseInsensitively(t *testing.T) {
got := mergeImageProxyAllowedHosts(
[]string{"192.168.1.50:8096", "MeBox-MetaTube-1"},
[]string{"mebox-metatube-1", "", " ", "192.168.1.50"},
)
sort.Strings(got)
want := []string{"192.168.1.50", "mebox-metatube-1"}
if len(got) != len(want) {
t.Fatalf("merged = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("merged = %v, want %v", got, want)
}
}
}
// 公网上游必须留在名单之外:放行后图片代理会优先走直连,而 GFW 环境恰恰要靠
// HTTP(S)_PROXY 才能取到 image.tmdb.org 这类海报。只有内网地址才需要豁免。
func TestInternalServiceImageHostsKeepsOnlyInternalAddresses(t *testing.T) {
got := internalServiceImageHosts(
"http://mebox-metatube-1:8081",
"http://127.0.0.1:7700",
"http://192.168.1.50:8096",
"https://api.themoviedb.org/3",
"https://image.tmdb.org",
"https://api.bgm.tv",
"https://javdb.com",
"http://nas.local:7700",
"http://media.internal",
)
want := []string{
"127.0.0.1",
"192.168.1.50",
"mebox-metatube-1",
"media.internal",
"nas.local",
}
sort.Strings(want)
if len(got) != len(want) {
t.Fatalf("hosts = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("hosts = %v, want %v", got, want)
}
}
}
func TestIsInternalServiceHost(t *testing.T) {
internal := []string{
"mebox-metatube-1", "127.0.0.1", "10.1.2.3", "172.18.0.5",
"192.168.1.50", "fd00::5", "nas.local", "media.internal",
"box.lan", "db.home.arpa",
}
for _, host := range internal {
if !isInternalServiceHost(host) {
t.Errorf("isInternalServiceHost(%q) = false, want true", host)
}
}
external := []string{
"", "api.themoviedb.org", "image.tmdb.org", "www.javbus.com",
"pics.dmm.co.jp", "8.8.8.8", "example.com",
}
for _, host := range external {
if isInternalServiceHost(host) {
t.Errorf("isInternalServiceHost(%q) = true, want false", host)
}
}
}
// 端到端装配:从真实的 settings / api_configs 里读出内网服务地址。
// 现场数据是 settings.adult.scraper.metatube_server = http://mebox-metatube-1:8081,
// 而 api_configs.metatube.base_url 还是出厂默认的 http://127.0.0.1:7700。
func TestConfiguredServiceImageHostsFromRepoReadsSettingsAndAPIConfigs(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
ctx := context.Background()
if err := repos.Setting.Set(ctx, "adult.scraper.metatube_server", "http://mebox-metatube-1:8081"); err != nil {
t.Fatal(err)
}
if err := repos.Setting.Set(ctx, "adult.scraper.builtin_javdb_url", "https://javdb.com"); err != nil {
t.Fatal(err)
}
for _, row := range []model.APIConfig{
{Provider: "metatube", BaseURL: "http://127.0.0.1:7700", Enabled: true},
{Provider: "tmdb", BaseURL: "https://api.themoviedb.org/3", Enabled: true},
{Provider: "openai", BaseURL: "http://192.168.1.77:11434/v1", Enabled: true},
} {
if err := repos.ApiConfig.Create(ctx, &row); err != nil {
t.Fatal(err)
}
}
got := configuredServiceImageHostsFromRepo(ctx, repos)
// javdb.com / api.themoviedb.org 是公网地址,必须留在名单之外(否则图片
// 代理会改走直连,GFW 环境下反而取不到图);openai 的 base_url 虽然指向
// 内网,但它从不产出图片 URL,同样不应成为 SSRF 例外。
want := []string{"127.0.0.1", "mebox-metatube-1"}
sort.Strings(want)
if len(got) != len(want) {
t.Fatalf("hosts = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("hosts = %v, want %v", got, want)
}
}
}
// 放行名单是 SSRF 例外,必须收窄:只有「会产出图片 URL」的配置才入选。
func TestConfiguredServiceImageHostsFromRepoIgnoresUnrelatedInternalServices(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
ctx := context.Background()
// 只配置了内网的 OpenAI 兼容网关与内网下载器,没有任何图片来源。
if err := repos.Setting.Set(ctx, "app.server_url", "http://10.0.0.2:1314"); err != nil {
t.Fatal(err)
}
if err := repos.Setting.Set(ctx, "strm.base_url", "http://10.0.0.2:1314"); err != nil {
t.Fatal(err)
}
for _, row := range []model.APIConfig{
{Provider: "openai", BaseURL: "http://192.168.1.77:11434/v1", Enabled: true},
{Provider: "tmdb", BaseURL: "https://api.themoviedb.org/3", Enabled: true},
} {
if err := repos.ApiConfig.Create(ctx, &row); err != nil {
t.Fatal(err)
}
}
if got := configuredServiceImageHostsFromRepo(ctx, repos); len(got) != 0 {
t.Fatalf("hosts = %v, want none: unrelated internal services must not become SSRF exceptions", got)
}
}
func TestConfiguredServiceImageHostsFromRepoToleratesMissingRepos(t *testing.T) {
if got := configuredServiceImageHostsFromRepo(context.Background(), nil); len(got) != 0 {
t.Fatalf("hosts = %v, want none", got)
}
}
@@ -0,0 +1,84 @@
package service
import (
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
)
// hostAndPortOf 从测试服务器的 URL 里取出主机名与端口。
func hostAndPortOf(t *testing.T, raw string) (string, string) {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse upstream url: %v", err)
}
return u.Hostname(), u.Port()
}
// 现场(2026-10-16,生产容器内实测):放行名单里明明有 mebox-metatube-1,
// 回源仍然失败于
//
// image proxy: requests to private/internal hosts are not allowed
//
// 原因:连接层校验(dialer 的 Control 回调)拿到的 address 是**解析后的 IP**,
// 而名单里存的是主机名(ConfiguredRemoteHosts 与
// configuredServiceImageHostsFromRepo 都返回 u.Hostname()),两者永远不相等:
// 那段 isAllowedRemoteHost 分支对主机名条目是死代码。
//
// 远程 Emby 挂载用 IP 配置时看不出问题,用主机名(NAS 名、docker 服务名)配置
// 时同样中招。这个用例锁住「按主机名放行」这条路径。
func TestImageProxyDialsAllowlistedHostname(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(testJPEG)
}))
defer upstream.Close()
proxy := NewImageProxy(
&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}},
zap.NewNop(),
)
// 名单里放「主机名」而不是 IP —— 这正是生产里 mebox-metatube-1 的形态。
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{"localhost"} })
// 直接用该代理自己的 transport 拨号,才能命中连接层校验。
_, port := hostAndPortOf(t, upstream.URL)
resp, err := proxy.client.Get("http://localhost:" + port + "/poster.jpg")
if err != nil {
t.Fatalf("allowlisted hostname must be dialable, got: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
}
// 反向约束:未放行的主机名解析到私网地址时仍必须被拒。
// 这是连接层校验存在的理由(DNS rebinding),不能为了修上面那条而拆掉。
func TestImageProxyStillBlocksUnlistedHostnameResolvingToPrivateIP(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(testJPEG)
}))
defer upstream.Close()
proxy := NewImageProxy(
&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}},
zap.NewNop(),
)
// 名单为空:localhost 解析到回环地址,必须被拦。
proxy.SetAllowedRemoteHostsProvider(func() []string { return nil })
_, port := hostAndPortOf(t, upstream.URL)
resp, err := proxy.client.Get("http://localhost:" + port + "/poster.jpg")
if err == nil {
resp.Body.Close()
t.Fatal("a non-allowlisted hostname resolving to a private IP must be refused")
}
}
@@ -0,0 +1,152 @@
// 在真实 docker 网络里验证图片代理的私网放行名单修复。
//
// 现场(修复前):MetaTube 刮削结果里的 poster_url 指向
// http://mebox-metatube-1:8081/... ,而放行名单只含远程 Emby 挂载主机,
// 于是每一次封面回源都被连接层 SSRF 校验拒掉,前端长期只拿到占位图。
//
// 本文件用与 service_builder.initImageProxy 相同的装配方式复现那条路径。
// 真实回源那一步只在能解析到 MetaTube 服务名的环境里跑(生产容器网络);
// 开发机上会 Skip,因为那属于环境缺失,不是回归。
//
// 在生产机上运行的姿势(无需 Go 工具链):
//
// GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go test -c -o /tmp/verify.test ./internal/service
// scp /tmp/verify.test root@HOST:/tmp/ && ssh root@HOST \
// 'docker cp /tmp/verify.test mebox:/tmp/ && docker exec mebox /tmp/verify.test \
// -test.run TestImageProxyFetchesFromMetaTubeService -test.v'
package service
import (
"context"
"errors"
"net"
"path/filepath"
"strings"
"testing"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// 与生产环境一致的两个取值(取自 10.0.0.2 上真实的 settings / api_configs)。
const (
metaTubeServer = "http://mebox-metatube-1:8081"
metaTubeLegacy = "http://127.0.0.1:7700"
sampleImageURL = "http://mebox-metatube-1:8081/v1/images/primary/JAV321/dsam-15"
)
// newMetaTubeVerificationRepos 建一个带现场取值的临时库(不碰生产数据)。
func newMetaTubeVerificationRepos(t *testing.T) (*repository.Container, *ImageProxy) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
ctx := context.Background()
if err := repos.Setting.Set(ctx, "adult.scraper.metatube_server", metaTubeServer); err != nil {
t.Fatalf("seed setting: %v", err)
}
if err := repos.ApiConfig.Create(ctx, &model.APIConfig{
Provider: "metatube", BaseURL: metaTubeLegacy, Enabled: true,
}); err != nil {
t.Fatalf("seed metatube api config: %v", err)
}
// 一个公网上游,用来确认它没有混进放行名单。
if err := repos.ApiConfig.Create(ctx, &model.APIConfig{
Provider: "tmdb", BaseURL: "https://api.themoviedb.org/3", Enabled: true,
}); err != nil {
t.Fatalf("seed tmdb api config: %v", err)
}
cfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}
cfg.Secrets.JWTSecret = "verify-secret"
proxy := NewImageProxy(cfg, zap.NewNop())
// 与 service_builder.initImageProxy 相同的装配方式(无远程 Emby 挂载:
// 本次只验证「显式配置的服务地址」这条路径)。
proxy.SetAllowedRemoteHostsProvider(func() []string {
return mergeImageProxyAllowedHosts(
nil,
configuredServiceImageHostsFromRepo(ctx, repos),
)
})
return repos, proxy
}
// TestImageProxyAllowlistIncludesConfiguredMetaTube 确认内网服务地址被放行、
// 公网上游没有被放行。
func TestImageProxyAllowlistIncludesConfiguredMetaTube(t *testing.T) {
repos, proxy := newMetaTubeVerificationRepos(t)
t.Logf("allowlist from config: %v",
configuredServiceImageHostsFromRepo(context.Background(), repos))
for _, host := range []string{"mebox-metatube-1", "127.0.0.1"} {
if !proxy.isAllowedRemoteHost(host) {
t.Fatalf("host %q must be allowlisted (it is an explicitly configured service)", host)
}
}
if proxy.isAllowedRemoteHost("api.themoviedb.org") {
t.Fatal("public upstreams must stay off the allowlist so image fetches keep using the proxy")
}
}
// TestImageProxyReachesMetaTubeService 证明放行名单真的解除了私网拦截。
//
// 现场(修复前)回源必然失败于
//
// dial tcp 172.18.0.5:8081: image proxy: requests to private/internal hosts are not allowed
//
// 也就是连接根本没发生。修复后连接成功,失败只可能来自上游自己的 HTTP 状态
// (MetaTube 冷缓存时对未刮削过的番号返回 500)。
//
// 因此这个用例断言的不是「拿到 200」,而是**「不再是被私网校验拒绝」**:
// 后者才是本次修复的对象,前者取决于 MetaTube 缓存里有没有那条记录。
func TestImageProxyReachesMetaTubeService(t *testing.T) {
_, proxy := newMetaTubeVerificationRepos(t)
data, ctype, err := proxy.Fetch(context.Background(), sampleImageURL)
if err != nil {
if isMetaTubeUnreachable(err) {
t.Skipf("mebox-metatube-1 is not reachable from this environment: %v", err)
}
if isPrivateHostRejection(err) {
t.Fatalf("the allowlist did not take effect: %v", err)
}
// 连接已经建立,只是上游自己返回了错误状态(MetaTube 冷缓存)。
t.Logf("reached metaTube; upstream returned its own error: %v", err)
return
}
t.Logf("fetched %d bytes (content-type=%s)", len(data), ctype)
if len(data) == 0 {
t.Fatal("fetched an empty image body")
}
}
// isPrivateHostRejection 报告错误是否来自连接层的私网校验。
// 这正是本次修复必须消除的那一类失败。
func isPrivateHostRejection(err error) bool {
return err != nil && strings.Contains(err.Error(), "requests to private/internal hosts are not allowed")
}
// isMetaTubeUnreachable 区分「环境里没有这个服务」与「放行名单没生效」。
func isMetaTubeUnreachable(err error) bool {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return true
}
msg := strings.ToLower(err.Error())
for _, needle := range []string{"no such host", "server misbehaving", "i/o timeout", "connection refused"} {
if strings.Contains(msg, needle) {
return true
}
}
return false
}
+46 -22
View File
@@ -159,28 +159,41 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.Stream.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTargetWithUA)
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
if b.c.EmbyRemote != nil {
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
mountID, remoteID, ok := DecodeEmbyRemoteID(encodedID)
if !ok {
return nil, "", errors.New("invalid emby remote id")
}
mount, acct, err := b.c.EmbyRemote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
if err != nil {
return nil, "", err
}
return nil, "", errors.New("emby mount or account not found")
}
m, err := b.c.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
if err != nil || m == nil {
return nil, "", err
}
streamURL, _ := b.c.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
return m, streamURL, nil
})
b.c.Danmaku.SetRemoteMediaResolver(b.resolveDanmakuRemoteMedia)
}
}
// resolveDanmakuRemoteMedia 把远程 Emby 伪 ID 解析成弹幕识别要用的 Media 与直链 URL。
//
// 目录类条目(整部剧/季/目录)自身没有媒体源,拿它的 ID 去请求远程
// /Videos/{id}/stream 只会回 500(Unable to cast object of type 'Series' to type
// 'IHasMediaSources'),哈希层于是每次都在这里白白失败、退回按文件名搜索。所以先按
// 与 /api/stream 相同的规则回落到第一集:算出来的哈希对得上真正播放的那条流,元数据
// (时长、文件名)也才是播放中那一集的。
func (b *serviceContainerBuilder) resolveDanmakuRemoteMedia(ctx context.Context, encodedID string) (*model.Media, string, error) {
mountID, remoteID, ok := DecodeEmbyRemoteID(encodedID)
if !ok {
return nil, "", errors.New("invalid emby remote id")
}
mount, acct, err := b.c.EmbyRemote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
if err != nil {
return nil, "", err
}
return nil, "", errors.New("emby mount or account not found")
}
playable := b.c.EmbyRemote.PlayableRemoteID(ctx, acct, remoteID)
if strings.TrimSpace(playable) == "" {
playable = remoteID
}
m, err := b.c.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, playable)
if err != nil || m == nil {
return nil, "", err
}
streamURL, _ := b.c.EmbyRemote.WebStreamURL(ctx, acct, playable)
return m, streamURL, nil
}
func (b *serviceContainerBuilder) initAccessAndStorageServices() {
b.c.PlayProfiles = NewPlayProfileService(b.log, b.repos)
b.c.Permissions = NewPermissionService(b.log, b.repos)
@@ -240,10 +253,21 @@ func (b *serviceContainerBuilder) initIdentityServices() {
func (b *serviceContainerBuilder) initImageProxy() {
b.c.ImageProxy = NewImageProxy(b.cfg, b.log)
b.c.ImageProxy.SetLibraryRootsProvider(b.libraryRoots)
// 放行名单 = 远程 Emby 挂载主机 + 用户显式配置的内网服务地址。
//
// 只放行挂载主机是不够的:MetaTube 这类刮削后端会把「容器服务名」写进
// poster_url(现场:http://mebox-metatube-1:8081),连接层 SSRF 校验会把
// 每一次封面回源都拒掉,前端长期只拿到占位图。名单本身有 30s 缓存,
// 所以管理员改完设置无需重启即可生效。
b.c.ImageProxy.SetAllowedRemoteHostsProvider(func() []string {
var mounts []string
if b.c.EmbyRemote != nil {
mounts = b.c.EmbyRemote.ConfiguredRemoteHosts(context.Background())
}
services := configuredServiceImageHostsFromRepo(context.Background(), b.repos)
return mergeImageProxyAllowedHosts(mounts, services)
})
if b.c.EmbyRemote != nil {
b.c.ImageProxy.SetAllowedRemoteHostsProvider(func() []string {
return b.c.EmbyRemote.ConfiguredRemoteHosts(context.Background())
})
// 远程 Emby 的图片 URL 会带着签发时的 api_key 长期缓存/下发;轮换后
// URL 里的旧值必然过期。回源时改用账号当前 token,并在上游 401/403 时
// 重新登录一次再重试,封面图不再依赖人工改账号。
+21 -2
View File
@@ -2,7 +2,7 @@ import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '../stores/auth'
import { getActivePlayProfileId, getActivePlayProfilePinToken } from '../stores/playProfile'
import type { RefreshOutcome } from '../utils/authRefresh'
import { createTokenPreflight, type RefreshOutcome } from '../utils/authRefresh'
// Single axios instance used by every API helper. Adds the JWT to outgoing
// requests and refreshes it on 401. Only an explicit server-side rejection of
@@ -48,8 +48,27 @@ function isRefreshRequest(config?: InternalAxiosRequestConfig | null): boolean {
return Boolean(config?.url?.includes('/auth/refresh'))
}
// 请求发出前的令牌预检。本地持久化的 token 往往已经过期,而首屏有多个各自
// 独立的 useEffect 会并发发请求;不预检的话它们会一起拿到 401,控制台刷出
// 一整屏红字(现场:2026-10-16 12:49,7 个请求同时 401),用户还要等拦截器
// 刷新完才看到内容。预检把刷新提到请求之前,且刷新协调器本身是单飞的。
const preflightAccessToken = createTokenPreflight({
readAccessToken: () => useAuthStore.getState().token,
refreshSession: () => useAuthStore.getState().refreshSession(),
})
// Add auth token to requests
api.interceptors.request.use((config) => {
api.interceptors.request.use(async (config) => {
// 刷新请求自己绝不能走预检,否则会递归刷新。
if (!isRefreshRequest(config)) {
try {
await preflightAccessToken()
} catch {
// 会话已被服务端判定失效:直接失败,不发无意义的 401 请求。
// 清空会话与跳转登录页由刷新协调器和 401 拦截器负责,保持一致。
throw new axios.Cancel('session expired')
}
}
const token = useAuthStore.getState().token
if (token) {
config.headers = config.headers ?? {}
+4 -1
View File
@@ -102,7 +102,10 @@ export const danmakuAPI = {
api
.get<DanmakuFetchResult>(`/danmaku/${encodeURIComponent(mediaId)}`, {
params: { kw: options.kw || undefined, episodeId: options.episodeId || undefined },
timeout: 20_000,
// 服务端依次做「16MB 前缀哈希 → 上游匹配 → 评论库」三段串行请求,每段的上游
// 超时都是 20 秒。客户端要是也卡在 20 秒,就会在服务端正要降级返回(哈希超时
// 后改按文件名搜索)时把请求撤掉:用户看不到弹幕,服务端还记一条 404。
timeout: 45_000,
})
.then((r) => r.data),
+3 -28
View File
@@ -1,38 +1,13 @@
import { useEffect, useRef } from 'react'
import { useAuthStore } from '../stores/auth'
import { isAccessTokenStale } from '../utils/authRefresh'
// 前 5 次沿用原快速退避间隔;之后进入 60s 慢速重试并不再放弃,
// 服务重启或网络恢复后仍能自动重连(清理函数可随时取消定时器)。
const FAST_RECONNECT_ATTEMPTS = 5
const SLOW_RECONNECT_INTERVAL = 60_000
// token 过期前 30 秒即视为失效,给刷新留出余量。
const TOKEN_EXPIRY_SKEW_MS = 30_000
// decodeJwtExpiryMs 解析 JWT 的 exp 声明并换算成毫秒时间戳。
// 解析失败(非 JWT / 结构异常)返回 null,调用方据此放行重连。
function decodeJwtExpiryMs(token: string): number | null {
const segment = token.split('.')[1]
if (!segment) return null
try {
// JWT 使用 base64url 编码,需先还原字符集并补齐 padding。
const normalized = segment.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const claims = JSON.parse(atob(padded)) as { exp?: unknown }
return typeof claims.exp === 'number' ? claims.exp * 1000 : null
} catch {
return null
}
}
// isTokenExpired 判断 token 是否已过期或即将过期。
function isTokenExpired(token: string): boolean {
const expiry = decodeJwtExpiryMs(token)
if (expiry === null) return false
return Date.now() >= expiry - TOKEN_EXPIRY_SKEW_MS
}
// useWebSocket opens a single connection to /api/ws and dispatches every
// message to the supplied handler. Auto-reconnects with back-off while the
// auth token is present; after the fast retries are exhausted it keeps a
@@ -68,7 +43,7 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
const open = () => {
if (closed) return
if (isTokenExpired(token)) {
if (isAccessTokenStale(token)) {
refreshExpiredToken()
return
}
@@ -92,7 +67,7 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
ws.onclose = () => {
if (closed) return
// token 过期时不要继续退避重试,交给刷新流程处理。
if (isTokenExpired(token)) {
if (isAccessTokenStale(token)) {
refreshExpiredToken()
return
}
+79 -18
View File
@@ -37,7 +37,8 @@ import {
type SubtitleStylePreset,
} from '../utils/subtitleDisplay'
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMedia, type PlayerMode } from './playerPageModel'
import { classifyDirectPlayError, DIRECT_SEEK_GRACE_MS } from './directPlayError'
import { classifyDirectPlayError, directPlayFailureCopy, directStallTimeoutMs, DIRECT_SEEK_GRACE_MS } from './directPlayError'
import type { DirectPlayFailureReason } from './directPlayError'
import { canSeekDirectNow, DIRECT_SEEK_WAIT_MS } from './directPlaySeek'
import { apiErrorMessage } from './StrmManagePage'
import { PlayerTopBar } from './PlayerTopBar'
@@ -200,6 +201,13 @@ export function PlayerPage() {
// 必须在切换前先记下来,等直连 loadedmetadata 后再跳。
const pendingDirectStartRef = useRef(0)
const hlsSourceRef = useRef<'cloud' | 'local'>('local')
// 起播看门狗要等到 20 秒后才判定,判定时用 ref 读最新的「直连解码」开关,避免
// 把回调塞进播放 effect 的依赖里反复重建。
const directOnlyRef = useRef(false)
// 当前挂在画面上的「播放失败」横幅文案。这类提示描述的是「刚才那一下没起来」,
// 画面真正播起来之后必须撤掉:上游冷启动慢 10 秒就会先误报一次,而播放随后
// 是正常的,留在画面上就成了「编码不兼容」的假结论。
const transientPlayErrorRef = useRef('')
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
const [danmakuOpen, setDanmakuOpen] = useState(false)
@@ -519,6 +527,7 @@ export function PlayerPage() {
directRetryRef.current = false
retryingDirectRef.current = false
directSeekGraceUntilRef.current = 0
transientPlayErrorRef.current = ''
pendingDirectSeekRef.current = null
directSeekWaitCleanupRef.current?.()
directSeekWaitCleanupRef.current = null
@@ -553,6 +562,24 @@ export function PlayerPage() {
}
}, [])
/**
* 挂一条「播放起不来」的横幅。走这个入口的提示都会记下来,一旦画面真的开始
* 播放(playing)就被撤掉;切媒体、切线路时也会清空。信息类提示(例如「可能
* 只有画面没有声音」)不走这里,它们播起来之后依然成立。
*/
const showPlayFailure = useCallback((message: string) => {
transientPlayErrorRef.current = message
setPlayerError(message)
}, [])
const clearPlayFailure = useCallback(() => {
const stale = transientPlayErrorRef.current
if (!stale) return
transientPlayErrorRef.current = ''
// 只撤掉自己挂的那条:期间可能已经换了别的提示。
setPlayerError((prev) => (prev === stale ? '' : prev))
}, [])
const clearDirectSeekWait = useCallback(() => {
directSeekWaitCleanupRef.current?.()
directSeekWaitCleanupRef.current = null
@@ -742,6 +769,7 @@ export function PlayerPage() {
if (cancelled) return
setMedia(m)
setPlayerError('')
transientPlayErrorRef.current = ''
setLoadError('')
const isDirect = isDirectStreamMedia(m)
const auto = pickPlayerMode(m)
@@ -875,6 +903,13 @@ export function PlayerPage() {
// 加载统一播放能力:115 云端清晰度 + 本地 HLS 清晰度。
useEffect(() => {
if (!mediaId) return
// 远程 Emby 条目只存在于远程库里,本地 media 表没有它的伪 ID,问这个接口只会
// 拿到 404(控制台里就是一条红字);而它的播放方式恒为「远程直连、不转码」,
// 也没有任何可切换的档位。播放地址由 /api/stream/:id 负责解析。
if (isRemoteEmbyID(mediaId)) {
setPlaybackInfo(null)
return
}
let cancelled = false
mediaAPI
.playbackInfo(mediaId)
@@ -1145,24 +1180,30 @@ export function PlayerPage() {
const mediaRef = useRef(media)
useEffect(() => {
mediaRef.current = media
}, [media])
directOnlyRef.current = directOnly
}, [directOnly, media])
/**
* 直连失败后的统一回退:按当前源与设置决定切 115 云转码 / 本地转码 / 明确报错。
* error 分支与起播看门狗(armDirectStallWatchdog)共用同一套判定。
*
* reason:'stall' = 看门狗等到期还没数据;'error' = 媒体元素报了 error(重试过
* 一次仍然失败)。两者文案必须分开——超时的真实原因多半是上游慢,不是编码不兼容。
*/
const fallbackDirectPlay = useCallback(() => {
const fallbackDirectPlay = useCallback((reason: DirectPlayFailureReason = 'error') => {
if (modeRef.current !== 'direct') return
// 宽限期内又触发的迟到 fallback 定时器:直接丢掉。
if (Date.now() < directSeekGraceUntilRef.current) return
const current = ref.current
if (current && current.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return
if (isRemoteEmbyID(mediaRef.current?.id) || isDirectStreamMedia(mediaRef.current)) {
setPlayerError('直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。')
toast.error('直接播放失败,建议使用外部播放器')
const copy = directPlayFailureCopy('remote-emby', reason)
showPlayFailure(copy.banner)
toast.error(copy.toast)
} else if (directOnly) {
setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。')
toast.error('直接播放失败(客户端直连解码模式)')
const copy = directPlayFailureCopy('direct-only', reason)
showPlayFailure(copy.banner)
toast.error(copy.toast)
} else if (playbackInfo?.provider === 'cloud115') {
const preferred =
findPlaybackQualityById(playbackInfo, selectedQuality) ??
@@ -1180,7 +1221,7 @@ export function PlayerPage() {
switchToLocalHLS(current?.currentTime || 0)
}
} else if (hlsUnavailable) {
setPlayerError('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。')
showPlayFailure('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。')
toast.error('直接播放失败,HLS 转码不可用')
} else {
toast.error('直接播放失败,切换到 HLS 转码')
@@ -1192,23 +1233,25 @@ export function PlayerPage() {
playbackInfo,
selectedQuality,
setPlaybackMode,
showPlayFailure,
startCloudTranscode,
switchToLocalHLS,
])
/**
* 安排一次直连回退判定。STRM/115 直链冷启动比本地文件慢,给足缓冲时间再判失败。
* 只由 handleVideoError(媒体元素真的报了 error)触发,所以原因按 'error' 记。
*/
const scheduleDirectFallback = useCallback(() => {
clearFallbackTimer()
const fallbackDelayMs = isStrmMedia(mediaRef.current) ? 4000 : 1500
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, fallbackDelayMs)
fallbackTimerRef.current = setTimeout(() => fallbackDirectPlay('error'), fallbackDelayMs)
}, [clearFallbackTimer, fallbackDirectPlay])
// 看门狗定时器可能比一次渲染活得更久:始终调用最新一版回退逻辑,避免用
// 「起播那一刻」捕获的 playbackInfo(那时可能还是 null)去判定,把本该走
// 115 云转码的条目误判成本地转码。
const fallbackDirectPlayRef = useRef<() => void>(() => {})
const fallbackDirectPlayRef = useRef<(reason?: DirectPlayFailureReason) => void>(() => {})
useEffect(() => {
fallbackDirectPlayRef.current = fallbackDirectPlay
}, [fallbackDirectPlay])
@@ -1222,17 +1265,27 @@ export function PlayerPage() {
* 只能退出重进或手动切播放方式)。这里给每次直连起播挂一个兜底定时器:
* 到点仍是 readyState=0(连元数据都没解析到)就按失败处理;只要已经拿到
* metadata 就说明链路是通的,只是慢,交给 playing 事件收尾。
*
* 期限本身要按「失败之后还有没有补救动作」来定:能切 HLS 的源早点换线路对
* 用户有利;远程 Emby / 直连解码没有可退的线路,等不到数据时唯一的产出是
* 一条提示,所以必须容忍跨网上游的冷启动(见 directStallTimeoutMs)。
*/
const armDirectStallWatchdog = useCallback(() => {
clearFallbackTimer()
const timeoutMs = isStrmMedia(mediaRef.current) ? 12000 : 6000
const currentMedia = mediaRef.current
const canFallback = !isDirectStreamMedia(currentMedia) && !directOnlyRef.current
const timeoutMs = directStallTimeoutMs({
isStrm: isStrmMedia(currentMedia),
canFallback,
})
fallbackTimerRef.current = setTimeout(() => {
fallbackTimerRef.current = null
if (modeRef.current !== 'direct') return
if (Date.now() < directSeekGraceUntilRef.current) return
const current = ref.current
if (current && current.readyState >= HTMLMediaElement.HAVE_METADATA) return
fallbackDirectPlayRef.current()
// 这里只可能是「一直没数据」,按超时文案报,不要说成编码不兼容。
fallbackDirectPlayRef.current('stall')
}, timeoutMs)
}, [clearFallbackTimer])
@@ -1313,10 +1366,12 @@ export function PlayerPage() {
}
setHlsUnavailable(true)
if (playbackProvider === 'cloud115') {
setPlayerError('115 云端和本地转码均不可用,请检查账号授权或稍后重试。')
showPlayFailure('115 云端和本地转码均不可用,请检查账号授权或稍后重试。')
toast.error('115 云端和本地转码均不可用')
return
}
// 这条同时是「为什么可能没声音」的说明,播起来之后依然成立,
// 所以不走 showPlayFailure(不随 playing 撤掉)。
setPlayerError('HLS 转码不可用,正在尝试直接播放原始文件。若出现有画面无声音,通常是 MKV/AC3/EAC3 音轨需要配置本机 ffmpeg 转码为 AAC。')
toast.error('HLS 转码失败,尝试切换到直接播放')
setPlaybackMode('direct')
@@ -1337,14 +1392,14 @@ export function PlayerPage() {
void video.play().catch(() => undefined)
} else {
setHlsUnavailable(true)
setPlayerError('当前浏览器不支持 HLS,正在尝试直接播放。')
showPlayFailure('当前浏览器不支持 HLS,正在尝试直接播放。')
toast.error('当前浏览器不支持 HLS,降级到直接播放')
setPlaybackMode('direct')
}
}).catch(() => {
if (cancelled) return
setHlsUnavailable(true)
setPlayerError('HLS 播放组件加载失败,正在尝试直接播放。')
showPlayFailure('HLS 播放组件加载失败,正在尝试直接播放。')
setPlaybackMode('direct')
})
} else {
@@ -1393,7 +1448,11 @@ export function PlayerPage() {
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
}
}
const onPlaying = () => clearFallbackTimer()
const onPlaying = () => {
clearFallbackTimer()
// 画面真的动了:之前挂的「起不来」提示一律作废。
clearPlayFailure()
}
video.addEventListener('playing', onPlaying)
return () => {
cancelled = true
@@ -1404,6 +1463,7 @@ export function PlayerPage() {
activeBurnedSubtitleStream,
armDirectStallWatchdog,
clearFallbackTimer,
clearPlayFailure,
cloudReloadNonce,
cloudWaiting,
hlsSource,
@@ -1416,6 +1476,7 @@ export function PlayerPage() {
seekStrmDirectTo,
selectedQuality,
setPlaybackMode,
showPlayFailure,
switchToLocalHLS,
teardownHls,
vr360DirectProxy,
@@ -2253,7 +2314,7 @@ export function PlayerPage() {
const handleVideoError = useCallback(() => {
const video = ref.current
if (mode !== 'direct') {
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
showPlayFailure('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
toast.error('视频播放失败,请检查文件是否存在')
return
}
@@ -2300,7 +2361,7 @@ export function PlayerPage() {
}
scheduleDirectFallback()
}, [mediaId, mode, scheduleDirectFallback, seekStrmDirectTo])
}, [mediaId, mode, scheduleDirectFallback, seekStrmDirectTo, showPlayFailure])
const changeSubtitlePosition = useCallback((nextPosition: SubtitlePosition) => {
setSubtitlePosition(nextPosition)
+59
View File
@@ -6,6 +6,65 @@ const HAVE_CURRENT_DATA = 2
/** 程序化续播/跳转后忽略直连误报的宽限期(毫秒)。 */
export const DIRECT_SEEK_GRACE_MS = 12_000
/** 直连起播看门狗的等待上限(毫秒)。 */
export const DIRECT_STALL_TIMEOUT_MS = 6_000
/** STRM / 云盘直链要等 302 换链,冷启动比本地文件慢,给长一点的期限。 */
export const DIRECT_STALL_TIMEOUT_STRM_MS = 12_000
/**
* 没有可退线路(远程 Emby 挂载、客户端直连解码)时的等待上限。
*
* 这两种源都不在宿主机转码,等不到数据时没有任何补救动作——唯一的输出是给用户
* 一条提示,所以宁可多等:跨网上游的冷启动实测要 8–11 秒才吐第一段数据,6 秒
* 就判失败只会把「上游慢」写成「浏览器不支持这个编码」,而画面其实几秒后就播上了。
*/
export const DIRECT_STALL_TIMEOUT_NO_FALLBACK_MS = 20_000
/**
* 起播看门狗该等多久才把「还没起来」当成「起不来」。
*
* canFallback=false:直连失败没有可切的线路(远程 Emby / 直连解码),失败提示
* 是唯一产出,所以要容忍慢启动。canFallback=true:还能切 HLS,早点换线路对用户
* 更有利,保持较短期限。
*/
export function directStallTimeoutMs(input: { isStrm?: boolean; canFallback: boolean }): number {
if (!input.canFallback) return DIRECT_STALL_TIMEOUT_NO_FALLBACK_MS
return input.isStrm ? DIRECT_STALL_TIMEOUT_STRM_MS : DIRECT_STALL_TIMEOUT_MS
}
/** 直连起播失败的两类起因:看门狗等超时(上游一直没数据)/ 媒体元素真的报了 error。 */
export type DirectPlayFailureReason = 'stall' | 'error'
/** 直连失败时用户看到的来源:远程 Emby 挂载 / 客户端直连解码。 */
export type DirectPlayFailureKind = 'remote-emby' | 'direct-only'
/**
* 直连失败的横幅与 toast 文案。
*
* 超时和真报错的文案必须分开:超时的真实原因是上游慢,不该断言「浏览器不支持这个
* 编码」(实测最常见的情形就是慢,画面随后自己就播上了)。
*/
export function directPlayFailureCopy(
kind: DirectPlayFailureKind,
reason: DirectPlayFailureReason = 'error',
): { banner: string; toast: string } {
if (reason === 'stall') {
return {
banner:
kind === 'remote-emby'
? '直接播放失败:远程 Emby 挂载的流长时间没有返回数据(该线路不转码,只能直连)。可稍后重试,或用外部播放器(如 PotPlayer / VLC / IINA)播放。'
: '直接播放失败:客户端直连解码模式下宿主机不转码,流长时间没有返回数据。可稍后重试,或关闭直连解码模式改用转码播放。',
toast: '直连流长时间没有响应',
}
}
return {
banner:
kind === 'remote-emby'
? '直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。'
: '直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。',
toast: '直接播放失败,建议使用外部播放器',
}
}
/**
* Chromium 常把被中断的直连(换 src、插入 track、302 未完成就 seek)
* 报成 error。真正不兼容应 fallback;瞬时中断应忽略或静默重试一次。
+40 -1
View File
@@ -1,4 +1,11 @@
import { classifyDirectPlayError } from './directPlayError.ts'
import {
classifyDirectPlayError,
directPlayFailureCopy,
directStallTimeoutMs,
DIRECT_STALL_TIMEOUT_MS,
DIRECT_STALL_TIMEOUT_NO_FALLBACK_MS,
DIRECT_STALL_TIMEOUT_STRM_MS,
} from './directPlayError.ts'
function check(name: string, cond: boolean) {
if (!cond) throw new Error(`classifyDirectPlayError: ${name}`)
@@ -96,4 +103,36 @@ check(
}) === 'ignore',
)
// 起播看门狗的期限:没有可退线路(远程 Emby / 直连解码)时必须容忍慢启动,
// 否则上游冷启动的 8–11 秒会被误报成「编码不兼容」。
check('local direct stall waits 6s', directStallTimeoutMs({ canFallback: true }) === DIRECT_STALL_TIMEOUT_MS)
check(
'strm direct stall waits 12s',
directStallTimeoutMs({ isStrm: true, canFallback: true }) === DIRECT_STALL_TIMEOUT_STRM_MS,
)
check(
'no fallback line waits 20s',
directStallTimeoutMs({ canFallback: false }) === DIRECT_STALL_TIMEOUT_NO_FALLBACK_MS,
)
check(
'no fallback line ignores strm tuning',
directStallTimeoutMs({ isStrm: true, canFallback: false }) === DIRECT_STALL_TIMEOUT_NO_FALLBACK_MS,
)
const stallCopy = directPlayFailureCopy('remote-emby', 'stall')
check('stall copy blames the upstream, not the codec', !stallCopy.banner.includes('不支持'))
check('stall copy keeps the failure prefix', stallCopy.banner.startsWith('直接播放失败'))
const decodeCopy = directPlayFailureCopy('remote-emby', 'error')
check('decode copy still mentions unsupported codec', decodeCopy.banner.includes('不支持'))
check(
'copy defaults to the decode wording',
directPlayFailureCopy('remote-emby').banner === decodeCopy.banner,
)
check(
'direct-only copy names the client-decode mode',
directPlayFailureCopy('direct-only', 'stall').banner.includes('客户端直连解码') &&
directPlayFailureCopy('direct-only', 'error').banner.includes('客户端直连解码'),
)
check('stall toast is not the external-player advice', stallCopy.toast !== decodeCopy.toast)
console.log('playerPageModel.test.ts ok')
+2 -2
View File
@@ -1,5 +1,5 @@
import type { ReaderBook } from '../../api/reader'
import { formatRelativeTime, sortShelfBooks, unreadChapters } from './bookshelfModel'
import type { ReaderBook } from '../../api/reader.ts'
import { formatRelativeTime, sortShelfBooks, unreadChapters } from './bookshelfModel.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`bookshelfModel: ${name}`)
+146
View File
@@ -5,6 +5,9 @@ import {
RefreshRequestError,
createRefreshCoordinator,
createSingleFlightRunner,
createTokenPreflight,
decodeJwtExpiryMs,
isAccessTokenStale,
refreshFailureOutcome,
type RefreshOutcome,
type RefreshTokens,
@@ -30,6 +33,149 @@ check('timeout is transient', refreshFailureOutcome(408) === 'transient')
check('429 is transient', refreshFailureOutcome(429) === 'transient')
check('unexpected status is transient', refreshFailureOutcome(418) === 'transient')
// ── JWT 过期判定:首屏放行前必须能识别出「已经过期的 access token」 ────────
// 现场(2026-10-16 12:49):刷新页面时本地 token 已过期,React 首屏并发发出
// 7 个受保护请求,全部 401,控制台一次性刷出 7 行红字;约 2 秒后才由拦截器
// 刷新成功并重放。修复要求首屏就认得过期 token,先刷新再放行。
function jwtWithExp(exp: number | string | null): string {
const encode = (value: unknown) =>
Buffer.from(JSON.stringify(value)).toString('base64url')
const claims: Record<string, unknown> = { uid: 'u1' }
if (exp !== null) claims.exp = exp
return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(claims)}.signature`
}
const NOW = 1_700_000_000_000
check('jwt expiry is decoded to milliseconds',
decodeJwtExpiryMs(jwtWithExp(NOW / 1000 + 3600)) === NOW + 3600_000)
check('jwt without exp decodes to null', decodeJwtExpiryMs(jwtWithExp(null)) === null)
check('jwt with non-numeric exp decodes to null', decodeJwtExpiryMs(jwtWithExp('soon')) === null)
check('opaque token decodes to null', decodeJwtExpiryMs('not-a-jwt') === null)
check('empty token decodes to null', decodeJwtExpiryMs('') === null)
check('malformed payload decodes to null', decodeJwtExpiryMs('aaa.!!!.ccc') === null)
// 已过期 / 即将过期的 token 必须在首屏就被拦下。
check('expired token is stale', isAccessTokenStale(jwtWithExp(NOW / 1000 - 1), NOW))
check('token expiring inside the skew is stale',
isAccessTokenStale(jwtWithExp(NOW / 1000 + 20), NOW))
check('token outside the skew is fresh',
isAccessTokenStale(jwtWithExp(NOW / 1000 + 3600), NOW) === false)
// 边界:正好落在 skew 上算过期(留出余量,避免刷新途中再次过期)。
check('token exactly at the skew boundary is stale',
isAccessTokenStale(jwtWithExp(NOW / 1000 + 30), NOW))
// 没有 token 或 token 无法解析时不能判定为「过期」:
// 前者由路由守卫处理,后者强行刷新会变成无休止的刷新循环。
check('missing token is not stale', isAccessTokenStale(null, NOW) === false)
check('opaque token is not stale', isAccessTokenStale('opaque', NOW) === false)
// ── 发送前预检:本地 token 已过期就先刷新,再让请求带新令牌出去 ────────────
// 现场(2026-10-16 12:49):刷新页面时本地 token 已过期,React 首屏并发发出
// 7 个受保护请求,全部 401,控制台一次刷出 7 行红字;约 2 秒后才由 401 拦截器
// 刷新成功并重放。修复要求请求在发出前就认得过期 token,先刷新再发送。
async function expectPreflight(
name: string,
run: Promise<void>,
want: 'resolved' | 'rejected',
) {
let outcome: 'resolved' | 'rejected' = 'resolved'
try {
await run
} catch {
outcome = 'rejected'
}
if (outcome !== want) throw new Error(`authRefresh: ${name} = ${outcome}, want ${want}`)
}
function preflightHarness(overrides: {
accessToken?: string | null
refresh?: () => Promise<RefreshOutcome>
}) {
const state = { token: overrides.accessToken === undefined ? 'at-1' : overrides.accessToken }
const calls = { refresh: 0 }
const ensure = createTokenPreflight({
readAccessToken: () => state.token,
refreshSession: async () => {
calls.refresh += 1
if (overrides.refresh) return overrides.refresh()
state.token = jwtWithExp(Date.now() / 1000 + 3600)
return 'refreshed'
},
now: () => NOW,
})
return { state, calls, ensure }
}
{
// 令牌新鲜:不得产生任何刷新请求。
const { calls, ensure } = preflightHarness({ accessToken: jwtWithExp(NOW / 1000 + 3600) })
await expectPreflight('fresh token passes straight through', ensure(), 'resolved')
check('fresh token skips the refresh', calls.refresh === 0)
}
{
// 核心回归:过期令牌必须先刷新,且刷新完成后才返回(调用方据此发请求)。
const { state, calls, ensure } = preflightHarness({ accessToken: jwtWithExp(NOW / 1000 - 60) })
await expectPreflight('stale token refreshes before sending', ensure(), 'resolved')
check('stale token refreshed exactly once', calls.refresh === 1)
check('preflight handed out a fresh token',
!isAccessTokenStale(state.token, NOW))
}
{
// 并发首屏:7 个请求同时预检,只允许刷新一次(协调器本身是单飞,
// 这里确认预检不会绕过它)。
const { calls, ensure } = preflightHarness({ accessToken: jwtWithExp(NOW / 1000 - 60) })
await Promise.all([ensure(), ensure(), ensure(), ensure(), ensure(), ensure(), ensure()])
check('concurrent preflight refreshes once', calls.refresh === 1)
}
{
// 瞬时故障(部署/重启窗口):保留会话,照常发出请求——
// 页面自己会重试,不该把人挡在门外。
const { ensure } = preflightHarness({
accessToken: jwtWithExp(NOW / 1000 - 60),
refresh: async () => 'transient',
})
await expectPreflight('transient failure still sends the request', ensure(), 'resolved')
}
{
// 服务端明确判定凭证失效:不发请求,直接失败,避免刷出无意义的 401。
const { ensure } = preflightHarness({
accessToken: jwtWithExp(NOW / 1000 - 60),
refresh: async () => 'invalid',
})
await expectPreflight('revoked session refuses to send', ensure(), 'rejected')
}
{
// 未登录:交给路由守卫和 401 路径处理,预检不替它们做决定。
const { calls, ensure } = preflightHarness({ accessToken: null })
await expectPreflight('anonymous request is not preflighted', ensure(), 'resolved')
check('anonymous request does not refresh', calls.refresh === 0)
}
{
// 非 JWT 令牌(Emby 等外部客户端签发):读不出 exp,按新鲜处理,
// 绝不能因此陷入刷新循环。
const { calls, ensure } = preflightHarness({ accessToken: 'opaque-token' })
await expectPreflight('opaque token passes through', ensure(), 'resolved')
check('opaque token does not refresh', calls.refresh === 0)
}
{
// 刷新抛出异常(网络不可达):按瞬时故障处理,请求照发。
const { ensure } = preflightHarness({
accessToken: jwtWithExp(NOW / 1000 - 60),
refresh: async () => {
throw new Error('network down')
},
})
await expectPreflight('throwing refresh still sends the request', ensure(), 'resolved')
}
// ── 单飞:并发调用共享同一次执行,结束后可重新执行 ────────────────────────
{
let calls = 0
+78
View File
@@ -15,6 +15,46 @@
/** 一次刷新尝试的结果。 */
export type RefreshOutcome = 'refreshed' | 'invalid' | 'transient'
/**
* access token 过期前多久就视为失效。刷新要留出余量:刚好卡在 exp 上
* 刷新,重放的原请求可能在服务端又已经过期。
*/
export const TOKEN_EXPIRY_SKEW_MS = 30_000
/**
* 解析 JWT 的 exp 声明并换算成毫秒时间戳。
*
* 解析失败(非 JWT、base64url 损坏、缺少 exp、exp 不是数字)一律返回 null:
* 调用方据此放行,绝不能把「读不懂」当成「已过期」,否则不透明的令牌会导致
* 无休止的刷新循环。
*/
export function decodeJwtExpiryMs(token: string | null | undefined): number | null {
if (!token) return null
const segment = token.split('.')[1]
if (!segment) return null
try {
// JWT 使用 base64url 编码,需先还原字符集并补齐 padding。
const normalized = segment.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const claims = JSON.parse(atob(padded)) as { exp?: unknown }
return typeof claims.exp === 'number' ? claims.exp * 1000 : null
} catch {
return null
}
}
/**
* token 是否已过期或即将过期。
*
* 首屏放行前用它做一次判断:本地持久化的 token 往往已经过期,直接发请求只
* 会一次性刷出一屏 401。读不出 exp 的令牌按「新鲜」处理(见 decodeJwtExpiryMs)。
*/
export function isAccessTokenStale(token: string | null | undefined, nowMs = Date.now()): boolean {
const expiry = decodeJwtExpiryMs(token)
if (expiry === null) return false
return nowMs >= expiry - TOKEN_EXPIRY_SKEW_MS
}
/** 刷新接口返回的令牌对。 */
export interface RefreshTokens {
token: string
@@ -101,6 +141,44 @@ export function createBrowserExclusiveRunner(
}
}
export interface SessionGateDeps {
/** 读取当前 access token(可能来自持久化存储,往往已经过期)。 */
readAccessToken: () => string | null
/** 刷新会话;语义与 useAuthStore.refreshSession 一致。 */
refreshSession: () => Promise<RefreshOutcome>
/** 可注入的时间源,便于测试。 */
now?: () => number
}
/**
* 构造请求前预检:每个请求发出前调用,必要时先把过期的令牌换新。
*
* 本地持久化的 access token 常常已经过期。此时若直接发请求,首屏那些各自
* 独立的 useEffect 会在同一帧里并发打出受保护请求,全部拿到 401,控制台
* 一次刷出一整屏红字,用户还要等 401 拦截器刷新完才能看到内容
* (现场:2026-10-16 12:49,7 个请求同时 401)。
*
* 预检把「先刷新、再发送」放在请求发出之前:
* - token 新鲜(或无法解析 exp)→ 立即返回;
* - token 已过期 → 等待刷新(刷新协调器自带单飞,并发请求只发一次);
* - 刷新遇到瞬时故障 → 照常发送(页面自己会重试);
* - 服务端明确判定凭证失效 → 抛出,请求根本不发出去。
*
* 未登录(没有 token)直接返回:跳登录页是路由守卫的职责。
*/
export function createTokenPreflight(deps: SessionGateDeps): () => Promise<void> {
return async () => {
const token = deps.readAccessToken()
if (!token) return
const now = deps.now ?? Date.now
if (!isAccessTokenStale(token, now())) return
const outcome = await deps.refreshSession().catch((): RefreshOutcome => 'transient')
if (outcome === 'invalid') {
throw new Error('session expired')
}
}
}
export interface RefreshCoordinatorDeps {
readAccessToken: () => string | null
readRefreshToken: () => string | null
+1 -1
View File
@@ -1,4 +1,4 @@
import { CHAPTER_GROUP_SIZE, buildChapterGroups, chapterGroupIndexOf } from './chapterGroups'
import { CHAPTER_GROUP_SIZE, buildChapterGroups, chapterGroupIndexOf } from './chapterGroups.ts'
// 目录分段分组的纯逻辑回归测试(对齐 utils/readerBookGroups.test.ts 的写法)。
+1 -1
View File
@@ -10,7 +10,7 @@ import {
normalizeBookGroups,
reorderBookGroups,
resolveSelectedGroupId,
} from './readerBookGroups'
} from './readerBookGroups.ts'
// 书架分组的纯逻辑回归测试(对齐 utils/libraryTags.test.ts 的写法)。