mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
254 lines
8.9 KiB
Go
254 lines
8.9 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
type remoteImageFetchClient struct {
|
|
name string
|
|
client *http.Client
|
|
}
|
|
|
|
func (p *ImageProxy) remoteImageFetchClients(host string) []remoteImageFetchClient {
|
|
client := p.client
|
|
if client == nil {
|
|
client = NewExternalHTTPClient(30 * time.Second)
|
|
}
|
|
if _, ok := client.Transport.(*http.Transport); !ok {
|
|
return []remoteImageFetchClient{{name: "default", client: client}}
|
|
}
|
|
timeout := client.Timeout
|
|
if timeout <= 0 {
|
|
timeout = 30 * time.Second
|
|
}
|
|
direct := p.directClient
|
|
if direct == nil {
|
|
direct = &http.Client{Timeout: timeout, Transport: NewInternalTransport()}
|
|
}
|
|
directCandidate := remoteImageFetchClient{name: "direct", client: direct}
|
|
defaultCandidate := remoteImageFetchClient{name: "default", client: client}
|
|
// Hosts the user configured themselves — remote Emby mounts and their image
|
|
// endpoints — are reached over the LAN or a dedicated line. Routing those
|
|
// through the OS/env proxy first costs a failed attempt before every single
|
|
// image, so try the direct client first for them.
|
|
if p.isAllowedRemoteHost(host) {
|
|
return []remoteImageFetchClient{directCandidate, defaultCandidate}
|
|
}
|
|
return []remoteImageFetchClient{defaultCandidate, directCandidate}
|
|
}
|
|
|
|
func (p *ImageProxy) canUseExternalImageFallback() bool {
|
|
if p == nil || p.client == nil {
|
|
return false
|
|
}
|
|
_, ok := p.client.Transport.(*http.Transport)
|
|
return ok
|
|
}
|
|
|
|
// remoteImageFetchResult 是一次成功拉取的结果。正常路径图片已经流式落盘
|
|
// (data 为空,调用方从缓存文件下发/缩放);只有缓存目录不可写、退回内存
|
|
// 缓冲时才带 data,保证图片仍能发给客户端。
|
|
type remoteImageFetchResult struct {
|
|
data []byte
|
|
contentType string
|
|
}
|
|
|
|
// fetchRemoteImageOnce 拉取一次上游图片并写入 cachePath。响应体直接流式落盘,
|
|
// 不再整张读进内存。
|
|
func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string, candidate remoteImageFetchClient, cachePath, failPath string) (remoteImageFetchResult, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
|
if err != nil {
|
|
p.log.Warn("imageproxy: build request failed", zap.String("url", redactSensitiveURL(raw)), zap.Error(redactSensitiveError(err)))
|
|
return remoteImageFetchResult{}, errImageProxyRequestSetup
|
|
}
|
|
applyRemoteImageHeaders(req, host, raw)
|
|
|
|
resp, err := candidate.client.Do(req)
|
|
if err != nil {
|
|
logImageFetchError(p.log, "imageproxy: upstream fetch failed", host, candidate.name, err)
|
|
return remoteImageFetchResult{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
p.log.Warn("imageproxy: upstream returned non-OK", zap.String("host", host), zap.String("client", candidate.name), zap.String("status", resp.Status))
|
|
return remoteImageFetchResult{}, errors.New("upstream returned " + resp.Status)
|
|
}
|
|
if err := p.streamImageToCache(cachePath, failPath, resp.Body); err != nil {
|
|
if errors.Is(err, errImageCacheUnavailable) {
|
|
// 缓存目录不可写:响应体还没读,退回内存缓冲。
|
|
return p.bufferRemoteImage(resp, host, candidate.name, cachePath, failPath)
|
|
}
|
|
logImageFetchError(p.log, "imageproxy: stream image failed", host, candidate.name, err)
|
|
return remoteImageFetchResult{}, err
|
|
}
|
|
return remoteImageFetchResult{}, nil
|
|
}
|
|
|
|
// bufferRemoteImage 在缓存不可用时把响应体读进内存,校验为图片后尽力写入
|
|
// 缓存(失败也不影响本次下发)。
|
|
func (p *ImageProxy) bufferRemoteImage(resp *http.Response, host, client, cachePath, failPath string) (remoteImageFetchResult, error) {
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, imageProxyMaxDownloadBytes))
|
|
if err != nil || len(data) == 0 {
|
|
p.log.Warn("imageproxy: read upstream body failed", zap.String("host", host), zap.String("client", client), zap.Error(redactSensitiveError(err)))
|
|
if err == nil {
|
|
err = errors.New("upstream image body is empty")
|
|
}
|
|
return remoteImageFetchResult{}, err
|
|
}
|
|
ctype, ok := validImageContentType(data)
|
|
if !ok {
|
|
p.log.Warn("imageproxy: upstream returned non-image content", zap.String("host", host), zap.String("client", client), zap.String("content_type", resp.Header.Get("Content-Type")))
|
|
return remoteImageFetchResult{}, errImageProxyNonImageContent
|
|
}
|
|
p.writeImageCache(cachePath, failPath, "img-*.tmp", data)
|
|
return remoteImageFetchResult{data: data, contentType: ctype}, nil
|
|
}
|
|
|
|
func logImageFetchError(log *zap.Logger, message, host, client string, err error) {
|
|
if log == nil || err == nil {
|
|
return
|
|
}
|
|
fields := []zap.Field{
|
|
zap.String("host", host),
|
|
zap.String("client", client),
|
|
zap.Error(redactSensitiveError(err)),
|
|
}
|
|
if errors.Is(err, context.Canceled) {
|
|
log.Debug(message, fields...)
|
|
return
|
|
}
|
|
log.Warn(message, fields...)
|
|
}
|
|
|
|
func applyRemoteImageHeaders(req *http.Request, host, raw string) {
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36")
|
|
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
|
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,ja;q=0.8,en;q=0.7")
|
|
req.Header.Set("Cache-Control", "no-cache")
|
|
req.Header.Set("Pragma", "no-cache")
|
|
if cookie := remoteImageCookie(host); cookie != "" {
|
|
req.Header.Set("Cookie", cookie)
|
|
}
|
|
if referer := remoteImageReferer(host, raw); referer != "" {
|
|
req.Header.Set("Referer", referer)
|
|
}
|
|
}
|
|
|
|
func remoteImageCookie(host string) string {
|
|
h := strings.ToLower(strings.TrimSpace(host))
|
|
switch {
|
|
case strings.Contains(h, "javbus") || strings.Contains(h, "busjav") || strings.Contains(h, "dmmbus") || strings.Contains(h, "javsee") || strings.Contains(h, "cdnbus"):
|
|
return "age=verified; existmag=all"
|
|
case strings.Contains(h, "javdb") || strings.Contains(h, "jdbstatic"):
|
|
return "over18=1"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func remoteImageReferer(host, raw string) string {
|
|
h := strings.ToLower(strings.TrimSpace(host))
|
|
switch {
|
|
case strings.Contains(h, "doubanio.com"):
|
|
return "https://movie.douban.com/"
|
|
case strings.Contains(h, "bgm.tv"):
|
|
return "https://bgm.tv/"
|
|
case strings.Contains(h, "javbus") || strings.Contains(h, "busjav") || strings.Contains(h, "dmmbus") || strings.Contains(h, "javsee"):
|
|
return "https://www.javbus.com/"
|
|
case strings.Contains(h, "javdb") || strings.Contains(h, "jdbstatic"):
|
|
return "https://javdb.com/"
|
|
case strings.Contains(h, "dmm.co.jp") || strings.Contains(h, "dmm.com"):
|
|
return "https://www.dmm.co.jp/"
|
|
case strings.Contains(h, "mgstage.com"):
|
|
return "https://www.mgstage.com/"
|
|
case strings.Contains(h, "faleno.jp"):
|
|
return "https://faleno.jp/"
|
|
case strings.Contains(h, "fc2.com"):
|
|
return "https://adult.contents.fc2.com/"
|
|
case h != "":
|
|
scheme := "https"
|
|
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(raw)), "http://") {
|
|
scheme = "http"
|
|
}
|
|
return scheme + "://" + h + "/"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func isDoubanImageHost(host string) bool {
|
|
return strings.Contains(strings.ToLower(host), "doubanio.com")
|
|
}
|
|
|
|
func fetchRemoteImageWithCurl(ctx context.Context, raw, host string) ([]byte, string, string, error) {
|
|
bin, err := exec.LookPath("curl")
|
|
if err != nil {
|
|
return nil, "", "", err
|
|
}
|
|
curlCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
|
defer cancel()
|
|
|
|
args := []string{
|
|
"--fail",
|
|
"--location",
|
|
"--silent",
|
|
"--show-error",
|
|
"--http1.1",
|
|
"--max-time", "20",
|
|
"--proto", "=http,https",
|
|
"--proto-redir", "=http,https",
|
|
"--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36",
|
|
"--header", "Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
|
"--header", "Accept-Language: zh-CN,zh;q=0.9,en;q=0.8",
|
|
"--header", "Cache-Control: no-cache",
|
|
"--header", "Pragma: no-cache",
|
|
}
|
|
if referer := remoteImageReferer(host, raw); referer != "" {
|
|
args = append(args, "--referer", referer)
|
|
}
|
|
if cookie := remoteImageCookie(host); cookie != "" {
|
|
args = append(args, "--cookie", cookie)
|
|
}
|
|
args = append(args, "--", raw)
|
|
|
|
cmd := exec.CommandContext(curlCtx, bin, args...) // #nosec G204 -- bin is resolved by LookPath and args are not shell-expanded.
|
|
stderr := bytes.Buffer{}
|
|
cmd.Stderr = &stderr
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, "", "", err
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, "", "", err
|
|
}
|
|
data, readErr := io.ReadAll(io.LimitReader(stdout, 32<<20))
|
|
waitErr := cmd.Wait()
|
|
if readErr != nil {
|
|
return nil, "", "", readErr
|
|
}
|
|
if waitErr != nil {
|
|
message := strings.TrimSpace(stderr.String())
|
|
if message != "" {
|
|
return nil, "", "", redactSensitiveError(errors.New(message))
|
|
}
|
|
return nil, "", "", redactSensitiveError(waitErr)
|
|
}
|
|
if len(data) == 0 {
|
|
return nil, "", "", errors.New("curl image body is empty")
|
|
}
|
|
ctype := detectContentType(data)
|
|
if !isImageContentType(ctype) || isTransparentPlaceholderData(data) {
|
|
return nil, "", "", errors.New("curl returned non-image content")
|
|
}
|
|
return data, ctype, "", nil
|
|
}
|