mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
194 lines
6.1 KiB
Go
194 lines
6.1 KiB
Go
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
|
|
}
|
|
|